diff --git a/agenda_view.go b/agenda_view.go new file mode 100644 index 0000000..e3401c1 --- /dev/null +++ b/agenda_view.go @@ -0,0 +1,149 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// AgendaViewOptions configure an AgendaView. +type AgendaViewOptions struct { + // Location is the zone days are cut in; required. + Location *time.Location + // Version must change when events change in place. + Version int + // From is the first day listed; zero means today. + From Date + // Days is how many days are listed; zero means 30. + Days int + // Today is marked; zero uses the system clock. + Today Date + Hour12 bool + Disabled bool + Label string +} + +// agendaRow is a day heading (ev < 0) or event. +type agendaRow struct { + day Date + ev int +} + +// AgendaView lists events by day, building only visible rows. +func AgendaView(c *ui.Context, events []CalendarEvent, opts AgendaViewOptions) CalendarViewResult { + loc := eventLocation("AgendaView", opts.Location) + if opts.Days < 0 { + panic("mujica: AgendaView Days is negative") + } + days := opts.Days + if days == 0 { + days = 30 + } + eventsCheck("AgendaView", events) + k := Tokens(c) + d := Density(c) + today := datetimeToday(c, opts.Today) + from := opts.From + if from.IsZero() { + from = today + } + datetimeCheckDate("AgendaView From", from) + label := opts.Label + if label == "" { + label = msg(c, "agenda_view.label") + } + r := CalendarViewResult{} + root := ui.Column(c).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + rows := eventsDerived(root, "agenda-rows", events, opts.Version, eventDayStart(from, loc), eventDayStart(from.AddDays(days), loc), + func(hits []int) []agendaRow { return agendaRows(events, hits, from, days, loc) }) + state := ui.Local(root, "agenda-list", func() *ui.ListState { return &ui.ListState{} }) + st := *state + st.Key = func(i int) any { return rows[i] } + st.Header = func(i int) bool { return rows[i].ev < 0 } + st.Label = func(i int) string { + if rows[i].ev < 0 { + return datetimeDayLabel(c, rows[i].day) + } + return events[rows[i].ev].Title + } + root.Children(func() { + list := ui.List(c, st, len(rows), func(i int) { + row := rows[i] + if row.ev < 0 { + h := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit() * 2).PaddingX(d.Unit() * 3).Background(k.Background).Role(ui.RoleColumnHeader) + h.Children(func() { + t := ui.Text(c, datetimeDayLabel(c, row.day)).Font(theme.SerifFont).TextColor(k.Text) + if row.day == today { + t.Bold().TextColor(k.AccentText) + ui.Text(c, msg(c, "agenda_view.today")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText) + } + }) + h.DrawOver(func(p *ui.Painter, rr ui.Rect) { internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+rr.H-1, k.Ornament) }) + return + } + if agendaItem(c, events[row.ev], loc, opts.Hour12) && !root.IsDisabled() { + r.opened = events[row.ev].ID + } + }).Grow(1).MinHeight(d.ControlHeight() * 4).FocusGroup(ui.Vertical) + list.Children(func() { + if len(rows) == 0 { + ui.Text(c, fmt.Sprintf(msg(c, "agenda_view.empty"), days)).TextColor(k.TextMuted).Padding(d.Unit() * 3) + } + }) + }) + return r +} + +// agendaRows flattens the span into headed days. +func agendaRows(events []CalendarEvent, hits []int, from Date, days int, loc *time.Location) []agendaRow { + buckets := make([][]int, days) + for _, h := range hits { + ev := events[h] + first := max(0, monthDays(ev.Start.In(loc), from)*-1) + last := min(days-1, -monthDays(ev.End.Add(-time.Nanosecond).In(loc), from)) + for i := first; i <= last; i++ { + buckets[i] = append(buckets[i], h) + } + } + var out []agendaRow + for i, b := range buckets { + if len(b) > 0 { + out = append(out, agendaRow{from.AddDays(i), -1}) + for _, h := range b { + out = append(out, agendaRow{from.AddDays(i), h}) + } + } + } + return out +} + +// agendaItem is one event row; it reports a click. +func agendaItem(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) bool { + k := Tokens(c) + d := Density(c) + when := eventTimeText(c, ev, loc, hour12) + b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(d.Unit() * 3).PaddingX(d.Unit() * 3).Label(ev.Title + ", " + when) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + col := eventColor(k, ev.Color) + b.Children(func() { + ui.Text(c, when).Width(d.ControlHeight() * 3.5).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), col) + }) + ui.Column(c).Grow(1).Shrink(1).Children(func() { + ui.Text(c, ev.Title).TextColor(k.Text).SingleLine().Ellipsis("…") + if ev.Location != "" { + ui.Text(c, ev.Location).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…") + } + }) + }) + return b.Clicked() +} diff --git a/agenda_view_test.go b/agenda_view_test.go new file mode 100644 index 0000000..630c7bd --- /dev/null +++ b/agenda_view_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func agendaTester(events []CalendarEvent, opts AgendaViewOptions, opened *[]string) *ui.Tester { + opts.Location, opts.Today = calTokyo, calendarToday + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(500).Children(func() { + if id, ok := AgendaView(c, events, opts).Opened(); ok { + *opened = append(*opened, id) + } + }) + }, 600, 520) +} + +func TestAgendaViewGroupsByDay(t *testing.T) { + evs := []CalendarEvent{calEvent("b", 7, 14, 0, 60), calEvent("a", 7, 9, 0, 30), calEvent("c", 9, 10, 0, 60), calEvent("old", 1, 9, 0, 60)} + evs[0].Location = "Rehearsal hall" + var opened []string + tt := agendaTester(evs, AgendaViewOptions{}, &opened) + texts := tt.Texts() + idx := func(s string) int { + for i, x := range texts { + if x == s { + return i + } + } + return -1 + } + d7, a, b, d9, c := idx("Wednesday, October 7, 2026"), idx("Event a, 09:00 – 09:30"), idx("Event b, 14:00 – 15:00"), idx("Friday, October 9, 2026"), idx("Event c, 10:00 – 11:00") + if d7 < 0 || !(d7 < a && a < b && b < d9 && d9 < c) || tt.HasText("Event old") || !tt.HasText("Rehearsal hall") { + t.Fatalf("order %d %d %d %d %d in %v", d7, a, b, d9, c, texts) + } + _ = tt.Click("Event b, 14:00 – 15:00") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(opened) != 2 || opened[0] != "b" || opened[1] != "c" { + t.Fatalf("opened %v", opened) + } +} + +func TestAgendaViewMultiDayEmptyAndDisabled(t *testing.T) { + trip := calEvent("t", 6, 20, 0, 26*60) + var opened []string + tt := agendaTester([]CalendarEvent{trip}, AgendaViewOptions{Days: 3}, &opened) + if !tt.HasText("Tuesday, October 6, 2026") || !tt.HasText("Wednesday, October 7, 2026") { + t.Fatalf("a multi-day event lists on each day: %v", tt.Texts()) + } + tt = agendaTester(nil, AgendaViewOptions{Days: 7}, &opened) + if !tt.HasText("No events in the next 7 days.") { + t.Fatal("no empty state") + } + tt = agendaTester([]CalendarEvent{calEvent("a", 6, 9, 0, 30)}, AgendaViewOptions{Disabled: true}, &opened) + _ = tt.Click("Event a, 09:00 – 09:30") + if len(opened) != 0 { + t.Fatal("disabled agenda opened") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + agendaTester(nil, AgendaViewOptions{Days: -1}, &opened) +} + +func TestAgendaViewLargeData(t *testing.T) { + var evs []CalendarEvent + base := time.Date(2026, time.October, 5, 0, 0, 0, 0, calTokyo) + for i := range 100000 { + s := base.Add(time.Duration(i%(365*24)) * time.Hour) + evs = append(evs, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(30 * time.Minute)}) + } + var opened []string + tt := agendaTester(evs, AgendaViewOptions{Days: 365}, &opened) + if n := len(tt.Texts()); n > 200 { + t.Fatalf("built %d labels", n) + } +} + +func TestAgendaViewSnapshot(t *testing.T) { + evs := []CalendarEvent{calEvent("a", 5, 9, 0, 30), calEvent("b", 5, 14, 0, 60), calEvent("c", 7, 10, 0, 60), calEvent("d", 8, 18, 0, 120)} + evs[1].Location, evs[1].Color, evs[3].Color = "Rehearsal hall", EventInfo, EventSuccess + snapshot(t, "agenda_view", 520, 420, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(420).Children(func() { + AgendaView(c, evs, AgendaViewOptions{Location: calTokyo, Today: calendarToday}) + }) + }) +} diff --git a/attendee_list.go b/attendee_list.go index 32b3173..a1a8568 100644 --- a/attendee_list.go +++ b/attendee_list.go @@ -46,7 +46,7 @@ type AttendeeListResult struct { // 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. +// AttendeeList shows invitees, their replies and a summary. func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult { k := Tokens(c) d := Density(c) @@ -71,7 +71,7 @@ func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) 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() { + 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 diff --git a/attendee_list_test.go b/attendee_list_test.go new file mode 100644 index 0000000..1f9b350 --- /dev/null +++ b/attendee_list_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var calAttendees = []Attendee{ + {Name: "Sakiko Togawa", Email: "sakiko@court.example", Status: AttendeeAccepted, Organizer: true}, + {Name: "Mutsumi Wakaba", Status: AttendeeTentative}, + {Name: "Uika Misumi", Email: "uika@court.example", Status: AttendeeDeclined, Optional: true}, + {Name: "Nyamu Yuutenji", Status: AttendeePending}, +} + +func attendeeTester(list *[]Attendee, opts AttendeeListOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if i, ok := AttendeeList(c, *list, opts).Removed(); ok { + *list = append((*list)[:i:i], (*list)[i+1:]...) + } + }, 480, 400) +} + +func TestAttendeeListStatusesAndSummary(t *testing.T) { + list := append([]Attendee(nil), calAttendees...) + tt := attendeeTester(&list, AttendeeListOptions{}) + for _, s := range []string{"1 accepted · 1 maybe · 1 declined · 1 awaiting", "Sakiko Togawa · Organizer", + "Uika Misumi · Optional", "Accepted", "Maybe", "Declined", "Awaiting reply", "uika@court.example"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %v", s, tt.Texts()) + } + } + if tt.HasText("Remove Mutsumi Wakaba") { + t.Fatal("remove buttons without Removable") + } +} + +func TestAttendeeListRemoveKeyboardAndOrganizer(t *testing.T) { + list := append([]Attendee(nil), calAttendees...) + tt := attendeeTester(&list, AttendeeListOptions{Removable: true}) + if tt.HasText("Remove Sakiko Togawa") { + t.Fatal("the organizer cannot be removed") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(list) != 3 || list[0].Name != "Sakiko Togawa" || list[1].Name != "Uika Misumi" { + t.Fatalf("keyboard remove left %v", list) + } + _ = tt.Click("Remove Uika Misumi") + if len(list) != 2 || !tt.HasText("1 accepted · 0 maybe · 0 declined · 1 awaiting") { + t.Fatalf("click remove left %v", list) + } +} + +func TestAttendeeListDisabledEmptyInvalid(t *testing.T) { + list := append([]Attendee(nil), calAttendees...) + tt := attendeeTester(&list, AttendeeListOptions{Removable: true, Disabled: true}) + _ = tt.Click("Remove Mutsumi Wakaba") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(list) != 4 { + t.Fatal("disabled list removed someone") + } + empty := []Attendee{} + if tt := attendeeTester(&empty, AttendeeListOptions{}); !tt.HasText("No attendees yet.") { + t.Fatal("no empty state") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic on bad status") + } + }() + bad := []Attendee{{Name: "X", Status: 7}} + attendeeTester(&bad, AttendeeListOptions{}) +} + +func TestAttendeeListSnapshot(t *testing.T) { + snapshot(t, "attendee_list", 420, 260, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { AttendeeList(c, calAttendees, AttendeeListOptions{Removable: true}) }) + }) +} diff --git a/availability_picker.go b/availability_picker.go new file mode 100644 index 0000000..b7dc491 --- /dev/null +++ b/availability_picker.go @@ -0,0 +1,180 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// AvailabilityPerson is another person's free slots, [day][slot]. +type AvailabilityPerson struct { + Name string + Free [][]bool +} + +// AvailabilityPickerOptions configure an AvailabilityPicker. +type AvailabilityPickerOptions struct { + // Days are the grid's columns; required. + Days []Date + // From and To are hours; zero means 9 to 18. + From, To int + // SlotMinutes must divide an hour; zero means 30. + SlotMinutes int + // Others are counted as free per slot. + Others []AvailabilityPerson + Hour12 bool + Disabled bool + Label string +} + +// availabilityDrag is a paint stroke: the value being painted. +type availabilityDrag struct { + active bool + paint bool +} + +// AvailabilityPicker paints free slots and counts others' overlap. +func AvailabilityPicker(c *ui.Context, mine *[][]bool, opts AvailabilityPickerOptions) ChoiceResult { + from, to := opts.From, opts.To + if from == 0 && to == 0 { + from, to = 9, 18 + } + slot := timeGridSlot("AvailabilityPicker", opts.SlotMinutes) + if len(opts.Days) == 0 || from < 0 || to > 24 || to <= from || slot > 60 { + panic(fmt.Sprintf("mujica: AvailabilityPicker needs days and hours 0 <= %d < %d <= 24", from, to)) + } + slots := (to - from) * 60 / slot + availabilityCheck("value", *mine, len(opts.Days), slots) + for _, p := range opts.Others { + availabilityCheck(p.Name, p.Free, len(opts.Days), slots) + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "availability_picker.label") + } + gutter := d.ControlHeight() * 1.8 + rowH := float32(theme.MinHitSize) + headH := d.ControlHeight() + changed := false + set := func(day, s int, v bool) { + if (*mine)[day][s] != v { + (*mine)[day][s], changed = v, true + } + } + grid := ui.Column(c).Focusable().Role(ui.RoleTable).Label(label).Disabled(opts.Disabled) + internal.Ring(grid, k.Focus, theme.ControlRadius) + drag := ui.Local(grid, "availability-drag", func() availabilityDrag { return availabilityDrag{} }) + cursor := ui.Local(grid, "availability-cursor", func() [2]int { return [2]int{} }) + if !grid.IsDisabled() { + b := grid.Bounds() + px, py, _ := grid.PointerPosition() + cw := (b.W - gutter) / float32(len(opts.Days)) + day, s := int((px-gutter)/max(cw, 1)), int((py-headH)/rowH) + inside := px >= gutter && py >= headH && day < len(opts.Days) && s < slots + if _, _, pressing := grid.Dragged(); pressing && inside { + if !drag.active { + *drag = availabilityDrag{true, !(*mine)[day][s]} + *cursor = [2]int{day, s} + } + set(day, s, drag.paint) + } else if !pressing { + drag.active = false + } + for key, dd := range map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, -1}, ui.KeyDown: {0, 1}} { + if grid.Shortcut(0, key) { + *cursor = [2]int{max(0, min(len(opts.Days)-1, cursor[0]+dd[0])), max(0, min(slots-1, cursor[1]+dd[1]))} + } + } + if grid.Shortcut(0, ui.KeySpace) || grid.Shortcut(0, ui.KeyEnter) { + set(cursor[0], cursor[1], !(*mine)[cursor[0]][cursor[1]]) + } + } + focused := grid.FocusVisible() || grid.Focused() && !drag.active + grid.Children(func() { + ui.Row(c).Height(headH).Children(func() { + ui.Box(c).Width(gutter).Shrink(0) + for _, day := range opts.Days { + ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))+" "+strconv.Itoa(day.Day)).Grow(1).Basis(0). + TextAlign(ui.Center).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleColumnHeader) + } + }) + for s := range slots { + m := from*60 + s*slot + ui.Row(c).Height(rowH).Role(ui.RoleRow).Children(func() { + hour := "" + if m%60 == 0 { + hour = timeText(c, TimeOfDay{m / 60, 0, 0}, timeConfig{hour12: opts.Hour12}) + } + ui.Text(c, hour).Width(gutter).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + for di, day := range opts.Days { + free := 0 + for _, p := range opts.Others { + if p.Free[di][s] { + free++ + } + } + availabilityCell(c, day, m, (*mine)[di][s], free, len(opts.Others), focused && *cursor == [2]int{di, s}, opts.Hour12) + } + }) + } + }) + if changed { + slog.Debug("mujica: availability_picker change") + } + return ChoiceResult{Element: grid, changed: changed} +} + +func availabilityCheck(name string, v [][]bool, days, slots int) { + if len(v) != days { + panic(fmt.Sprintf("mujica: AvailabilityPicker %s has %d days, want %d", name, len(v), days)) + } + for _, row := range v { + if len(row) != slots { + panic(fmt.Sprintf("mujica: AvailabilityPicker %s has %d slots, want %d", name, len(row), slots)) + } + } +} + +// availabilityCell shows one slot's choice and free count. +func availabilityCell(c *ui.Context, day Date, m int, mine bool, free, others int, cursor, hour12 bool) { + k := Tokens(c) + text := datetimeDayLabel(c, day) + " " + timeText(c, TimeOfDay{m / 60, m % 60, 0}, timeConfig{hour12: hour12}) + if mine { + text += ", " + msg(c, "availability_picker.mine") + } + if others > 0 { + text += ", " + fmt.Sprintf(msg(c, "availability_picker.others"), free, others) + } + everyone := mine && others > 0 && free == others + cell := ui.Box(c).Grow(1).Basis(0).MinWidth(0).FillHeight().Center().Role(ui.RoleCell).Label(text).BorderWidth(0, 0, theme.BorderWidth, theme.BorderWidth).BorderColor(k.Border) + if mine { + cell.Background(k.Selection) + } + cell.Draw(func(p *ui.Painter, r ui.Rect) { + if others > 0 && free > 0 { + h := (r.H - 4) * float32(free) / float32(others) + p.Fill(ui.Rect{X: r.X + 2, Y: r.Y + r.H - 2 - h, W: 3, H: h}, k.Info, 1) + } + if mine { + internal.Mark(p, ui.Rect{X: r.X + r.W - 16, Y: r.Y + 2, W: 12, H: 12}, "check", k.AccentText) + } + if cursor { + datetimeSelectedMark(p, r, k.Focus) + } + }) + cell.Children(func() { + if others > 0 && free > 0 { + t := ui.Text(c, strconv.Itoa(free)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + if everyone { + t.Bold().TextColor(k.AccentText) + } + } + }) +} diff --git a/availability_picker_test.go b/availability_picker_test.go new file mode 100644 index 0000000..86f47a3 --- /dev/null +++ b/availability_picker_test.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func availabilityGrid(days, slots int) [][]bool { + v := make([][]bool, days) + for i := range v { + v[i] = make([]bool, slots) + } + return v +} + +var availabilityDays = []Date{oct(12), oct(13), oct(14)} + +func availabilityTester(v *[][]bool, opts *AvailabilityPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := *opts + o.Days, o.From, o.To = availabilityDays, 9, 12 + ui.Column(c).Padding(16).Children(func() { + if AvailabilityPicker(c, v, o).Changed() { + *n++ + } + }) + }, 560, 360) +} + +func TestAvailabilityPickerPaintDrag(t *testing.T) { + v, n := availabilityGrid(3, 6), 0 + tt := availabilityTester(&v, &AvailabilityPickerOptions{}, &n) + a, _ := tt.Find("Monday, October 12, 2026 09:00") + b, _ := tt.Find("Monday, October 12, 2026 10:30") + calDrag(tt, a.X+a.W/2, a.Y+a.H/2, b.X+b.W/2, b.Y+b.H/2) + for s := range 6 { + if v[0][s] != (s <= 3) { + t.Fatalf("painted %v", v[0]) + } + } + if n == 0 || !tt.HasText("Monday, October 12, 2026 10:00, you are free") { + t.Fatal("no change or label") + } + // Starting on a free slot erases. + calDrag(tt, a.X+a.W/2, a.Y+a.H/2, a.X+a.W/2, a.Y+a.H*1.5) + if v[0][0] || v[0][1] || !v[0][2] { + t.Fatalf("erase stroke %v", v[0]) + } +} + +func TestAvailabilityPickerOthersAndKeyboard(t *testing.T) { + v, n := availabilityGrid(3, 6), 0 + free := availabilityGrid(3, 6) + free[1][0] = true + other := availabilityGrid(3, 6) + other[1][0], other[1][1] = true, true + opts := &AvailabilityPickerOptions{Others: []AvailabilityPerson{{Name: "Mutsumi", Free: free}, {Name: "Uika", Free: other}}} + tt := availabilityTester(&v, opts, &n) + if !tt.HasText("Tuesday, October 13, 2026 09:00, 2 of 2 others free") || !tt.HasText("Tuesday, October 13, 2026 09:30, 1 of 2 others free") { + t.Fatalf("overlap counts: %v", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if !v[1][0] || !v[1][1] || n != 2 { + t.Fatalf("keyboard toggles %v", v[1]) + } +} + +func TestAvailabilityPickerDisabledAndInvalid(t *testing.T) { + v, n := availabilityGrid(3, 6), 0 + tt := availabilityTester(&v, &AvailabilityPickerOptions{Disabled: true}, &n) + a, _ := tt.Find("Monday, October 12, 2026 09:00") + calDrag(tt, a.X+a.W/2, a.Y+a.H/2, a.X+a.W/2, a.Y+a.H*2.5) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if v[0][0] || n != 0 { + t.Fatal("disabled picker painted") + } + for _, bad := range []*AvailabilityPickerOptions{{SlotMinutes: 7}, {Others: []AvailabilityPerson{{Name: "X", Free: availabilityGrid(2, 6)}}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", bad) + } + }() + w := availabilityGrid(3, 6) + availabilityTester(&w, bad, &n) + }() + } +} + +func TestAvailabilityPickerSnapshot(t *testing.T) { + v := availabilityGrid(3, 6) + v[0][1], v[0][2], v[1][2] = true, true, true + free := availabilityGrid(3, 6) + free[0][2], free[1][2], free[2][0] = true, true, true + snapshot(t, "availability_picker", 520, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + AvailabilityPicker(c, &v, AvailabilityPickerOptions{Days: availabilityDays, From: 9, To: 12, + Others: []AvailabilityPerson{{Name: "Mutsumi", Free: free}}}) + }) + }) +} diff --git a/calendar_day_view.go b/calendar_day_view.go new file mode 100644 index 0000000..a1856ab --- /dev/null +++ b/calendar_day_view.go @@ -0,0 +1,50 @@ +package mujica + +import ( + "time" + + "github.com/egoist/mygo/ui" +) + +// CalendarDayViewOptions configure a CalendarDayView. +type CalendarDayViewOptions struct { + // Location is the zone the day is cut in; required. + Location *time.Location + // Version must change when events change in place. + Version int + // SlotMinutes is the grid step; zero means 30. + SlotMinutes int + // Today is marked; zero uses the system clock. + Today Date + // Now places the current-time line; zero uses the system clock. + Now time.Time + Hour12 bool + Disabled bool + Label string +} + +// CalendarDayView shows one day's time grid. +func CalendarDayView(c *ui.Context, day *Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult { + datetimeCheckDate("CalendarDayView day", *day) + if day.IsZero() { + panic("mujica: CalendarDayView needs a day") + } + cfg := timeGridConfig{name: "calendar_day_view", loc: eventLocation("CalendarDayView", opts.Location), version: opts.Version, + slot: timeGridSlot("CalendarDayView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12} + eventsCheck("CalendarDayView", events) + title := datetimeDayLabel(c, *day) + label := opts.Label + if label == "" { + label = title + } + r := CalendarViewResult{} + root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + root.Children(func() { + calendarViewHeader(c, title, msg(c, "calendar_day_view.prev"), msg(c, "calendar_day_view.next"), func(dir int) { + *day = day.AddDays(dir) + }) + timeGrid(c, root, []Date{*day}, events, cfg, &r) + }) + return r +} diff --git a/calendar_day_view_test.go b/calendar_day_view_test.go new file mode 100644 index 0000000..8ffa166 --- /dev/null +++ b/calendar_day_view_test.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func dayViewTester(day *Date, events []CalendarEvent, opts CalendarDayViewOptions, changes *[]CalendarChange) *ui.Tester { + opts.Location, opts.Today = calTokyo, calendarToday + opts.Now = time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ch, ok := CalendarDayView(c, day, events, opts).Changed(); ok { + *changes = append(*changes, ch) + } + }, 600, 900) +} + +func TestCalendarDayViewOverlapSideBySide(t *testing.T) { + day := oct(14) + evs := []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 9, 30, 60), calEvent("c", 14, 10, 0, 60), calEvent("d", 14, 12, 0, 30)} + var ch []CalendarChange + tt := dayViewTester(&day, evs, CalendarDayViewOptions{}, &ch) + a, _ := tt.Find("Event a, 09:00 – 11:00") + b, _ := tt.Find("Event b, 09:30 – 10:30") + c, _ := tt.Find("Event c, 10:00 – 11:00") + d, _ := tt.Find("Event d, 12:00 – 12:30") + if !(a.X < b.X && b.X < c.X) || a.W > d.W/2 || d.X != a.X { + t.Fatalf("three overlapping events need three columns, the lone one the full width: %v %v %v %v", a, b, c, d) + } +} + +func TestCalendarDayViewNavigationSlotAndDisabled(t *testing.T) { + day := oct(14) + var ch []CalendarChange + tt := dayViewTester(&day, nil, CalendarDayViewOptions{SlotMinutes: 15}, &ch) + if !tt.HasText("Wednesday, October 14, 2026") { + t.Fatalf("title %v", tt.Texts()[:4]) + } + _ = tt.Click("Schedule, Wednesday, October 14, 2026") + tt.Key(0, ui.KeyEnter) + if len(ch) != 1 || ch[0].End.Sub(ch[0].Start) != 15*time.Minute { + t.Fatalf("15-minute slot create %+v", ch) + } + _ = tt.Click("Next day") + if day != oct(15) { + t.Fatalf("day %v", day) + } + d := oct(14) + ch = nil + tt = dayViewTester(&d, nil, CalendarDayViewOptions{Disabled: true}, &ch) + _ = tt.Click("Schedule, Wednesday, October 14, 2026") + tt.Key(0, ui.KeyEnter) + _ = tt.Click("Previous day") + if len(ch) != 0 || d != oct(14) { + t.Fatal("disabled day view reacted") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without a day") + } + }() + zero := Date{} + dayViewTester(&zero, nil, CalendarDayViewOptions{}, &ch) +} + +func TestCalendarDayViewSnapshot(t *testing.T) { + day := oct(14) + evs := []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 9, 30, 60), calEvent("c", 14, 10, 0, 60), calEvent("d", 14, 12, 0, 30)} + evs[1].Color, evs[2].Color = EventInfo, EventSuccess + snapshot(t, "calendar_day_view", 560, 860, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + CalendarDayView(c, &day, evs, CalendarDayViewOptions{Location: calTokyo, Today: calendarToday, + Now: time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)}) + }) + }) +} diff --git a/calendar_events.go b/calendar_events.go index 6599680..cf624e0 100644 --- a/calendar_events.go +++ b/calendar_events.go @@ -22,7 +22,7 @@ const ( EventNeutral ) -// CalendarEvent is one entry of the calendar views. End is exclusive. +// CalendarEvent is one entry of the views; End is exclusive. type CalendarEvent struct { ID string Title string @@ -46,7 +46,7 @@ const ( CalendarCreate ) -// CalendarChange is a move, resize or new event a view asks the caller to apply. +// CalendarChange is a move, resize or creation to apply. type CalendarChange struct { Kind CalendarChangeKind ID string @@ -54,7 +54,7 @@ type CalendarChange struct { AllDay bool } -// CalendarViewResult is a calendar view and what the user asked of it. +// CalendarViewResult is a view and what the user asked. type CalendarViewResult struct { Element *ui.Element opened string @@ -76,7 +76,7 @@ func (r CalendarViewResult) Changed() (CalendarChange, bool) { // 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. +// eventPalette maps EventColor to tokens at 3:1 on Surface. func eventPalette(k theme.Tokens) [6]ui.Color { return [6]ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Danger, k.TextMuted} } @@ -100,24 +100,25 @@ func eventsCheck(name string, events []CalendarEvent) { } } -// eventsIndex caches the events overlapping a span, rebuilt only when data or span change. -type eventsIndex struct { +// eventsCache holds data derived from events overlapping a span. +type eventsCache[T any] struct { first *CalendarEvent n int version int from, to time.Time - hits []int + value T + valid bool } -// 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{} }) +// eventsDerived rebuilds build(hits) only when events or span change. +func eventsDerived[T any](e *ui.Element, key string, events []CalendarEvent, version int, from, to time.Time, build func(hits []int) T) T { + cache := ui.Local(e, key, func() *eventsCache[T] { return &eventsCache[T]{} }) 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 + if x := *cache; x.valid && x.first == first && x.n == len(events) && x.version == version && x.from.Equal(from) && x.to.Equal(to) { + return x.value } hits := []int{} for i, ev := range events { @@ -132,14 +133,15 @@ func eventsIn(e *ui.Element, events []CalendarEvent, version int, from, to time. } return x.End.After(y.End) }) - **ix = eventsIndex{first, len(events), version, from, to, hits} - return hits + v := build(hits) + **cache = eventsCache[T]{first, len(events), version, from, to, v, true} + return v } // 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. +// eventColumns gives overlapping spans side-by-side columns and cluster widths. func eventColumns(spans []eventSpan) (col, cols []int) { col, cols = make([]int, len(spans)), make([]int, len(spans)) order := make([]int, len(spans)) @@ -179,7 +181,7 @@ func eventColumns(spans []eventSpan) (col, cols []int) { return col, cols } -// eventDayStart is midnight of d in loc; a missing midnight resolves to the day's first instant. +// eventDayStart is d's first instant in loc. func eventDayStart(d Date, loc *time.Location) time.Time { return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, loc) } @@ -195,6 +197,38 @@ func eventTimeText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 b return text } +// eventShift moves ev by days, keeping wall time. +func eventShift(c *ui.Context, ev CalendarEvent, days int, loc *time.Location) (CalendarChange, string) { + s, e := ev.Start.In(loc), ev.End.In(loc) + ch := CalendarChange{Kind: CalendarMove, ID: ev.ID, AllDay: ev.AllDay} + if ev.AllDay { + ch.Start, ch.End = eventDayStart(DateOf(s).AddDays(days), loc), eventDayStart(DateOf(e).AddDays(days), loc) + return ch, "" + } + day := DateOf(s).AddDays(days) + wall := TimeOfDay{s.Hour(), s.Minute(), s.Second()} + at, n := dateTimeResolve(day, wall, loc) + if p := eventWallProblem(c, day, wall, loc, n); p != "" { + return ch, p + } + ch.Start, ch.End = at, at.Add(ev.End.Sub(ev.Start)) + return ch, "" +} + +// eventWallProblem reports a wall time DST skips or repeats. +func eventWallProblem(c *ui.Context, day Date, wall TimeOfDay, loc *time.Location, n int) string { + key := "" + switch { + case n == 0: + key = "date_time_picker.gap" + case n > 1: + key = "date_time_picker.ambiguous" + default: + return "" + } + return fmt.Sprintf(msg(c, key), day.String()+" "+fmt.Sprintf("%02d:%02d", wall.Hour, wall.Minute), loc.String()) +} + // eventLocation panics without a caller zone; views never assume UTC. func eventLocation(name string, loc *time.Location) *time.Location { if loc == nil { diff --git a/calendar_month_view.go b/calendar_month_view.go new file mode 100644 index 0000000..2d8a72b --- /dev/null +++ b/calendar_month_view.go @@ -0,0 +1,248 @@ +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) +} diff --git a/calendar_month_view_test.go b/calendar_month_view_test.go new file mode 100644 index 0000000..e955450 --- /dev/null +++ b/calendar_month_view_test.go @@ -0,0 +1,171 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type monthProbe struct { + month YearMonth + events []CalendarEvent + opts CalendarMonthViewOptions + opened []string + changes []CalendarChange + days []Date + disabled bool +} + +func (p *monthProbe) tester() *ui.Tester { + if p.month.IsZero() { + p.month = YearMonth{2026, time.October} + } + p.opts.Location, p.opts.Today = calTokyo, calendarToday + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := p.opts + o.Disabled = p.disabled + r := CalendarMonthView(c, &p.month, p.events, o) + if id, ok := r.Opened(); ok { + p.opened = append(p.opened, id) + } + if ch, ok := r.Changed(); ok { + p.changes = append(p.changes, ch) + } + if d, ok := r.DayChosen(); ok { + p.days = append(p.days, d) + } + }, 1000, 900) +} + +func TestCalendarMonthViewOverflowAndPopover(t *testing.T) { + p := &monthProbe{} + for i := range 5 { + p.events = append(p.events, calEvent(fmt.Sprint(i), 14, 8+2*i, 0, 60)) + } + tt := p.tester() + if !tt.HasText("+2") || tt.HasText("Event 3") { + t.Fatalf("3 rows then +2 expected: %v", tt.Texts()) + } + _ = tt.Click("2 more events") + if !tt.HasText("Event 4") { + t.Fatal("+N should list the hidden events") + } + _ = tt.Click("Event 4, 16:00 – 17:00") + if len(p.opened) != 1 || p.opened[0] != "4" { + t.Fatalf("opened %v", p.opened) + } +} + +func TestCalendarMonthViewOverlapSideBySide(t *testing.T) { + p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 10, 0, 60), calEvent("c", 14, 15, 0, 60)}} + tt := p.tester() + a, _ := tt.Find("Event a, 09:00 – 11:00") + b, _ := tt.Find("Event b, 10:00 – 11:00") + cc, _ := tt.Find("Event c, 15:00 – 16:00") + if a.Y != b.Y || b.X <= a.X || cc.Y <= a.Y { + t.Fatalf("overlapping events should share a row: %v %v %v", a, b, cc) + } +} + +func TestCalendarMonthViewDragAndKeys(t *testing.T) { + p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 30, 60)}} + tt := p.tester() + chip, _ := tt.Find("Event a, 09:30 – 10:30") + target, _ := tt.Find(datetimeTestLabel(oct(16))) + tt.Press(chip.X+10, chip.Y+5) + for i := 1; i <= 4; i++ { + f := float32(i) / 4 + tt.Move(chip.X+10+(target.X+20-chip.X-10)*f, chip.Y+5+(target.Y+40-chip.Y-5)*f) + } + tt.Release(target.X+20, target.Y+40) + want := time.Date(2026, time.October, 16, 9, 30, 0, 0, calTokyo) + if len(p.changes) != 1 || !p.changes[0].Start.Equal(want) || p.changes[0].End.Sub(p.changes[0].Start) != time.Hour { + t.Fatalf("drop gave %+v", p.changes) + } + _ = tt.Click("Event a, 09:30 – 10:30") + tt.Key(ui.Alt, ui.KeyDown) + if len(p.changes) != 2 || !p.changes[1].Start.Equal(time.Date(2026, time.October, 21, 9, 30, 0, 0, calTokyo)) { + t.Fatalf("Alt+Down gave %+v", p.changes) + } +} + +func TestCalendarMonthViewDSTGapReported(t *testing.T) { + ny, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + ev := CalendarEvent{ID: "g", Title: "Early", Start: time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)} + ev.End = ev.Start.Add(time.Hour) + p := &monthProbe{month: YearMonth{2026, time.March}, events: []CalendarEvent{ev}} + p.opts.Location = ny + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ch, ok := CalendarMonthView(c, &p.month, p.events, CalendarMonthViewOptions{Location: ny, Today: Date{2026, time.March, 1}}).Changed(); ok { + p.changes = append(p.changes, ch) + } + }, 1000, 900) + _ = tt.Click("Early, 02:30 – 03:30") + tt.Key(ui.Alt, ui.KeyRight) + if len(p.changes) != 0 || !tt.HasText("2026-03-08 02:30 does not exist in America/New_York") { + t.Fatalf("gap not reported: %v", tt.Texts()) + } +} + +func TestCalendarMonthViewNavigationDayAndDisabled(t *testing.T) { + p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60)}} + tt := p.tester() + _ = tt.Click(datetimeTestLabel(oct(20))) + _ = tt.Click("Next month") + if len(p.days) != 1 || p.days[0] != oct(20) || p.month != (YearMonth{2026, time.November}) { + t.Fatalf("day %v month %v", p.days, p.month) + } + p.month, p.disabled = YearMonth{2026, time.October}, true + tt.Frame() + _ = tt.Click("Next month") + _ = tt.Click("Event a, 09:00 – 10:00") + tt.Key(ui.Alt, ui.KeyRight) + if p.month != (YearMonth{2026, time.October}) || len(p.opened) != 0 || len(p.changes) != 0 { + t.Fatal("disabled view reacted") + } +} + +func TestCalendarMonthViewLargeDataAndInvalid(t *testing.T) { + p := &monthProbe{} + base := time.Date(2000, 1, 1, 9, 0, 0, 0, calTokyo) + for i := range 100000 { + s := base.Add(time.Duration(i) * 3 * time.Hour) + p.events = append(p.events, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(time.Hour)}) + } + tt := p.tester() + if n := len(tt.Texts()); n > 800 { + t.Fatalf("built %d labels for one month", n) + } + for _, bad := range []func(){ + func() { q := &monthProbe{events: []CalendarEvent{{ID: "x", Start: base, End: base}}}; q.tester() }, + func() { q := &monthProbe{opts: CalendarMonthViewOptions{MaxPerDay: -1}}; q.tester() }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("expected panic") + } + }() + bad() + }() + } +} + +func TestCalendarMonthViewSnapshot(t *testing.T) { + m := YearMonth{2026, time.October} + evs := []CalendarEvent{calEvent("1", 6, 9, 0, 60), calEvent("2", 14, 9, 0, 120), calEvent("3", 14, 10, 0, 30), + calEvent("4", 14, 13, 0, 30), calEvent("5", 14, 15, 0, 30), calEvent("6", 14, 17, 0, 30), calEvent("7", 22, 18, 0, 60)} + evs[1].Color, evs[2].Color, evs[6].Color = EventInfo, EventSuccess, EventWarning + snapshot(t, "calendar_month_view", 900, 860, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + CalendarMonthView(c, &m, evs, CalendarMonthViewOptions{Location: calTokyo, Today: calendarToday}) + }) + }) +} diff --git a/calendar_time_grid.go b/calendar_time_grid.go new file mode 100644 index 0000000..3b241bc --- /dev/null +++ b/calendar_time_grid.go @@ -0,0 +1,369 @@ +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} +} diff --git a/calendar_week_view.go b/calendar_week_view.go new file mode 100644 index 0000000..bf24605 --- /dev/null +++ b/calendar_week_view.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// CalendarWeekViewOptions configure a CalendarWeekView. +type CalendarWeekViewOptions 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 + // SlotMinutes is the grid step; zero means 30. + SlotMinutes int + // Today is marked; zero uses the system clock. + Today Date + // Now places the current-time line; zero uses the system clock. + Now time.Time + Hour12 bool + Disabled bool + Label string +} + +// CalendarWeekView shows *week as a time grid; drags edit events. +func CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult { + datetimeCheckDate("CalendarWeekView week", *week) + if week.IsZero() { + panic("mujica: CalendarWeekView needs a week") + } + cfg := timeGridConfig{name: "calendar_week_view", loc: eventLocation("CalendarWeekView", opts.Location), version: opts.Version, + slot: timeGridSlot("CalendarWeekView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12} + eventsCheck("CalendarWeekView", events) + first := calendarFirstWeekday(c, opts.WeekStart) + start := week.AddDays(-((int(week.Weekday()) - int(first) + 7) % 7)) + days := make([]Date, 7) + for i := range days { + days[i] = start.AddDays(i) + } + title := weekViewTitle(c, start, start.AddDays(6)) + label := opts.Label + if label == "" { + label = title + } + r := CalendarViewResult{} + root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + root.Children(func() { + calendarViewHeader(c, title, msg(c, "calendar_week_view.prev"), msg(c, "calendar_week_view.next"), func(dir int) { + *week = week.AddDays(7 * dir) + }) + timeGrid(c, root, days, events, cfg, &r) + }) + return r +} + +// calendarViewHeader is the title row with previous and next buttons. +func calendarViewHeader(c *ui.Context, title, prev, next string, step func(dir int)) { + k := Tokens(c) + ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() { + if b := iconAction(c, icons.Must("chevron-left"), prev); b.Clicked() && !b.IsDisabled() { + step(-1) + } + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).TextAlign(ui.Center) + if b := iconAction(c, icons.Must("chevron-right"), next); b.Clicked() && !b.IsDisabled() { + step(1) + } + }) +} + +// weekViewTitle names a span of days. +func weekViewTitle(c *ui.Context, a, b Date) string { + return fmt.Sprintf(msg(c, "calendar_week_view.title"), datetimeMonthName(c, a.Month), a.Day, datetimeMonthName(c, b.Month), b.Day, b.Year) +} diff --git a/calendar_week_view_test.go b/calendar_week_view_test.go new file mode 100644 index 0000000..63a5ea4 --- /dev/null +++ b/calendar_week_view_test.go @@ -0,0 +1,175 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type weekProbe struct { + week Date + events []CalendarEvent + opts CalendarWeekViewOptions + changes []CalendarChange + opened []string + disabled bool +} + +func (p *weekProbe) tester() *ui.Tester { + if p.week.IsZero() { + p.week = oct(14) + } + p.opts.Location, p.opts.Today = calTokyo, calendarToday + p.opts.Now = time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := p.opts + o.Disabled = p.disabled + r := CalendarWeekView(c, &p.week, p.events, o) + if ch, ok := r.Changed(); ok { + p.changes = append(p.changes, ch) + } + if id, ok := r.Opened(); ok { + p.opened = append(p.opened, id) + } + }, 1100, 900) +} + +func calDrag(tt *ui.Tester, x0, y0, x1, y1 float32) { + tt.Press(x0, y0) + for i := 1; i <= 4; i++ { + f := float32(i) / 4 + tt.Move(x0+(x1-x0)*f, y0+(y1-y0)*f) + } + tt.Release(x1, y1) +} + +func calAt(t *testing.T, h, m int, day int) time.Time { + t.Helper() + return time.Date(2026, time.October, day, h, m, 0, 0, calTokyo) +} + +func TestCalendarWeekViewDragCreateAndKeyboardCreate(t *testing.T) { + p := &weekProbe{} + tt := p.tester() + col, ok := tt.Find("Schedule, " + datetimeTestLabel(oct(15))) + if !ok { + t.Fatal("no column") + } + hourH := float32(72) + // Drag 9:00 to 10:30; the visible top is 8:00. + y9 := col.Y + hourH + 5 + calDrag(tt, col.X+20, y9, col.X+20, y9+hourH*1.5) + if len(p.changes) != 1 || p.changes[0].Kind != CalendarCreate || !p.changes[0].Start.Equal(calAt(t, 9, 0, 15)) || !p.changes[0].End.Equal(calAt(t, 11, 0, 15)) { + t.Fatalf("drag create %+v", p.changes) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(p.changes) != 2 || !p.changes[1].Start.Equal(calAt(t, 9, 30, 15)) || !p.changes[1].End.Equal(calAt(t, 10, 0, 15)) { + t.Fatalf("keyboard create %+v", p.changes) + } +} + +func TestCalendarWeekViewMoveResizeAndOverlap(t *testing.T) { + p := &weekProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60), calEvent("b", 14, 9, 30, 60)}} + tt := p.tester() + a, _ := tt.Find("Event a, 09:00 – 10:00") + b, _ := tt.Find("Event b, 09:30 – 10:30") + if b.X <= a.X || b.X-a.X < a.W/2 || b.Y <= a.Y { + t.Fatalf("overlapping events should sit side by side: %v %v", a, b) + } + calDrag(tt, a.X+10, a.Y+20, a.X+10, a.Y+20+72) + if len(p.changes) != 1 || p.changes[0].Kind != CalendarMove || !p.changes[0].Start.Equal(calAt(t, 10, 0, 14)) || !p.changes[0].End.Equal(calAt(t, 11, 0, 14)) { + t.Fatalf("move %+v", p.changes) + } + h, _ := tt.Find("Resize Event b") + calDrag(tt, h.X+10, h.Y+3, h.X+10, h.Y+3+36) + if len(p.changes) != 2 || p.changes[1].Kind != CalendarResize || !p.changes[1].End.Equal(calAt(t, 11, 0, 14)) { + t.Fatalf("resize %+v", p.changes) + } + _ = tt.Click("Event a, 09:00 – 10:00") + if len(p.opened) != 1 { + t.Fatalf("click should open: %v", p.opened) + } + tt.Key(ui.Alt, ui.KeyRight) + tt.Key(ui.Alt, ui.KeyDown) + tt.Key(ui.Alt|ui.Shift, ui.KeyUp) + if len(p.changes) != 5 || !p.changes[2].Start.Equal(calAt(t, 9, 0, 15)) || !p.changes[3].Start.Equal(calAt(t, 9, 30, 14)) || + !p.changes[4].End.Equal(calAt(t, 9, 30, 14)) { + t.Fatalf("keyboard changes %+v", p.changes[2:]) + } +} + +func TestCalendarWeekViewAllDayWeekStartAndNav(t *testing.T) { + ev := calEvent("h", 16, 0, 0, 24*60) + ev.AllDay, ev.Title = true, "Holiday" + p := &weekProbe{events: []CalendarEvent{ev}, opts: CalendarWeekViewOptions{WeekStart: WeekStartMonday}} + tt := p.tester() + if !tt.HasText("October 12 – October 18, 2026") || !tt.HasText("All day") || !tt.HasText("Holiday") { + t.Fatalf("texts %v", tt.Texts()[:12]) + } + _ = tt.Click("Holiday, All day") + tt.Key(ui.Alt, ui.KeyRight) + if len(p.changes) != 1 || !p.changes[0].Start.Equal(calAt(t, 0, 0, 17)) || !p.changes[0].AllDay { + t.Fatalf("all-day move %+v", p.changes) + } + _ = tt.Click("Next week") + if p.week != oct(21) { + t.Fatalf("week %v", p.week) + } +} + +func TestCalendarWeekViewDisabledAndInvalid(t *testing.T) { + p := &weekProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60)}, disabled: true} + tt := p.tester() + a, _ := tt.Find("Event a, 09:00 – 10:00") + calDrag(tt, a.X+10, a.Y+20, a.X+10, a.Y+92) + _ = tt.Click("Event a, 09:00 – 10:00") + tt.Key(ui.Alt, ui.KeyDown) + _ = tt.Click("Next week") + if len(p.changes) != 0 || len(p.opened) != 0 || p.week != oct(14) { + t.Fatal("disabled week view reacted") + } + for _, opts := range []CalendarWeekViewOptions{{SlotMinutes: 7}, {SlotMinutes: -15}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", opts) + } + }() + q := &weekProbe{opts: opts} + q.tester() + }() + } +} + +func TestCalendarWeekViewLargeData(t *testing.T) { + p := &weekProbe{} + base := time.Date(2000, 1, 1, 9, 0, 0, 0, calTokyo) + for i := range 100000 { + s := base.Add(time.Duration(i) * 3 * time.Hour) + p.events = append(p.events, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(time.Hour)}) + } + tt := p.tester() + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d labels for one week", n) + } +} + +func TestCalendarWeekViewSnapshot(t *testing.T) { + w := oct(14) + evs := []CalendarEvent{calEvent("1", 12, 9, 0, 60), calEvent("2", 14, 9, 0, 120), calEvent("3", 14, 9, 30, 60), + calEvent("4", 15, 13, 0, 90), calEvent("5", 16, 10, 0, 45)} + evs[1].Color, evs[2].Color, evs[3].Color = EventInfo, EventSuccess, EventWarning + h := calEvent("h", 13, 0, 0, 24*60) + h.AllDay, h.Title = true, "Holiday" + evs = append(evs, h) + snapshot(t, "calendar_week_view", 1000, 860, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + CalendarWeekView(c, &w, evs, CalendarWeekViewOptions{Location: calTokyo, Today: calendarToday, + Now: time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)}) + }) + }) +} diff --git a/countdown.go b/countdown.go new file mode 100644 index 0000000..4afc585 --- /dev/null +++ b/countdown.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CountdownFormat chooses how the time left reads. +type CountdownFormat int + +const ( + // CountdownClock reads 01:02:03, with days as "2d" first. + CountdownClock CountdownFormat = iota + // CountdownWords reads "1 h 2 min 3 s". + CountdownWords +) + +// CountdownOptions configure a Countdown. +type CountdownOptions struct { + Format CountdownFormat + // Now fixes the clock and stops updates. + Now time.Time + Label string +} + +// CountdownResult is the countdown and its finish. +type CountdownResult struct { + Element *ui.Element + finished bool +} + +// Finished reports reaching zero, once per deadline. +func (r CountdownResult) Finished() bool { return r.finished } + +// Countdown shows the time left, ticking each second while shown. +func Countdown(c *ui.Context, deadline time.Time, opts CountdownOptions) CountdownResult { + if opts.Format < CountdownClock || opts.Format > CountdownWords { + panic(fmt.Sprintf("mujica: Countdown Format %d out of range", opts.Format)) + } + k := Tokens(c) + now := opts.Now + live := now.IsZero() + if live { + now = c.Now() + } + left := max(deadline.Sub(now), 0) + shown := left.Truncate(time.Second) + if left%time.Second != 0 { + shown += time.Second + } + label := opts.Label + if label == "" { + label = msg(c, "countdown.label") + } + text := CountdownText(c, shown, opts.Format) + e := ui.Text(c, text).Font(theme.MonoFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text).FontFeatures("tnum"). + Label(label + ", " + CountdownText(c, shown, CountdownWords)) + r := CountdownResult{Element: e} + fired := ui.Local(e, "countdown-fired", func() time.Time { return time.Time{} }) + switch { + case left == 0 && !fired.Equal(deadline): + *fired, r.finished = deadline, true + slog.Debug("mujica: countdown finished", "deadline", deadline) + c.Announce(fmt.Sprintf(msg(c, "countdown.done"), label)) + case left > 0 && live: + c.After(left % time.Second) + if left%time.Second == 0 { + c.After(time.Second) + } + } + if left == 0 { + e.TextColor(k.AccentText) + } + return r +} + +// CountdownText formats a non-negative duration in whole seconds. +func CountdownText(c *ui.Context, d time.Duration, f CountdownFormat) string { + s := int(d / time.Second) + days, h, m, sec := s/86400, s%86400/3600, s%3600/60, s%60 + if f == CountdownClock { + t := fmt.Sprintf("%02d:%02d:%02d", h, m, sec) + if days > 0 { + t = fmt.Sprintf(msg(c, "countdown.days_clock"), days) + " " + t + } + return t + } + out := "" + for i, v := range []int{days, h, m, sec} { + if v > 0 || i == 3 && out == "" { + if out != "" { + out += " " + } + out += fmt.Sprintf(msg(c, []string{"countdown.d", "countdown.h", "countdown.m", "countdown.s"}[i]), v) + } + } + return out +} diff --git a/countdown_test.go b/countdown_test.go new file mode 100644 index 0000000..470b2e0 --- /dev/null +++ b/countdown_test.go @@ -0,0 +1,69 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestCountdownFormatsAndFinishesOnce(t *testing.T) { + deadline := time.Date(2026, time.October, 5, 12, 0, 0, 0, calTokyo) + now, n := deadline.Add(-(26*time.Hour + 3*time.Minute + 4*time.Second + 500*time.Millisecond)), 0 + format := CountdownClock + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Countdown(c, deadline, CountdownOptions{Now: now, Format: format}).Finished() { + n++ + } + }, 400, 120) + if !tt.HasText("Time left, 1 d 2 h 3 min 5 s") { + t.Fatalf("label %v", tt.Texts()) + } + var clock, words string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + clock, words = CountdownText(c, 26*time.Hour+3*time.Minute+5*time.Second, CountdownClock), CountdownText(c, 0, CountdownWords) + }, 10, 10) + if clock != "1d 02:03:05" || words != "0 s" { + t.Fatalf("formats %q %q", clock, words) + } + format = CountdownWords + now = deadline.Add(time.Second) + tt.Frame() + tt.Frame() + if n != 1 || !tt.HasText("Time left, 0 s") { + t.Fatalf("finished %d times, %v", n, tt.Texts()) + } + if a := tt.Announcements(); len(a) != 1 || a[0] != "Time left: time is up." { + t.Fatalf("announcements %q", a) + } + deadline = deadline.Add(time.Minute) + now = deadline + tt.Frame() + if n != 2 { + t.Fatal("a new deadline should finish again") + } +} + +func TestCountdownInvalidFormat(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Countdown(c, time.Now(), CountdownOptions{Format: 7}) + }, 10, 10) +} + +func TestCountdownSnapshot(t *testing.T) { + deadline := time.Date(2026, time.October, 5, 12, 0, 0, 0, calTokyo) + snapshot(t, "countdown", 320, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + Countdown(c, deadline, CountdownOptions{Now: deadline.Add(-(3*time.Hour + 25*time.Second))}) + Countdown(c, deadline, CountdownOptions{Now: deadline.Add(-90 * time.Second), Format: CountdownWords}) + }) + }) +} diff --git a/cron_editor.go b/cron_editor.go new file mode 100644 index 0000000..cf36e19 --- /dev/null +++ b/cron_editor.go @@ -0,0 +1,346 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CronSchedule is a parsed five-field cron expression. +type CronSchedule struct { + fields [5]uint64 + tokens [5]string + // domAny and dowAny note a "*" day field. + domAny, dowAny bool +} + +// CronProblem is what is wrong with a cron field. +type CronProblem int + +const ( + CronFieldCount CronProblem = iota + CronEmptyItem + CronBadStep + CronNotNumber + CronOutOfRange + CronBackwards +) + +// CronError locates a problem in a cron expression. +type CronError struct { + // Field is 0 to 4, or -1 for the count. + Field int + Problem CronProblem + // Text is the offending part. + Text string +} + +var cronProblemKeys = [...]string{"cron_editor.err_count", "cron_editor.err_empty", "cron_editor.err_step", "cron_editor.err_number", + "cron_editor.err_range", "cron_editor.err_backwards"} + +func (e *CronError) Error() string { + return fmt.Sprintf("cron field %d: problem %d at %q", e.Field+1, e.Problem, e.Text) +} + +var cronBounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}} + +var cronNames = [5][]string{3: {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"}, + 4: {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}} + +// ParseCron parses standard five-field cron with names. +func ParseCron(expr string) (CronSchedule, error) { + parts := strings.Fields(expr) + var s CronSchedule + if len(parts) != 5 { + return s, &CronError{-1, CronFieldCount, strconv.Itoa(len(parts))} + } + for f, part := range parts { + s.tokens[f] = part + for _, item := range strings.Split(part, ",") { + bits, err := cronItem(f, strings.ToUpper(item)) + if err != nil { + err.Field = f + return CronSchedule{}, err + } + s.fields[f] |= bits + } + } + if s.fields[4]&(1<<7) != 0 { + s.fields[4] |= 1 + } + s.domAny, s.dowAny = parts[2] == "*", parts[4] == "*" + return s, nil +} + +// cronItem parses one list item into a bit set. +func cronItem(f int, item string) (uint64, *CronError) { + lo, hi := cronBounds[f][0], cronBounds[f][1] + if item == "" { + return 0, &CronError{Problem: CronEmptyItem} + } + rng, step := item, 1 + if i := strings.IndexByte(item, '/'); i >= 0 { + n, err := strconv.Atoi(item[i+1:]) + if err != nil || n < 1 { + return 0, &CronError{Problem: CronBadStep, Text: item[i+1:]} + } + rng, step = item[:i], n + } + a, b := lo, hi + switch { + case rng == "*": + case strings.Contains(rng, "-"): + x, y, _ := strings.Cut(rng, "-") + var e *CronError + if a, e = cronValue(f, x); e != nil { + return 0, e + } + if b, e = cronValue(f, y); e != nil { + return 0, e + } + if a > b { + return 0, &CronError{Problem: CronBackwards, Text: rng} + } + default: + var e *CronError + if a, e = cronValue(f, rng); e != nil { + return 0, e + } + if step == 1 { + b = a + } + } + var bits uint64 + for v := a; v <= b; v += step { + bits |= 1 << v + } + return bits, nil +} + +func cronValue(f int, s string) (int, *CronError) { + for i, n := range cronNames[f] { + if s == n { + return i + cronBounds[f][0], nil + } + } + v, err := strconv.Atoi(s) + if err != nil { + return 0, &CronError{Problem: CronNotNumber, Text: s} + } + if v < cronBounds[f][0] || v > cronBounds[f][1] { + return 0, &CronError{Problem: CronOutOfRange, Text: s} + } + return v, nil +} + +// CronRun is one upcoming run; Repeated marks a DST repeat. +type CronRun struct { + At time.Time + Repeated bool +} + +// Next returns up to n runs within five years. +func (s CronSchedule) Next(t time.Time, loc *time.Location, n int) []CronRun { + var out []CronRun + w := t.In(loc) + day := DateOf(w) + for i := 0; i < 5*366 && len(out) < n; i, day = i+1, day.AddDays(1) { + if !s.dayMatches(day) { + continue + } + for h := 0; h < 24 && len(out) < n; h++ { + if s.fields[1]&(1< 1}) + } + } + } + return out +} + +// dayMatches applies cron's either-day-field rule. +func (s CronSchedule) dayMatches(d Date) bool { + if s.fields[3]&(1< hi { + problem = fmt.Sprintf(msg(c, "duration_picker.range"), DurationText(c, opts.Min), DurationText(c, hi)) + } + if edited && problem == "" && total != *value && !col.IsDisabled() { + slog.Debug("mujica: duration_picker change", "from", *value, "to", total) + *value, *seen, changed = total, total, true + } + datetimeError(c, problem) + }) + return ChoiceResult{Element: col, changed: changed} +} + +func durationParts(v time.Duration) [3]float64 { + return [3]float64{float64(v / time.Hour), float64(v % time.Hour / time.Minute), float64(v % time.Minute / time.Second)} +} + +// DurationText words a duration, as "1 h 30 min". +func DurationText(c *ui.Context, v time.Duration) string { + p := durationParts(v) + s := "" + for i, key := range []string{"duration_picker.hours_unit", "duration_picker.minutes_unit", "duration_picker.seconds_unit"} { + if p[i] > 0 || i == 1 && s == "" && p[2] == 0 { + if s != "" { + s += " " + } + s += fmt.Sprintf("%d %s", int(p[i]), msg(c, key)) + } + } + return s +} diff --git a/duration_picker_test.go b/duration_picker_test.go new file mode 100644 index 0000000..391f708 --- /dev/null +++ b/duration_picker_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func durationTester(v *time.Duration, opts *DurationPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if DurationPicker(c, v, *opts).Changed() { + *n++ + } + }, 640, 200) +} + +func TestDurationPickerEditsFields(t *testing.T) { + v, n := 90*time.Minute, 0 + opts := &DurationPickerOptions{Seconds: true} + tt := durationTester(&v, opts, &n) + _ = tt.Click("Hours") + tt.Key(0, ui.KeyUp) + _ = tt.Click("Seconds") + tt.Command("selectAll") + tt.Type("45") + tt.Key(0, ui.KeyEnter) + if v != 2*time.Hour+30*time.Minute+45*time.Second || n != 2 { + t.Fatalf("value %v (%d)", v, n) + } +} + +func TestDurationPickerRangeReported(t *testing.T) { + v, n := 30*time.Minute, 0 + opts := &DurationPickerOptions{Min: 15 * time.Minute, Max: 2 * time.Hour} + tt := durationTester(&v, opts, &n) + _ = tt.Click("Hours") + tt.Key(0, ui.KeyUp) + if v != 90*time.Minute || n != 1 { + t.Fatalf("1 h 30 min should fit the range: %v", v) + } + tt.Key(0, ui.KeyUp) + if v != 90*time.Minute || !tt.HasText("Choose a duration from 15 min to 2 h.") { + t.Fatalf("over Max accepted: %v %v", v, tt.Texts()) + } + _ = tt.Click("Minutes") + tt.Command("selectAll") + tt.Type("0") + tt.Key(0, ui.KeyEnter) + if v != 2*time.Hour || n != 2 || tt.HasText("Choose a duration") { + t.Fatalf("fixing the minutes: %v", v) + } + if tt.HasText("Seconds") { + t.Fatal("seconds field without Seconds") + } +} + +func TestDurationPickerDisabledAndInvalid(t *testing.T) { + v, n := time.Hour, 0 + tt := durationTester(&v, &DurationPickerOptions{Disabled: true}, &n) + _ = tt.Click("Hours") + tt.Key(0, ui.KeyUp) + if v != time.Hour || n != 0 { + t.Fatal("disabled picker changed") + } + for _, c := range []struct { + v time.Duration + opts DurationPickerOptions + }{{30 * time.Second, DurationPickerOptions{}}, {0, DurationPickerOptions{Min: time.Hour, Max: time.Minute}}, {-time.Minute, DurationPickerOptions{}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v %+v: expected panic", c.v, c.opts) + } + }() + durationTester(&c.v, &c.opts, &n) + }() + } +} + +func TestDurationPickerSnapshot(t *testing.T) { + v := 2*time.Hour + 15*time.Minute + 30*time.Second + snapshot(t, "duration_picker", 640, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { DurationPicker(c, &v, DurationPickerOptions{Seconds: true}) }) + }) +} diff --git a/event_chip.go b/event_chip.go index 51acf51..cb81b1c 100644 --- a/event_chip.go +++ b/event_chip.go @@ -14,13 +14,13 @@ 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 puts the start 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. +// EventChip is a clickable, truncating event block. func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element { loc := eventLocation("EventChip", opts.Location) k := Tokens(c) @@ -32,7 +32,7 @@ func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Eleme 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()). + 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) @@ -60,7 +60,7 @@ func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Eleme 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, eventStartText(c, ev, loc, opts.Hour12)).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 { @@ -69,3 +69,12 @@ func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Eleme }) return b } + +// eventStartText is the start time alone, or "All day". +func eventStartText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) string { + if ev.AllDay { + return msg(c, "event_chip.all_day") + } + s := ev.Start.In(loc) + return timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, timeConfig{hour12: hour12}) +} diff --git a/event_chip_test.go b/event_chip_test.go new file mode 100644 index 0000000..ba6fbf7 --- /dev/null +++ b/event_chip_test.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +var calTokyo = func() *time.Location { + loc, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + panic(err) + } + return loc +}() + +// calEvent is an event on calendarToday's month in Tokyo. +func calEvent(id string, day, h, m, mins int) CalendarEvent { + s := time.Date(2026, time.October, day, h, m, 0, 0, calTokyo) + return CalendarEvent{ID: id, Title: "Event " + id, Start: s, End: s.Add(time.Duration(mins) * time.Minute)} +} + +func TestEventChipShowsZoneTimeAndClicks(t *testing.T) { + ev := calEvent("a", 6, 9, 30, 90) + ev.Start, ev.End = ev.Start.UTC(), ev.End.UTC() + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if EventChip(c, ev, EventChipOptions{Location: calTokyo}).Clicked() { + n++ + } + }, 300, 200) + if !tt.HasText("09:30 – 11:00") || !tt.HasText("Event a") { + t.Fatalf("chip should show Tokyo wall time: %v", tt.Texts()) + } + label := "Event a, 09:30 – 11:00" + _ = tt.Click(label) + tt.Key(0, ui.KeyEnter) + if n != 2 { + t.Fatalf("clicks %d", n) + } +} + +func TestEventChipTruncatesAndAllDay(t *testing.T) { + ev := calEvent("b", 6, 0, 0, 24*60) + ev.AllDay, ev.Title = true, strings.Repeat("Long title ", 20) + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(120).Children(func() { + w = EventChip(c, ev, EventChipOptions{Location: calTokyo, Inline: true}).Bounds().W + }) + }, 300, 200) + tt.Frame() + if w > 120 || !tt.HasText("All day") { + t.Fatalf("width %v texts %v", w, tt.Texts()) + } +} + +func TestEventChipDisabledAndInvalid(t *testing.T) { + ev := calEvent("c", 6, 9, 0, 30) + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if EventChip(c, ev, EventChipOptions{Location: calTokyo, Disabled: true}).Clicked() { + n++ + } + }, 300, 200) + _ = tt.Click("Event c, 09:00 – 09:30") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("disabled chip clicked") + } + for _, f := range []func(){ + func() { EventChip(nil, ev, EventChipOptions{}) }, + func() { ev.Color = 9; eventColor(theme.Light(), ev.Color) }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("expected panic") + } + }() + f() + }() + } +} + +func TestEventPaletteContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + for i, col := range eventPalette(k) { + if r := theme.Contrast(col, k.Surface); r < 3 { + t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r) + } + } + } +} + +func TestEventColumns(t *testing.T) { + col, cols := eventColumns([]eventSpan{{0, 2}, {1, 3}, {1.5, 2.5}, {2, 4}, {5, 6}}) + want := []int{0, 1, 2, 0, 0} + wantN := []int{3, 3, 3, 3, 1} + for i := range want { + if col[i] != want[i] || cols[i] != wantN[i] { + t.Fatalf("columns %v of %v", col, cols) + } + } +} + +func TestEventChipSnapshot(t *testing.T) { + evs := []CalendarEvent{calEvent("1", 6, 9, 0, 60), calEvent("2", 6, 13, 0, 30), calEvent("3", 6, 15, 0, 45)} + evs[1].Color, evs[2].Color = EventSuccess, EventWarning + snapshot(t, "event_chip", 420, 150, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(8).Children(func() { + EventChip(c, evs[0], EventChipOptions{Location: calTokyo}) + EventChip(c, evs[1], EventChipOptions{Location: calTokyo, Selected: true}) + EventChip(c, evs[2], EventChipOptions{Location: calTokyo, Inline: true}) + }) + }) +} diff --git a/event_editor.go b/event_editor.go new file mode 100644 index 0000000..66ce4da --- /dev/null +++ b/event_editor.go @@ -0,0 +1,171 @@ +package mujica + +import ( + "log/slog" + "slices" + "strings" + "time" + + "github.com/egoist/mygo/ui" +) + +// EventEditorOptions configure an EventEditor. +type EventEditorOptions struct { + // Location is the zone times are edited in; required. + Location *time.Location + Hour12 bool + WeekStart WeekStart + Disabled bool + Label string +} + +// EventEditorResult is the editor and its save or cancel. +type EventEditorResult struct { + Element *ui.Element + saved bool + canceled bool +} + +// Saved reports a valid draft written to the event. +func (r EventEditorResult) Saved() bool { return r.saved } + +// Canceled reports that the user discarded the draft. +func (r EventEditorResult) Canceled() bool { return r.canceled } + +// eventEditorState is the draft and whether Save was tried. +type eventEditorState struct { + draft CalendarEvent + tried bool + origin string +} + +// EventEditor edits a copy of *ev; Save checks and writes. +func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) EventEditorResult { + loc := eventLocation("EventEditor", opts.Location) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "event_editor.label") + } + r := EventEditorResult{} + root := ui.Column(c).Gap(d.Unit() * 4).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + st := ui.Local(root, "event-editor", func() *eventEditorState { return nil }) + if *st == nil || (*st).origin != ev.ID { + *st = &eventEditorState{draft: eventCopy(*ev), origin: ev.ID} + } + s := *st + dr := &s.draft + problems := eventEditorProblems(c, *dr, s.tried) + root.Children(func() { + FormField(c, msg(c, "event_editor.title"), FormFieldOptions{Required: true, Error: problems["title"]}, func() *ui.Element { + return TextInput(c, &dr.Title, TextInputOptions{Label: msg(c, "event_editor.title"), Disabled: opts.Disabled, Error: problems["title"]}).Input + }) + Checkbox(c, &dr.AllDay, msg(c, "event_editor.all_day"), CheckboxOptions{Disabled: opts.Disabled}) + ui.Row(c).Gap(d.Unit() * 3).Wrap().AlignItems(ui.Start).Children(func() { + for _, f := range []struct { + key string + t *time.Time + }{{"event_editor.starts", &dr.Start}, {"event_editor.ends", &dr.End}} { + FormField(c, msg(c, f.key), FormFieldOptions{Error: problems[f.key]}, func() *ui.Element { + if dr.AllDay { + day := DateOf(f.t.In(loc)) + r := DatePicker(c, &day, DatePickerOptions{Label: msg(c, f.key), WeekStart: opts.WeekStart, Disabled: opts.Disabled}) + if r.Changed() { + *f.t = eventDayStart(day, loc) + } + return r.Input + } + return DateTimePicker(c, f.t, DateTimePickerOptions{Location: loc, Hour12: opts.Hour12, MinuteStep: 5, + WeekStart: opts.WeekStart, Label: msg(c, f.key), Disabled: opts.Disabled}).Element + }) + } + }) + FormField(c, msg(c, "event_editor.where"), FormFieldOptions{}, func() *ui.Element { + return TextInput(c, &dr.Location, TextInputOptions{Label: msg(c, "event_editor.where"), Disabled: opts.Disabled}).Input + }) + FormField(c, msg(c, "event_editor.guests"), FormFieldOptions{Description: msg(c, "event_editor.guests_help")}, func() *ui.Element { + names := make([]string, len(dr.Attendees)) + for i, a := range dr.Attendees { + names[i] = a.Name + } + p := TagsInput(c, &names, TagsInputOptions{Label: msg(c, "event_editor.guests"), Disabled: opts.Disabled}) + if p.Changed() { + dr.Attendees = eventAttendees(dr.Attendees, names) + } + return p.Input + }) + if len(dr.Attendees) > 0 { + AttendeeList(c, dr.Attendees, AttendeeListOptions{Disabled: opts.Disabled}) + } + FormField(c, msg(c, "event_editor.reminders"), FormFieldOptions{}, func() *ui.Element { + return ReminderPicker(c, &dr.Reminders, ReminderPickerOptions{Disabled: opts.Disabled}).Element + }) + var rec RecurrenceResult + FormField(c, msg(c, "recurrence_editor.repeat"), FormFieldOptions{}, func() *ui.Element { + rec = RecurrenceEditor(c, &dr.Repeat, RecurrenceEditorOptions{Start: DateOf(dr.Start.In(loc)), WeekStart: opts.WeekStart, Disabled: opts.Disabled}) + return rec.Element + }) + datetimeFooter(c, func() { + if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() && !root.IsDisabled() { + slog.Debug("mujica: event_editor cancel", "id", ev.ID) + *st, r.canceled = nil, true + } + if Button(c, msg(c, "event_editor.save"), ButtonOptions{}).Clicked() && !root.IsDisabled() { + s.tried = true + if len(eventEditorProblems(c, *dr, true)) == 0 && rec.Problem() == "" { + slog.Debug("mujica: event_editor save", "id", ev.ID) + out := eventCopy(*dr) + if out.AllDay { + first, last := DateOf(out.Start.In(loc)), DateOf(out.End.Add(-time.Nanosecond).In(loc)) + out.Start, out.End = eventDayStart(first, loc), eventDayStart(last.AddDays(1), loc) + } + *ev, r.saved = out, true + } else { + slog.Debug("mujica: event_editor invalid", "id", ev.ID) + } + } + }) + }) + return r +} + +// eventEditorProblems checks a draft once Save was tried. +func eventEditorProblems(c *ui.Context, ev CalendarEvent, tried bool) map[string]string { + p := map[string]string{} + if !tried { + return p + } + if strings.TrimSpace(ev.Title) == "" { + p["title"] = msg(c, "event_editor.need_title") + } + if ev.Start.IsZero() { + p["event_editor.starts"] = msg(c, "event_editor.need_start") + } + if !ev.End.After(ev.Start) { + p["event_editor.ends"] = msg(c, "event_editor.end_order") + } + return p +} + +// eventAttendees keeps known attendees and adds new ones. +func eventAttendees(old []Attendee, names []string) []Attendee { + out := make([]Attendee, 0, len(names)) + for _, n := range names { + i := slices.IndexFunc(old, func(a Attendee) bool { return a.Name == n }) + if i >= 0 { + out = append(out, old[i]) + } else { + out = append(out, Attendee{Name: n}) + } + } + return out +} + +// eventCopy clones an event's slices for a draft. +func eventCopy(ev CalendarEvent) CalendarEvent { + ev.Attendees = slices.Clone(ev.Attendees) + ev.Reminders = slices.Clone(ev.Reminders) + ev.Repeat.Weekdays = slices.Clone(ev.Repeat.Weekdays) + return ev +} diff --git a/event_editor_test.go b/event_editor_test.go new file mode 100644 index 0000000..0c14ffe --- /dev/null +++ b/event_editor_test.go @@ -0,0 +1,112 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type editorProbe struct { + ev CalendarEvent + saves, cancels int + disabled bool +} + +func (p *editorProbe) tester() *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Scroll(c).Fill().Padding(16).Children(func() { + r := EventEditor(c, &p.ev, EventEditorOptions{Location: calTokyo, Disabled: p.disabled}) + if r.Saved() { + p.saves++ + } + if r.Canceled() { + p.cancels++ + } + }) + }, 900, 1400) +} + +func TestEventEditorValidatesAndSaves(t *testing.T) { + p := &editorProbe{ev: CalendarEvent{ID: "n", Start: calAt(t, 9, 0, 14), End: calAt(t, 9, 0, 13)}} + tt := p.tester() + _ = tt.Click("Save") + if p.saves != 0 || !tt.HasText("Enter a title.") || !tt.HasText("The end must be after the start.") { + t.Fatalf("invalid draft saved: %v", tt.Texts()) + } + _ = tt.Click("Title") + tt.Type("Masquerade") + _ = tt.Click("Location") + tt.Type("Hall") + _ = tt.Click("Guests") + tt.Type("Mutsumi") + tt.Key(0, ui.KeyEnter) + _ = tt.Click("15 minutes before") + _ = tt.Click("2026-10-13 09:00 JST") + _ = tt.Click(datetimeTestLabel(oct(14))) + _ = tt.Click("Time: Hours") + tt.Key(0, ui.KeyUp) + _ = tt.Click("Confirm") + if p.ev.Title != "" { + t.Fatal("the caller's event changed before Save") + } + _ = tt.Click("Save") + ev := p.ev + if p.saves != 1 || ev.Title != "Masquerade" || ev.Location != "Hall" || len(ev.Attendees) != 1 || ev.Attendees[0].Name != "Mutsumi" || + len(ev.Reminders) != 1 || !ev.End.Equal(calAt(t, 10, 0, 14)) { + t.Fatalf("saved %+v", ev) + } +} + +func TestEventEditorAllDayRepeatCancel(t *testing.T) { + p := &editorProbe{ev: calEvent("e", 14, 9, 0, 60)} + tt := p.tester() + _ = tt.Click("All day") + _ = tt.Click("Does not repeat") + _ = tt.Click("Weekly") + _ = tt.Click("Save") + if p.saves != 1 || !p.ev.AllDay || p.ev.Repeat.Unit != RepeatWeekly || p.ev.Repeat.Weekdays[0] != time.Wednesday || + !p.ev.Start.Equal(calAt(t, 0, 0, 14)) || !p.ev.End.Equal(calAt(t, 0, 0, 15)) { + t.Fatalf("saved %+v", p.ev) + } + _ = tt.Click("Title") + tt.Type(" draft") + _ = tt.Click("Cancel") + if p.cancels != 1 || p.ev.Title != "Event e" { + t.Fatalf("cancel kept %q", p.ev.Title) + } + tt.Frame() + _ = tt.Click("Save") + if p.ev.Title != "Event e" { + t.Fatal("canceled draft came back") + } +} + +func TestEventEditorDisabled(t *testing.T) { + p := &editorProbe{ev: calEvent("e", 14, 9, 0, 60), disabled: true} + tt := p.tester() + _ = tt.Click("Title") + tt.Type("x") + _ = tt.Click("Save") + _ = tt.Click("Cancel") + if p.saves != 0 || p.cancels != 0 || p.ev.Title != "Event e" { + t.Fatal("disabled editor acted") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without Location") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + EventEditor(c, &p.ev, EventEditorOptions{}) + }, 10, 10) +} + +func TestEventEditorSnapshot(t *testing.T) { + ev := calRichEvent() + snapshot(t, "event_editor", 760, 1100, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { EventEditor(c, &ev, EventEditorOptions{Location: calTokyo}) }) + }) +} diff --git a/event_popover.go b/event_popover.go new file mode 100644 index 0000000..bbb243f --- /dev/null +++ b/event_popover.go @@ -0,0 +1,114 @@ +package mujica + +import ( + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// EventPopoverOptions configure an EventPopover. +type EventPopoverOptions struct { + // Location is the zone times show in; required. + Location *time.Location + Hour12 bool + // ReadOnly hides the edit and delete actions. + ReadOnly bool + Disabled bool +} + +// EventPopoverResult is the popover and the action the user chose. +type EventPopoverResult struct { + Element *ui.Element + edit bool + del bool +} + +// Edit reports a click on Edit. +func (r EventPopoverResult) Edit() bool { return r.edit } + +// Delete reports a click on Delete. +func (r EventPopoverResult) Delete() bool { return r.del } + +// EventPopover summarizes an event beside anchor while *open. +func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult { + loc := eventLocation("EventPopover", opts.Location) + k := Tokens(c) + d := Density(c) + var r EventPopoverResult + if *open && (opts.Disabled || anchor.IsDisabled()) { + slog.Debug("mujica: event_popover close", "disabled", true) + *open = false + } + r.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + overlayStyle(c, panel, overlayEnter(c, panel)) + ww, wh := c.Size() + panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 4).Gap(d.Unit() * 2).Width(min(d.ControlHeight()*9, ww-16)).MaxHeight(wh - 16). + Role(ui.RoleDialog).Label(ev.Title) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(10, 10).Shrink(0).Draw(func(p *ui.Painter, rr ui.Rect) { + p.FillPath(internal.Diamond(rr.X+rr.W/2, rr.Y+rr.H/2, rr.W), eventColor(k, ev.Color)) + }) + ui.Text(c, ev.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).Shrink(1) + if iconAction(c, icons.Must("x"), msg(c, "event_popover.close")).Clicked() { + *open = false + } + }) + ui.Scroll(c).Shrink(1).Children(func() { + ui.Column(c).Gap(d.Unit() * 2).Children(func() { + for _, line := range eventPopoverLines(c, ev, loc, opts.Hour12) { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() { + ui.Icon(c, icons.Must(line[0])).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, line[1]).TextColor(k.Text).Grow(1).Shrink(1) + }) + } + }) + }) + if opts.ReadOnly { + return + } + datetimeFooter(c, func() { + if Button(c, msg(c, "event_popover.delete"), ButtonOptions{Variant: Danger}).Clicked() { + slog.Debug("mujica: event_popover delete", "id", ev.ID) + r.del, *open = true, false + } + if Button(c, msg(c, "event_popover.edit"), ButtonOptions{Variant: Secondary}).Clicked() { + slog.Debug("mujica: event_popover edit", "id", ev.ID) + r.edit, *open = true, false + } + }) + }) + return r +} + +// eventPopoverLines lists icon and text pairs for the summary. +func eventPopoverLines(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) [][2]string { + s := ev.Start.In(loc) + lines := [][2]string{{"clock", datetimeDayLabel(c, DateOf(s)) + " · " + eventTimeText(c, ev, loc, hour12)}} + if ev.Location != "" { + lines = append(lines, [2]string{"house", ev.Location}) + } + if ev.Repeat.Unit != RepeatNone { + lines = append(lines, [2]string{"refresh-cw", RecurrenceSummary(c, ev.Repeat)}) + } + if len(ev.Attendees) > 0 { + var names []string + for _, a := range ev.Attendees { + names = append(names, a.Name) + } + lines = append(lines, [2]string{"user", calendarCount(c, "event_popover.guests", len(names)) + ": " + strings.Join(names, ", ")}) + } + if len(ev.Reminders) > 0 { + var rs []string + for _, d := range ev.Reminders { + rs = append(rs, ReminderText(c, d)) + } + lines = append(lines, [2]string{"bell", strings.Join(rs, msg(c, "recurrence_editor.list_sep"))}) + } + return lines +} diff --git a/event_popover_test.go b/event_popover_test.go new file mode 100644 index 0000000..cbd1ec2 --- /dev/null +++ b/event_popover_test.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func calRichEvent() CalendarEvent { + ev := calEvent("r", 14, 19, 0, 120) + ev.Title, ev.Location, ev.Color = "Masquerade rehearsal", "Rehearsal hall", EventInfo + ev.Attendees = calAttendees[:2] + ev.Reminders = []time.Duration{15 * time.Minute, time.Hour} + ev.Repeat = Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Wednesday}} + return ev +} + +type popoverProbe struct { + open bool + opts EventPopoverOptions + edits, deletes int +} + +func (p *popoverProbe) tester() *ui.Tester { + p.opts.Location = calTokyo + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Children(func() { + b := Button(c, "Details", ButtonOptions{Variant: Secondary}) + if b.Clicked() { + p.open = true + } + r := EventPopover(c, b, &p.open, calRichEvent(), p.opts) + if r.Edit() { + p.edits++ + } + if r.Delete() { + p.deletes++ + } + }) + }, 600, 500) +} + +func TestEventPopoverSummaryAndActions(t *testing.T) { + p := &popoverProbe{} + tt := p.tester() + _ = tt.Click("Details") + for _, s := range []string{"Masquerade rehearsal", "Wednesday, October 14, 2026 · 19:00 – 21:00", "Rehearsal hall", + "Weekly on Wednesday", "2 guests: Sakiko Togawa, Mutsumi Wakaba", "15 minutes before, 1 hour before"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %v", s, tt.Texts()) + } + } + _ = tt.Click("Edit") + if p.edits != 1 || p.open { + t.Fatal("Edit should report and close") + } + _ = tt.Click("Details") + tt.Key(0, ui.KeyTab) + _ = tt.Click("Delete") + if p.deletes != 1 || p.open { + t.Fatal("Delete should report and close") + } + _ = tt.Click("Details") + tt.Key(0, ui.KeyEscape) + if p.open { + t.Fatal("Escape should close") + } +} + +func TestEventPopoverReadOnlyAndDisabled(t *testing.T) { + p := &popoverProbe{opts: EventPopoverOptions{ReadOnly: true}} + tt := p.tester() + _ = tt.Click("Details") + if tt.HasText("Edit") || tt.HasText("Delete") || !tt.HasText("Rehearsal hall") { + t.Fatal("read-only popover shows actions") + } + p.opts.ReadOnly, p.opts.Disabled = false, true + tt.Frame() + if p.open || tt.HasText("Rehearsal hall") { + t.Fatal("disabling should close the popover") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without Location") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + open := true + EventPopover(c, ui.Box(c), &open, calRichEvent(), EventPopoverOptions{}) + }, 10, 10) +} + +func TestEventPopoverSnapshot(t *testing.T) { + snapshot(t, "event_popover", 460, 400, func(c *ui.Context) { + ui.Row(c).Padding(16).Children(func() { + b := Button(c, "Details", ButtonOptions{Variant: Secondary}) + // Open once the anchor is laid out. + frames := ui.Local(c.Root(), "frames", func() int { return 0 }) + *frames++ + if *frames == 1 { + c.Invalidate() + } + open := *frames > 1 + EventPopover(c, b, &open, calRichEvent(), EventPopoverOptions{Location: calTokyo}) + }) + }) +} diff --git a/example_calendar_test.go b/example_calendar_test.go new file mode 100644 index 0000000..badbc93 --- /dev/null +++ b/example_calendar_test.go @@ -0,0 +1,262 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +func ExampleCalendarMonthView() { + loc := time.Local + events := exampleEvents(loc) + month := mujica.YearMonth{Year: 2026, Month: time.October} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc}) + if ch, ok := r.Changed(); ok { + fmt.Println("move", ch.ID, "to", ch.Start) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCalendarWeekView() { + loc := time.Local + events := exampleEvents(loc) + week := mujica.Date{Year: 2026, Month: time.October, Day: 14} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15}) + if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate { + fmt.Println("new event", ch.Start, ch.End) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCalendarDayView() { + loc := time.Local + events := exampleEvents(loc) + day := mujica.Date{Year: 2026, Month: time.October, Day: 14} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok { + fmt.Println("open", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAgendaView() { + loc := time.Local + events := exampleEvents(loc) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14} + if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok { + fmt.Println("open", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleYearView() { + loc := time.Local + events := exampleEvents(loc) + year := 2026 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok { + fmt.Println("go to", day) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEventChip() { + loc := time.Local + ev := exampleEvents(loc)[0] + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() { + fmt.Println("open", ev.ID) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEventEditor() { + loc := time.Local + ev := exampleEvents(loc)[0] + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc}) + if r.Saved() { + fmt.Println("saved", ev.Title, ev.Start) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEventPopover() { + loc := time.Local + ev := exampleEvents(loc)[0] + open := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}) + if chip.Clicked() { + open = true + } + r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc}) + if r.Delete() { + fmt.Println("delete", ev.ID) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCurrentTimeIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(200, 240).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRecurrenceEditor() { + rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}}) + if r.Changed() && r.Problem() == "" { + fmt.Println(mujica.RecurrenceSummary(c, rule)) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAvailabilityPicker() { + days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}} + mine := [][]bool{make([]bool, 18), make([]bool, 18)} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() { + fmt.Println("availability changed") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTimezoneSelect() { + zone := "Asia/Shanghai" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() { + fmt.Println("zone", zone) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleReminderPicker() { + reminders := []time.Duration{15 * time.Minute} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() { + fmt.Println("reminders", reminders) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAttendeeList() { + people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok { + people = append(people[:i], people[i+1:]...) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleWeekPicker() { + var week mujica.Date + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() { + fmt.Println("week of", week) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDurationPicker() { + length := 90 * time.Minute + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour} + if mujica.DurationPicker(c, &length, opts).Changed() { + fmt.Println("length", length) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCronEditor() { + expr := "0 9 * * MON-FRI" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() { + if _, err := mujica.ParseCron(expr); err == nil { + fmt.Println("schedule", expr) + } + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRelativeTime() { + posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCountdown() { + deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() { + fmt.Println("happy new year") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStopwatch() { + var sw mujica.StopwatchState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() { + fmt.Println("laps", len(sw.Laps)) + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_calendar.go b/examples/gallery/pages_calendar.go index 32f9be8..fef821f 100644 --- a/examples/gallery/pages_calendar.go +++ b/examples/gallery/pages_calendar.go @@ -1,3 +1,46 @@ package main -var calendarPages = []page{} +const calCategory = "Calendar" + +var calendarPages = []page{ + {ID: "421", Name: "CalendarMonthView", ZhName: "月视图", Category: calCategory, Demo: calMonthDemo, Code: calCalendarMonthViewCode, + Keys: "Tab reaches day numbers and event chips; arrows move between them; Enter opens; Alt+Left/Right moves an event a day, Alt+Up/Down a week; Escape closes the +N list."}, + {ID: "422", Name: "CalendarWeekView", ZhName: "周视图", Category: calCategory, Demo: calWeekDemo, Code: calCalendarWeekViewCode, + Keys: "Tab reaches a day column; Up/Down move the slot cursor, Enter creates a one-slot event; on a chip Alt+Up/Down moves by a slot, Alt+Left/Right by a day, Alt+Shift+Up/Down resizes."}, + {ID: "423", Name: "CalendarDayView", ZhName: "日视图", Category: calCategory, Demo: calDayDemo, Code: calCalendarDayViewCode, + Keys: "As the week view: Up/Down and Enter in the column, Alt with arrows on a chip."}, + {ID: "424", Name: "AgendaView", ZhName: "日程列表", Category: calCategory, Demo: calAgendaDemo, Code: calAgendaViewCode, + Keys: "Tab enters the list; Up/Down move between events; Enter opens."}, + {ID: "425", Name: "YearView", ZhName: "年视图", Category: calCategory, Demo: calYearDemo, Code: calYearViewCode, + Keys: "Tab enters the days; arrows move by day and week, PageUp/PageDown by month, across years; Enter chooses."}, + {ID: "426", Name: "EventChip", ZhName: "事件块", Category: calCategory, Demo: calChipDemo, Code: calEventChipCode, + Keys: "Tab focuses the chip; Enter or Space activates it."}, + {ID: "427", Name: "EventEditor", ZhName: "事件编辑器", Category: calCategory, Demo: calEditorDemo, Code: calEventEditorCode, + Keys: "Tab moves through the fields; each field keeps its own keys; Save and Cancel are buttons."}, + {ID: "428", Name: "EventPopover", ZhName: "事件浮层", Category: calCategory, Demo: calPopoverDemo, Code: calEventPopoverCode, + Keys: "Enter on the chip opens it; Tab reaches Close, Delete and Edit; Escape closes."}, + {ID: "429", Name: "CurrentTimeIndicator", ZhName: "当前时间线", Category: calCategory, Demo: calNowDemo, Code: calCurrentTimeIndicatorCode, + Keys: "Not focusable; screen readers read the time from its label."}, + {ID: "430", Name: "RecurrenceEditor", ZhName: "重复规则编辑器", Category: calCategory, Demo: calRecurrenceDemo, Code: calRecurrenceEditorCode, + Keys: "Tab moves between the repeat select, interval, weekdays and end choices; each control keeps its own keys."}, + {ID: "431", Name: "AvailabilityPicker", ZhName: "空闲时段选择", Category: calCategory, Demo: calAvailabilityDemo, Code: calAvailabilityPickerCode, + Keys: "Tab focuses the grid; arrows move the cursor; Space or Enter toggles the slot."}, + {ID: "432", Name: "TimezoneSelect", ZhName: "时区选择", Category: calCategory, Demo: calTimezoneDemo, Code: calTimezoneSelectCode, + Keys: "Enter, Space or Down opens; type to search; Up/Down move; Enter chooses; Escape closes."}, + {ID: "433", Name: "ReminderPicker", ZhName: "提醒选择", Category: calCategory, Demo: calReminderDemo, Code: calReminderPickerCode, + Keys: "Tab enters the list; Up/Down move; Space toggles."}, + {ID: "434", Name: "AttendeeList", ZhName: "参与者列表", Category: calCategory, Demo: calAttendeeDemo, Code: calAttendeeListCode, + Keys: "Tab reaches each remove button; Enter removes."}, + {ID: "435", Name: "WeekPicker", ZhName: "周选择器", Category: calCategory, Demo: calWeekPickerDemo, Code: calWeekPickerCode, + Keys: "Enter, Space or Down opens; arrows move; Enter picks the week; Escape closes."}, + {ID: "436", Name: "DurationPicker", ZhName: "时长选择器", Category: calCategory, Demo: calDurationDemo, Code: calDurationPickerCode, + Keys: "Tab moves between hours, minutes and seconds; Up/Down step; type digits and Enter."}, + {ID: "437", Name: "CronEditor", ZhName: "Cron 编辑器", Category: calCategory, Demo: calCronDemo, Code: calCronEditorCode, + Keys: "Type in the expression field; errors and the next runs update as you type."}, + {ID: "438", Name: "RelativeTime", ZhName: "相对时间", Category: calCategory, Demo: calRelativeDemo, Code: calRelativeTimeCode, + Keys: "Not focusable; the exact time is in its label and tooltip."}, + {ID: "439", Name: "Countdown", ZhName: "倒计时", Category: calCategory, Demo: calCountdownDemo, Code: calCountdownCode, + Keys: "Not focusable; reaching zero is announced."}, + {ID: "440", Name: "Stopwatch", ZhName: "秒表", Category: calCategory, Demo: calStopwatchDemo, Code: calStopwatchCode, + Keys: "Tab reaches Start/Pause, Lap and Reset; Enter or Space presses."}, +} diff --git a/examples/gallery/pages_calendar_code.go b/examples/gallery/pages_calendar_code.go new file mode 100644 index 0000000..949e14d --- /dev/null +++ b/examples/gallery/pages_calendar_code.go @@ -0,0 +1,253 @@ +package main + +// Code snippets mirror example_calendar_test.go. +const ( + calCalendarMonthViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +month := mujica.YearMonth{Year: 2026, Month: time.October} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc}) + if ch, ok := r.Changed(); ok { + fmt.Println("move", ch.ID, "to", ch.Start) + } +} +ui.Render(view, 400, 300, 1)` + calCalendarWeekViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +week := mujica.Date{Year: 2026, Month: time.October, Day: 14} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15}) + if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate { + fmt.Println("new event", ch.Start, ch.End) + } +} +ui.Render(view, 400, 300, 1)` + calCalendarDayViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +day := mujica.Date{Year: 2026, Month: time.October, Day: 14} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok { + fmt.Println("open", id) + } +} +ui.Render(view, 400, 300, 1)` + calAgendaViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14} + if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok { + fmt.Println("open", id) + } +} +ui.Render(view, 400, 300, 1)` + calYearViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +year := 2026 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok { + fmt.Println("go to", day) + } +} +ui.Render(view, 400, 300, 1)` + calEventChipCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +ev := exampleEvents(loc)[0] +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() { + fmt.Println("open", ev.ID) + } +} +ui.Render(view, 400, 300, 1)` + calEventEditorCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +ev := exampleEvents(loc)[0] +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc}) + if r.Saved() { + fmt.Println("saved", ev.Title, ev.Start) + } +} +ui.Render(view, 400, 300, 1)` + calEventPopoverCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +ev := exampleEvents(loc)[0] +open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}) + if chip.Clicked() { + open = true + } + r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc}) + if r.Delete() { + fmt.Println("delete", ev.ID) + } +} +ui.Render(view, 400, 300, 1)` + calCurrentTimeIndicatorCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(200, 240).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) +} +ui.Render(view, 400, 300, 1)` + calRecurrenceEditorCode = `rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}}) + if r.Changed() && r.Problem() == "" { + fmt.Println(mujica.RecurrenceSummary(c, rule)) + } +} +ui.Render(view, 400, 300, 1)` + calAvailabilityPickerCode = `days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}} +mine := [][]bool{make([]bool, 18), make([]bool, 18)} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() { + fmt.Println("availability changed") + } +} +ui.Render(view, 400, 300, 1)` + calTimezoneSelectCode = `zone := "Asia/Shanghai" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() { + fmt.Println("zone", zone) + } +} +ui.Render(view, 400, 300, 1)` + calReminderPickerCode = `reminders := []time.Duration{15 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() { + fmt.Println("reminders", reminders) + } +} +ui.Render(view, 400, 300, 1)` + calAttendeeListCode = `people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok { + people = append(people[:i], people[i+1:]...) + } +} +ui.Render(view, 400, 300, 1)` + calWeekPickerCode = `var week mujica.Date +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() { + fmt.Println("week of", week) + } +} +ui.Render(view, 400, 300, 1)` + calDurationPickerCode = `length := 90 * time.Minute +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour} + if mujica.DurationPicker(c, &length, opts).Changed() { + fmt.Println("length", length) + } +} +ui.Render(view, 400, 300, 1)` + calCronEditorCode = `expr := "0 9 * * MON-FRI" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() { + if _, err := mujica.ParseCron(expr); err == nil { + fmt.Println("schedule", expr) + } + } +} +ui.Render(view, 400, 300, 1)` + calRelativeTimeCode = `posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local}) +} +ui.Render(view, 400, 300, 1)` + calCountdownCode = `deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() { + fmt.Println("happy new year") + } +} +ui.Render(view, 400, 300, 1)` + calStopwatchCode = `var sw mujica.StopwatchState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() { + fmt.Println("laps", len(sw.Laps)) + } +} +ui.Render(view, 400, 300, 1)` +) diff --git a/examples/gallery/pages_calendar_parts.go b/examples/gallery/pages_calendar_parts.go new file mode 100644 index 0000000..fa08df4 --- /dev/null +++ b/examples/gallery/pages_calendar_parts.go @@ -0,0 +1,217 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +var calColors = []string{"Accent", "Info", "Success", "Warning", "Danger", "Neutral"} + +func calChipDemo(c *ui.Context, log func(string)) { + type state struct { + color string + inline, selected, disabled bool + } + s := ui.Local(c.Root(), "426", func() state { return state{color: "Accent"} }) + dtSection(c, "EventChip", func() { + dtControls(c, func() { + mujica.Select(c, &s.color, calOptions(calColors), mujica.SelectOptions{Label: "Color"}) + mujica.Checkbox(c, &s.inline, "Inline", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.selected, "Selected", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + ev := calSample()[0] + for i, n := range calColors { + if n == s.color { + ev.Color = mujica.EventColor(i) + } + } + ui.Box(c).Width(240).Children(func() { + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: time.Local, Inline: s.inline, Selected: s.selected, Disabled: s.disabled}).Clicked() { + log("clicked " + ev.Title) + } + }) + }) +} + +func calEditorDemo(c *ui.Context, log func(string)) { + type state struct { + ev mujica.CalendarEvent + disabled bool + } + s := ui.Local(c.Root(), "427", func() state { return state{ev: calSample()[0]} }) + dtSection(c, "EventEditor", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + r := mujica.EventEditor(c, &s.ev, mujica.EventEditorOptions{Location: time.Local, Disabled: s.disabled}) + if r.Saved() { + log(fmt.Sprintf("saved %q %s, %d guests", s.ev.Title, s.ev.Start.Format("Jan 2 15:04"), len(s.ev.Attendees))) + } + if r.Canceled() { + log("canceled") + } + }) +} + +func calPopoverDemo(c *ui.Context, log func(string)) { + type state struct { + open, readOnly, disabled bool + } + s := ui.Local(c.Root(), "428", func() state { return state{} }) + dtSection(c, "EventPopover", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.readOnly, "Read-only", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + ev := calSample()[0] + ev.Attendees = []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} + ev.Reminders = []time.Duration{15 * time.Minute} + ui.Box(c).Width(240).Children(func() { + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: time.Local, Disabled: s.disabled}) + if chip.Clicked() { + s.open = true + log("opened popover") + } + r := mujica.EventPopover(c, chip, &s.open, ev, mujica.EventPopoverOptions{Location: time.Local, ReadOnly: s.readOnly, Disabled: s.disabled}) + if r.Edit() { + log("edit " + ev.ID) + } + if r.Delete() { + log("delete " + ev.ID) + } + }) + }) +} + +func calNowDemo(c *ui.Context, log func(string)) { + dtSection(c, "CurrentTimeIndicator", func() { + k := mujica.Tokens(c) + ui.Box(c).Size(320, 240).Border(1, k.Border).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) + }) +} + +func calRecurrenceDemo(c *ui.Context, log func(string)) { + type state struct { + rule mujica.Recurrence + disabled bool + } + s := ui.Local(c.Root(), "430", func() state { return state{} }) + dtSection(c, "RecurrenceEditor", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + r := mujica.RecurrenceEditor(c, &s.rule, mujica.RecurrenceEditorOptions{Start: mujica.DateOf(time.Now()), Disabled: s.disabled}) + if r.Changed() { + log(mujica.RecurrenceSummary(c, s.rule) + strings.TrimSpace(" "+r.Problem())) + } + }) +} + +func calAvailabilityDemo(c *ui.Context, log func(string)) { + type state struct { + mine, theirs [][]bool + days []mujica.Date + others, frozen bool + } + s := ui.Local(c.Root(), "431", func() state { + today := mujica.DateOf(time.Now()) + grid := func(seed int) [][]bool { + g := make([][]bool, 5) + for i := range g { + g[i] = make([]bool, 18) + for j := range g[i] { + g[i][j] = (i*7+j*3+seed)%4 == 0 + } + } + return g + } + days := []mujica.Date{today, today.AddDays(1), today.AddDays(2), today.AddDays(3), today.AddDays(4)} + return state{mine: grid(2), theirs: grid(1), days: days, others: true} + }) + dtSection(c, "AvailabilityPicker", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.others, "Show Mutsumi", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.frozen, "Disabled", mujica.CheckboxOptions{}) + }) + var others []mujica.AvailabilityPerson + if s.others { + others = []mujica.AvailabilityPerson{{Name: "Mutsumi", Free: s.theirs}} + } + if mujica.AvailabilityPicker(c, &s.mine, mujica.AvailabilityPickerOptions{Days: s.days, Others: others, Disabled: s.frozen}).Changed() { + n := 0 + for _, d := range s.mine { + for _, v := range d { + if v { + n++ + } + } + } + log(fmt.Sprintf("%d free slots", n)) + } + }) +} + +func calTimezoneDemo(c *ui.Context, log func(string)) { + type state struct { + zone string + disabled bool + } + s := ui.Local(c.Root(), "432", func() state { return state{zone: "Asia/Shanghai"} }) + dtSection(c, "TimezoneSelect", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + if mujica.TimezoneSelect(c, &s.zone, mujica.TimezoneSelectOptions{Disabled: s.disabled}).Changed() { + log("zone " + s.zone) + } + }) +} + +func calReminderDemo(c *ui.Context, log func(string)) { + type state struct { + v []time.Duration + max string + disabled bool + } + s := ui.Local(c.Root(), "433", func() state { return state{v: []time.Duration{15 * time.Minute}, max: "0"} }) + dtSection(c, "ReminderPicker", func() { + dtControls(c, func() { + mujica.Select(c, &s.max, calOptions([]string{"0", "2", "3"}), mujica.SelectOptions{Label: "Max (0 = any)"}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + if limit := calAtoi(s.max); limit > 0 && len(s.v) > limit { + s.v = s.v[:limit] + } + if mujica.ReminderPicker(c, &s.v, mujica.ReminderPickerOptions{Max: calAtoi(s.max), Disabled: s.disabled}).Changed() { + log(fmt.Sprint("reminders ", s.v)) + } + }) +} + +func calAttendeeDemo(c *ui.Context, log func(string)) { + type state struct { + list []mujica.Attendee + removable bool + } + people := func() []mujica.Attendee { + return []mujica.Attendee{{Name: "Sakiko Togawa", Status: mujica.AttendeeAccepted, Organizer: true}, + {Name: "Mutsumi Wakaba", Status: mujica.AttendeeTentative}, {Name: "Uika Misumi", Status: mujica.AttendeeDeclined}, + {Name: "Nyamu Yuutenji", Email: "nyamu@example.com"}} + } + s := ui.Local(c.Root(), "434", func() state { return state{list: people(), removable: true} }) + dtSection(c, "AttendeeList", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.removable, "Removable", mujica.CheckboxOptions{}) + if mujica.Button(c, "Reset", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.list = people() + log("reset") + } + }) + if i, ok := mujica.AttendeeList(c, s.list, mujica.AttendeeListOptions{Removable: s.removable}).Removed(); ok { + log("removed " + s.list[i].Name) + s.list = append(s.list[:i:i], s.list[i+1:]...) + } + }) +} diff --git a/examples/gallery/pages_calendar_time.go b/examples/gallery/pages_calendar_time.go new file mode 100644 index 0000000..78c432a --- /dev/null +++ b/examples/gallery/pages_calendar_time.go @@ -0,0 +1,128 @@ +package main + +import ( + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func calWeekPickerDemo(c *ui.Context, log func(string)) { + type state struct { + week mujica.Date + weekStart string + disabled bool + } + s := ui.Local(c.Root(), "435", func() state { return state{weekStart: "Locale"} }) + dtSection(c, "WeekPicker", func() { + dtControls(c, func() { + if mujica.Select(c, &s.weekStart, calOptions(calWeekStarts), mujica.SelectOptions{Label: "Week start"}).Changed() { + s.week = mujica.Date{} + log("week start " + s.weekStart + ", value cleared") + } + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + opts := mujica.WeekPickerOptions{WeekStart: calWeekStart(s.weekStart), Clearable: true, Disabled: s.disabled} + if mujica.WeekPicker(c, &s.week, opts).Changed() { + log("week of " + s.week.String()) + } + }) +} + +func calDurationDemo(c *ui.Context, log func(string)) { + type state struct { + v time.Duration + seconds, disabled bool + } + s := ui.Local(c.Root(), "436", func() state { return state{v: 90 * time.Minute} }) + dtSection(c, "DurationPicker", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.seconds, "Seconds", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + opts := mujica.DurationPickerOptions{Seconds: s.seconds, Min: 15 * time.Minute, Max: 8 * time.Hour, Disabled: s.disabled} + if mujica.DurationPicker(c, &s.v, opts).Changed() { + log("duration " + s.v.String()) + } + }) +} + +func calCronDemo(c *ui.Context, log func(string)) { + type state struct { + expr string + disabled bool + } + s := ui.Local(c.Root(), "437", func() state { return state{expr: "*/15 9-17 * * MON-FRI"} }) + dtSection(c, "CronEditor", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + if mujica.CronEditor(c, &s.expr, mujica.CronEditorOptions{Location: time.Local, Disabled: s.disabled}).Changed() { + if _, err := mujica.ParseCron(s.expr); err != nil { + log("invalid: " + s.expr) + } else { + log("schedule " + s.expr) + } + } + }) +} + +func calRelativeDemo(c *ui.Context, log func(string)) { + type state struct{ at time.Time } + s := ui.Local(c.Root(), "438", func() state { return state{at: time.Now()} }) + dtSection(c, "RelativeTime", func() { + dtControls(c, func() { + for _, b := range []struct { + label string + d time.Duration + }{{"Now", 0}, {"5 minutes ago", -5 * time.Minute}, {"In 3 hours", 3 * time.Hour}, {"40 days ago", -40 * 24 * time.Hour}} { + if mujica.Button(c, b.label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.at = time.Now().Add(b.d) + log("time set: " + b.label) + } + } + }) + mujica.RelativeTime(c, s.at, mujica.RelativeTimeOptions{Location: time.Local}) + }) +} + +func calCountdownDemo(c *ui.Context, log func(string)) { + type state struct { + deadline time.Time + words bool + } + s := ui.Local(c.Root(), "439", func() state { return state{deadline: time.Now().Add(10 * time.Second)} }) + dtSection(c, "Countdown", func() { + dtControls(c, func() { + if mujica.Button(c, "Restart 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.deadline = time.Now().Add(10 * time.Second) + log("restarted") + } + mujica.Checkbox(c, &s.words, "Words", mujica.CheckboxOptions{}) + }) + format := mujica.CountdownClock + if s.words { + format = mujica.CountdownWords + } + if mujica.Countdown(c, s.deadline, mujica.CountdownOptions{Format: format}).Finished() { + log("finished") + } + }) +} + +func calStopwatchDemo(c *ui.Context, log func(string)) { + type state struct { + sw mujica.StopwatchState + disabled bool + } + s := ui.Local(c.Root(), "440", func() state { return state{} }) + dtSection(c, "Stopwatch", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + if mujica.Stopwatch(c, &s.sw, mujica.StopwatchOptions{Disabled: s.disabled}).Changed() { + state := "paused" + if s.sw.Running() { + state = "running" + } + log(state + ", " + mujica.StopwatchText(s.sw.Elapsed(time.Now()))) + } + }) +} diff --git a/examples/gallery/pages_calendar_views.go b/examples/gallery/pages_calendar_views.go new file mode 100644 index 0000000..b7b627b --- /dev/null +++ b/examples/gallery/pages_calendar_views.go @@ -0,0 +1,194 @@ +package main + +import ( + "fmt" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// calSample is demo events around today in the local zone. +func calSample() []mujica.CalendarEvent { + now := time.Now() + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + at := func(d, h, m, mins int) (time.Time, time.Time) { + s := day.AddDate(0, 0, d).Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute) + return s, s.Add(time.Duration(mins) * time.Minute) + } + mk := func(id, title string, color mujica.EventColor, d, h, m, mins int) mujica.CalendarEvent { + s, e := at(d, h, m, mins) + return mujica.CalendarEvent{ID: id, Title: title, Color: color, Start: s, End: e, Location: "Rehearsal hall"} + } + evs := []mujica.CalendarEvent{ + mk("1", "Rehearsal", mujica.EventAccent, 0, 9, 0, 120), mk("2", "Costume fitting", mujica.EventInfo, 0, 10, 0, 60), + mk("3", "Lunch", mujica.EventSuccess, 0, 12, 30, 60), mk("4", "Lyrics review", mujica.EventWarning, 1, 14, 0, 90), + mk("5", "Press", mujica.EventDanger, 2, 16, 0, 45), mk("6", "Sound check", mujica.EventNeutral, 3, 18, 0, 60), + mk("7", "Encore notes", mujica.EventInfo, 0, 15, 0, 30), mk("8", "Stage walk", mujica.EventAccent, 0, 17, 0, 30), + } + holiday := mk("9", "Masquerade", mujica.EventAccent, 4, 0, 0, 24*60) + holiday.AllDay, holiday.Start = true, day.AddDate(0, 0, 4) + holiday.End = holiday.Start.AddDate(0, 0, 1) + return append(evs, holiday) +} + +// calApply applies and logs a view change. +func calApply(evs *[]mujica.CalendarEvent, ch mujica.CalendarChange, log func(string)) { + switch ch.Kind { + case mujica.CalendarCreate: + id := fmt.Sprint(len(*evs) + 100) + *evs = append(*evs, mujica.CalendarEvent{ID: id, Title: "New event", Start: ch.Start, End: ch.End}) + log("created " + ch.Start.Format("Jan 2 15:04") + " – " + ch.End.Format("15:04")) + return + } + for i := range *evs { + if (*evs)[i].ID == ch.ID { + (*evs)[i].Start, (*evs)[i].End = ch.Start, ch.End + } + } + verb := "moved" + if ch.Kind == mujica.CalendarResize { + verb = "resized" + } + log(fmt.Sprintf("%s %s to %s – %s", verb, ch.ID, ch.Start.Format("Jan 2 15:04"), ch.End.Format("15:04"))) +} + +// calViewResult logs what a view reported and applies its change. +func calViewResult(r mujica.CalendarViewResult, evs *[]mujica.CalendarEvent, version *int, log func(string)) { + if id, ok := r.Opened(); ok { + log("opened " + id) + } + if d, ok := r.DayChosen(); ok { + log("day " + d.String()) + } + if ch, ok := r.Changed(); ok { + calApply(evs, ch, log) + *version++ + } +} + +var calWeekStarts = []string{"Locale", "Sunday", "Monday", "Saturday"} + +func calWeekStart(s string) mujica.WeekStart { + for i, n := range calWeekStarts { + if n == s { + return mujica.WeekStart(i) + } + } + panic("gallery: unknown week start " + s) +} + +func calMonthDemo(c *ui.Context, log func(string)) { + type state struct { + month mujica.YearMonth + events []mujica.CalendarEvent + version, maxRows int + weekStart, maxStr string + disabled bool + } + s := ui.Local(c.Root(), "421", func() state { + now := time.Now() + return state{month: mujica.YearMonth{Year: now.Year(), Month: now.Month()}, events: calSample(), weekStart: "Locale", maxStr: "3"} + }) + dtSection(c, "CalendarMonthView", func() { + dtControls(c, func() { + mujica.Select(c, &s.weekStart, calOptions(calWeekStarts), mujica.SelectOptions{Label: "Week start"}) + mujica.Select(c, &s.maxStr, calOptions([]string{"1", "2", "3", "4"}), mujica.SelectOptions{Label: "Rows per day"}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + s.maxRows = calAtoi(s.maxStr) + r := mujica.CalendarMonthView(c, &s.month, s.events, mujica.CalendarMonthViewOptions{Location: time.Local, Version: s.version, + WeekStart: calWeekStart(s.weekStart), MaxPerDay: s.maxRows, Disabled: s.disabled}) + calViewResult(r, &s.events, &s.version, log) + }) +} + +func calWeekDemo(c *ui.Context, log func(string)) { + type state struct { + week mujica.Date + events []mujica.CalendarEvent + version int + slot string + hour12 bool + disabled bool + } + s := ui.Local(c.Root(), "422", func() state { + return state{week: mujica.DateOf(time.Now()), events: calSample(), slot: "30"} + }) + dtSection(c, "CalendarWeekView", func() { + dtControls(c, func() { + mujica.Select(c, &s.slot, calOptions([]string{"15", "30", "60"}), mujica.SelectOptions{Label: "Slot minutes"}) + mujica.Checkbox(c, &s.hour12, "12-hour", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + slot := calAtoi(s.slot) + r := mujica.CalendarWeekView(c, &s.week, s.events, mujica.CalendarWeekViewOptions{Location: time.Local, Version: s.version, + SlotMinutes: slot, Hour12: s.hour12, Disabled: s.disabled}) + calViewResult(r, &s.events, &s.version, log) + }) +} + +func calDayDemo(c *ui.Context, log func(string)) { + type state struct { + day mujica.Date + events []mujica.CalendarEvent + version int + disabled bool + } + s := ui.Local(c.Root(), "423", func() state { return state{day: mujica.DateOf(time.Now()), events: calSample()} }) + dtSection(c, "CalendarDayView", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + r := mujica.CalendarDayView(c, &s.day, s.events, mujica.CalendarDayViewOptions{Location: time.Local, Version: s.version, Disabled: s.disabled}) + calViewResult(r, &s.events, &s.version, log) + }) +} + +func calAgendaDemo(c *ui.Context, log func(string)) { + type state struct { + events []mujica.CalendarEvent + days string + } + s := ui.Local(c.Root(), "424", func() state { return state{events: calSample(), days: "7"} }) + dtSection(c, "AgendaView", func() { + dtControls(c, func() { + mujica.Select(c, &s.days, calOptions([]string{"1", "7", "30"}), mujica.SelectOptions{Label: "Days"}) + }) + days := calAtoi(s.days) + ui.Column(c).Height(420).Children(func() { + if id, ok := mujica.AgendaView(c, s.events, mujica.AgendaViewOptions{Location: time.Local, Days: days}).Opened(); ok { + log("opened " + id) + } + }) + }) +} + +func calYearDemo(c *ui.Context, log func(string)) { + type state struct { + year int + events []mujica.CalendarEvent + } + s := ui.Local(c.Root(), "425", func() state { return state{year: time.Now().Year(), events: calSample()} }) + dtSection(c, "YearView", func() { + if d, ok := mujica.YearView(c, &s.year, s.events, mujica.YearViewOptions{Location: time.Local}).DayChosen(); ok { + log("day " + d.String()) + } + }) +} + +func calAtoi(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return n +} + +func calOptions(values []string) []mujica.SelectOption[string] { + out := make([]mujica.SelectOption[string], len(values)) + for i, v := range values { + out[i] = mujica.SelectOption[string]{Value: v, Label: v} + } + return out +} diff --git a/internal/calendar.go b/internal/calendar.go new file mode 100644 index 0000000..1b6164d --- /dev/null +++ b/internal/calendar.go @@ -0,0 +1,9 @@ +package internal + +import "github.com/egoist/mygo/ui" + +// Rule draws a pixel-aligned 1 DIP horizontal line at y. +func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) { + yy := ruleY(p, y) + p.Line(x0, yy, x1, yy, 1, c) +} diff --git a/messages_calendar.go b/messages_calendar.go new file mode 100644 index 0000000..af24774 --- /dev/null +++ b/messages_calendar.go @@ -0,0 +1,195 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" +) + +// calendarCount formats a count, using "_one" for one. +func calendarCount(c *ui.Context, key string, n int) string { + if n == 1 { + key += "_one" + } + return fmt.Sprintf(msg(c, key), n) +} + +func init() { + addMessages(map[string][2]string{ + "event_chip.all_day": {"All day", "全天"}, + "event_chip.untitled": {"(No title)", "(无标题)"}, + + "attendee_list.label": {"Attendees", "参与者"}, + "attendee_list.summary": {"%d accepted · %d maybe · %d declined · %d awaiting", "%d 人接受 · %d 人待定 · %d 人拒绝 · %d 人未回复"}, + "attendee_list.empty": {"No attendees yet.", "暂无参与者。"}, + "attendee_list.organizer": {"Organizer", "组织者"}, + "attendee_list.optional": {"Optional", "可选"}, + "attendee_list.remove": {"Remove %s", "移除 %s"}, + "attendee_list.pending": {"Awaiting reply", "未回复"}, + "attendee_list.accepted": {"Accepted", "已接受"}, + "attendee_list.tentative": {"Maybe", "待定"}, + "attendee_list.declined": {"Declined", "已拒绝"}, + + "recurrence_editor.label": {"Recurrence", "重复规则"}, + "recurrence_editor.repeat": {"Repeat", "重复"}, + "recurrence_editor.none": {"Does not repeat", "不重复"}, + "recurrence_editor.daily": {"Daily", "每天"}, + "recurrence_editor.weekly": {"Weekly", "每周"}, + "recurrence_editor.monthly": {"Monthly", "每月"}, + "recurrence_editor.yearly": {"Yearly", "每年"}, + "recurrence_editor.custom": {"Custom", "自定义"}, + "recurrence_editor.every": {"Every", "每"}, + "recurrence_editor.interval": {"Interval", "间隔"}, + "recurrence_editor.unit": {"Unit", "单位"}, + "recurrence_editor.days": {"days", "天"}, + "recurrence_editor.weeks": {"weeks", "周"}, + "recurrence_editor.months": {"months", "个月"}, + "recurrence_editor.years": {"years", "年"}, + "recurrence_editor.on_days": {"On days", "重复于"}, + "recurrence_editor.ends": {"Ends", "结束"}, + "recurrence_editor.forever": {"Never", "永不"}, + "recurrence_editor.after": {"After", "次数"}, + "recurrence_editor.until": {"On date", "截止日期"}, + "recurrence_editor.count": {"Occurrences", "重复次数"}, + "recurrence_editor.until_date": {"End date", "结束日期"}, + "recurrence_editor.no_days": {"Choose at least one day.", "请至少选择一天。"}, + "recurrence_editor.bad_count": {"Repeat at least once.", "重复次数至少为 1。"}, + "recurrence_editor.bad_until": {"The end date must not precede the start.", "结束日期不能早于开始日期。"}, + "recurrence_editor.never": {"Does not repeat", "不重复"}, + "recurrence_editor.every_n": {"Every %d %s", "每 %d %s"}, + "recurrence_editor.on": {" on %s", ",于%s"}, + "recurrence_editor.list_sep": {", ", "、"}, + "recurrence_editor.times": {", %d times", ",共 %d 次"}, + "recurrence_editor.times_one": {", %d time", ",共 %d 次"}, + "recurrence_editor.until_text": {", until %v", ",至 %v"}, + + "reminder_picker.label": {"Reminders", "提醒"}, + "reminder_picker.at_start": {"At start", "开始时"}, + "reminder_picker.minutes": {"%d minutes before", "提前 %d 分钟"}, + "reminder_picker.minutes_one": {"%d minute before", "提前 %d 分钟"}, + "reminder_picker.hours": {"%d hours before", "提前 %d 小时"}, + "reminder_picker.hours_one": {"%d hour before", "提前 %d 小时"}, + "reminder_picker.days": {"%d days before", "提前 %d 天"}, + "reminder_picker.days_one": {"%d day before", "提前 %d 天"}, + "reminder_picker.weeks": {"%d weeks before", "提前 %d 周"}, + "reminder_picker.weeks_one": {"%d week before", "提前 %d 周"}, + + "calendar_month_view.more": {"+%d", "+%d"}, + "calendar_month_view.more_label": {"%d more events", "另有 %d 项"}, + "calendar_month_view.more_label_one": {"%d more event", "另有 %d 项"}, + + "current_time_indicator.label": {"Now, %s", "当前时间 %s"}, + + "calendar_week_view.all_day": {"All day", "全天"}, + "calendar_week_view.prev": {"Previous week", "上一周"}, + "calendar_week_view.next": {"Next week", "下一周"}, + "calendar_week_view.title": {"%[1]s %[2]d – %[3]s %[4]d, %[5]d", "%[5]d年%[1]s%[2]d日 – %[3]s%[4]d日"}, + "calendar_week_view.resize": {"Resize %s", "调整 %s 的时长"}, + "calendar_week_view.too_short": {"An event must end after it starts.", "结束时间须晚于开始时间。"}, + "calendar_week_view.column": {"Schedule, %s", "%s 日程"}, + "calendar_day_view.prev": {"Previous day", "前一天"}, + "calendar_day_view.next": {"Next day", "后一天"}, + + "agenda_view.label": {"Agenda", "日程"}, + "agenda_view.today": {"Today", "今天"}, + "agenda_view.empty": {"No events in the next %d days.", "未来 %d 天没有日程。"}, + + "year_view.events": {"%d events", "%d 项日程"}, + "year_view.events_one": {"%d event", "%d 项日程"}, + + "event_popover.close": {"Close", "关闭"}, + "event_popover.edit": {"Edit", "编辑"}, + "event_popover.delete": {"Delete", "删除"}, + "event_popover.guests": {"%d guests", "%d 位参与者"}, + "event_popover.guests_one": {"%d guest", "%d 位参与者"}, + + "week_picker.label": {"Week", "周"}, + "week_picker.placeholder": {"Select week", "选择周"}, + + "duration_picker.label": {"Duration", "时长"}, + "duration_picker.hours": {"Hours", "小时数"}, + "duration_picker.minutes": {"Minutes", "分钟数"}, + "duration_picker.seconds": {"Seconds", "秒数"}, + "duration_picker.hours_unit": {"h", "小时"}, + "duration_picker.minutes_unit": {"min", "分"}, + "duration_picker.seconds_unit": {"s", "秒"}, + "duration_picker.range": {"Choose a duration from %s to %s.", "请选择 %s 至 %s 之间的时长。"}, + + "timezone_select.label": {"Time zone", "时区"}, + "timezone_select.placeholder": {"Select time zone", "选择时区"}, + "timezone_select.search": {"Search city or offset", "搜索城市或偏移"}, + "timezone_select.empty": {"No matching time zone.", "没有匹配的时区。"}, + "timezone_select.common": {"Common", "常用"}, + "timezone_select.all": {"All time zones", "全部时区"}, + + "availability_picker.label": {"Availability", "空闲时段"}, + "availability_picker.mine": {"you are free", "你有空"}, + "availability_picker.others": {"%d of %d others free", "其他 %[2]d 人中 %[1]d 人有空"}, + + "event_editor.label": {"Event", "日程"}, + "event_editor.title": {"Title", "标题"}, + "event_editor.all_day": {"All day", "全天"}, + "event_editor.starts": {"Starts", "开始"}, + "event_editor.ends": {"Ends", "结束"}, + "event_editor.where": {"Location", "地点"}, + "event_editor.guests": {"Guests", "参与者"}, + "event_editor.guests_help": {"Type a name and press Enter.", "输入姓名后按回车。"}, + "event_editor.reminders": {"Reminders", "提醒"}, + "event_editor.save": {"Save", "保存"}, + "event_editor.need_title": {"Enter a title.", "请输入标题。"}, + "event_editor.need_start": {"Choose a start.", "请选择开始时间。"}, + "event_editor.end_order": {"The end must be after the start.", "结束须晚于开始。"}, + + "cron_editor.expression": {"Expression", "表达式"}, + "cron_editor.label": {"Cron schedule", "Cron 表达式"}, + "cron_editor.next": {"Next runs", "下次运行"}, + "cron_editor.never": {"No run in the next five years.", "未来五年内不会运行。"}, + "cron_editor.repeated": {"repeated hour, first run", "重复时段,取首次"}, + "cron_editor.minute": {"Minute", "分钟"}, + "cron_editor.hour": {"Hour", "小时"}, + "cron_editor.dom": {"Day of month", "日"}, + "cron_editor.month": {"Month", "月"}, + "cron_editor.dow": {"Day of week", "星期"}, + "cron_editor.err_count": {"Use five fields, not %s.", "需要 5 个字段,而不是 %s 个。"}, + "cron_editor.err_empty": {"an empty list item", "列表项为空"}, + "cron_editor.err_step": {"step %q must be a positive number", "步长 %q 须为正整数"}, + "cron_editor.err_number": {"%q is not a number", "%q 不是数字"}, + "cron_editor.err_range": {"%s is outside %d–%d", "%s 超出 %d–%d"}, + "cron_editor.err_backwards": {"range %s runs backwards", "范围 %s 起止颠倒"}, + "cron_editor.field_error": {"%s: %s.", "%s:%s。"}, + "cron_editor.invalid": {"Invalid", "无效"}, + "cron_editor.any": {"Every value", "任意"}, + "cron_editor.every": {"Every %s", "每 %s"}, + "cron_editor.every_in": {"Every %s in %s", "%[2]s 内每 %[1]s"}, + + "relative_time.now": {"just now", "刚刚"}, + "relative_time.ago": {"%s ago", "%s前"}, + "relative_time.in": {"in %s", "%s后"}, + "relative_time.minutes": {"%d minutes", "%d 分钟"}, + "relative_time.minutes_one": {"%d minute", "%d 分钟"}, + "relative_time.hours": {"%d hours", "%d 小时"}, + "relative_time.hours_one": {"%d hour", "%d 小时"}, + "relative_time.days": {"%d days", "%d 天"}, + "relative_time.days_one": {"%d day", "%d 天"}, + "relative_time.months": {"%d months", "%d 个月"}, + "relative_time.months_one": {"%d month", "%d 个月"}, + "relative_time.years": {"%d years", "%d 年"}, + "relative_time.years_one": {"%d year", "%d 年"}, + + "countdown.label": {"Time left", "剩余时间"}, + "countdown.done": {"%s: time is up.", "%s:时间到。"}, + "countdown.days_clock": {"%dd", "%d天"}, + "countdown.d": {"%d d", "%d 天"}, + "countdown.h": {"%d h", "%d 小时"}, + "countdown.m": {"%d min", "%d 分"}, + "countdown.s": {"%d s", "%d 秒"}, + + "stopwatch.label": {"Stopwatch", "秒表"}, + "stopwatch.start": {"Start", "开始"}, + "stopwatch.pause": {"Pause", "暂停"}, + "stopwatch.resume": {"Resume", "继续"}, + "stopwatch.lap": {"Lap", "计次"}, + "stopwatch.reset": {"Reset", "复位"}, + "stopwatch.lap_n": {"Lap %d", "第 %d 圈"}, + }) +} diff --git a/recurrence_editor.go b/recurrence_editor.go new file mode 100644 index 0000000..a7bd8b7 --- /dev/null +++ b/recurrence_editor.go @@ -0,0 +1,223 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RepeatUnit is how often a rule repeats; zero never does. +type RepeatUnit int + +const ( + RepeatNone RepeatUnit = iota + RepeatDaily + RepeatWeekly + RepeatMonthly + RepeatYearly +) + +// RepeatEnd says when a recurrence stops. +type RepeatEnd int + +const ( + RepeatForever RepeatEnd = iota + RepeatCount + RepeatUntil +) + +// Recurrence repeats every Interval units, on Weekdays when weekly. +type Recurrence struct { + Unit RepeatUnit + Interval int + Weekdays []time.Weekday + End RepeatEnd + Count int + Until Date +} + +// RecurrenceEditorOptions configure a RecurrenceEditor. +type RecurrenceEditorOptions struct { + // Start is the first occurrence; Until must not precede it. + Start Date + WeekStart WeekStart + Disabled bool + Label string +} + +// RecurrenceResult is the editor, its change and its problem. +type RecurrenceResult struct { + Element *ui.Element + changed bool + problem string +} + +// Changed reports whether the user changed the rule this frame. +func (r RecurrenceResult) Changed() bool { return r.changed } + +// Problem explains why the rule cannot be saved. +func (r RecurrenceResult) Problem() string { return r.problem } + +const recurrenceCustom = 5 + +// RecurrenceEditor edits a repeat rule and when it ends. +func RecurrenceEditor(c *ui.Context, rule *Recurrence, opts RecurrenceEditorOptions) RecurrenceResult { + recurrenceCheck(*rule) + d := Density(c) + k := Tokens(c) + before := recurrenceKey(*rule) + r := RecurrenceResult{} + label := opts.Label + if label == "" { + label = msg(c, "recurrence_editor.label") + } + r.Element = ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + custom := ui.Local(r.Element, "recurrence-custom", func() bool { return rule.Interval > 1 }) + r.Element.Children(func() { + mode := int(rule.Unit) + if *custom && rule.Unit != RepeatNone { + mode = recurrenceCustom + } + modes := []SelectOption[int]{} + for i, key := range []string{"none", "daily", "weekly", "monthly", "yearly", "custom"} { + modes = append(modes, SelectOption[int]{Value: i, Label: msg(c, "recurrence_editor."+key)}) + } + if Select(c, &mode, modes, SelectOptions{Label: msg(c, "recurrence_editor.repeat"), Disabled: opts.Disabled}).Changed() { + *custom = mode == recurrenceCustom + switch { + case *custom: + rule.Unit = max(rule.Unit, RepeatDaily) + rule.Interval = max(rule.Interval, 2) + default: + rule.Unit, rule.Interval = RepeatUnit(mode), 1 + if mode == int(RepeatNone) { + *rule = Recurrence{} + } + } + if rule.Unit == RepeatWeekly && len(rule.Weekdays) == 0 && !opts.Start.IsZero() { + rule.Weekdays = []time.Weekday{opts.Start.Weekday()} + } + } + if rule.Unit == RepeatNone { + return + } + if *custom { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "recurrence_editor.every")) + ui.Box(c).Width(d.ControlHeight() * 4).Children(func() { + n := float64(rule.Interval) + if NumberInput(c, &n, NumberInputOptions{Min: 1, Max: 999, Label: msg(c, "recurrence_editor.interval"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) { + rule.Interval = int(n) + } + }) + units := []SelectOption[RepeatUnit]{} + for u := RepeatDaily; u <= RepeatYearly; u++ { + units = append(units, SelectOption[RepeatUnit]{Value: u, Label: msg(c, recurrenceUnitKeys[u])}) + } + Select(c, &rule.Unit, units, SelectOptions{Label: msg(c, "recurrence_editor.unit"), Disabled: opts.Disabled}) + }) + } + if rule.Unit == RepeatWeekly { + first := calendarFirstWeekday(c, opts.WeekStart) + items := []ToggleItem[time.Weekday]{} + for i := range 7 { + wd := time.Weekday((int(first) + i) % 7) + items = append(items, ToggleItem[time.Weekday]{Value: wd, Label: msg(c, fmt.Sprintf("calendar.weekday_%d", wd)), + Name: msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd))}) + } + ToggleGroup(c, &rule.Weekdays, items, ToggleGroupOptions{Multiple: true, AllowEmpty: true, Label: msg(c, "recurrence_editor.on_days"), Disabled: opts.Disabled}) + } + ends := []RadioItem[RepeatEnd]{{Value: RepeatForever, Label: msg(c, "recurrence_editor.forever")}, + {Value: RepeatCount, Label: msg(c, "recurrence_editor.after")}, {Value: RepeatUntil, Label: msg(c, "recurrence_editor.until")}} + ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Wrap().Children(func() { + if RadioGroup(c, &rule.End, ends, RadioGroupOptions{Horizontal: true, Label: msg(c, "recurrence_editor.ends"), Disabled: opts.Disabled}).Changed() { + if rule.End == RepeatCount && rule.Count == 0 { + rule.Count = 10 + } + if rule.End == RepeatUntil && rule.Until.IsZero() && !opts.Start.IsZero() { + rule.Until = opts.Start.AddMonths(1) + } + } + switch rule.End { + case RepeatCount: + ui.Box(c).Width(d.ControlHeight() * 4).Children(func() { + n := float64(rule.Count) + if NumberInput(c, &n, NumberInputOptions{Min: 1, Max: 999, Label: msg(c, "recurrence_editor.count"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) { + rule.Count = int(n) + } + }) + case RepeatUntil: + DatePicker(c, &rule.Until, DatePickerOptions{Label: msg(c, "recurrence_editor.until_date"), Min: opts.Start, Disabled: opts.Disabled}) + } + }) + r.problem = recurrenceProblem(c, *rule, opts.Start) + ui.Text(c, RecurrenceSummary(c, *rule)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus) + datetimeError(c, r.problem) + }) + if after := recurrenceKey(*rule); after != before && !r.Element.IsDisabled() { + slog.Debug("mujica: recurrence_editor change", "rule", after) + r.changed = true + } + return r +} + +var recurrenceUnitKeys = [...]string{"", "recurrence_editor.days", "recurrence_editor.weeks", "recurrence_editor.months", "recurrence_editor.years"} + +func recurrenceKey(r Recurrence) string { return fmt.Sprintf("%v", r) } + +// recurrenceCheck panics on rules no editor can show. +func recurrenceCheck(r Recurrence) { + if r.Unit < RepeatNone || r.Unit > RepeatYearly || r.End < RepeatForever || r.End > RepeatUntil || r.Interval < 0 || r.Count < 0 { + panic(fmt.Sprintf("mujica: Recurrence %+v is out of range", r)) + } + if r.Unit != RepeatNone && r.Interval == 0 { + panic("mujica: Recurrence repeats with Interval 0") + } + datetimeCheckDate("Recurrence Until", r.Until) +} + +// recurrenceProblem explains why a rule cannot be saved. +func recurrenceProblem(c *ui.Context, r Recurrence, start Date) string { + switch { + case r.Unit == RepeatNone: + return "" + case r.Unit == RepeatWeekly && len(r.Weekdays) == 0: + return msg(c, "recurrence_editor.no_days") + case r.End == RepeatCount && r.Count < 1: + return msg(c, "recurrence_editor.bad_count") + case r.End == RepeatUntil && (r.Until.IsZero() || !start.IsZero() && r.Until.Compare(start) < 0): + return msg(c, "recurrence_editor.bad_until") + } + return "" +} + +// RecurrenceSummary describes a rule in the window's language. +func RecurrenceSummary(c *ui.Context, r Recurrence) string { + if r.Unit == RepeatNone { + return msg(c, "recurrence_editor.never") + } + s := fmt.Sprintf(msg(c, "recurrence_editor.every_n"), r.Interval, msg(c, recurrenceUnitKeys[r.Unit])) + if r.Interval == 1 { + s = msg(c, []string{"", "recurrence_editor.daily", "recurrence_editor.weekly", "recurrence_editor.monthly", "recurrence_editor.yearly"}[r.Unit]) + } + if r.Unit == RepeatWeekly && len(r.Weekdays) > 0 { + var days []string + for _, wd := range r.Weekdays { + days = append(days, msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd))) + } + s += fmt.Sprintf(msg(c, "recurrence_editor.on"), strings.Join(days, msg(c, "recurrence_editor.list_sep"))) + } + switch r.End { + case RepeatCount: + s += calendarCount(c, "recurrence_editor.times", r.Count) + case RepeatUntil: + s += fmt.Sprintf(msg(c, "recurrence_editor.until_text"), r.Until) + } + return s +} diff --git a/recurrence_editor_test.go b/recurrence_editor_test.go new file mode 100644 index 0000000..1f93803 --- /dev/null +++ b/recurrence_editor_test.go @@ -0,0 +1,133 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type recurrenceProbe struct { + rule Recurrence + opts RecurrenceEditorOptions + changes int + problem string + summary string + disabled bool +} + +func (p *recurrenceProbe) tester() *ui.Tester { + p.opts.Start = oct(5) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := p.opts + o.Disabled = p.disabled + r := RecurrenceEditor(c, &p.rule, o) + if r.Changed() { + p.changes++ + } + p.problem = r.Problem() + p.summary = RecurrenceSummary(c, p.rule) + }, 720, 520) +} + +func recurrencePick(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click("Repeat"); err != nil { + t.Fatal(err) + } + if err := tt.Click(label); err != nil { + t.Fatal(err) + } +} + +func TestRecurrenceEditorPresets(t *testing.T) { + p := &recurrenceProbe{} + tt := p.tester() + recurrencePick(t, tt, "Weekly") + if p.rule.Unit != RepeatWeekly || p.rule.Interval != 1 || !slices.Equal(p.rule.Weekdays, []time.Weekday{time.Monday}) || p.changes != 1 { + t.Fatalf("weekly preset %+v", p.rule) + } + _ = tt.Click("Wednesday") + if p.summary != "Weekly on Monday, Wednesday" { + t.Fatalf("summary %q", p.summary) + } + for _, c := range []struct { + label string + unit RepeatUnit + }{{"Daily", RepeatDaily}, {"Monthly", RepeatMonthly}, {"Yearly", RepeatYearly}} { + recurrencePick(t, tt, c.label) + if p.rule.Unit != c.unit || p.rule.Interval != 1 { + t.Fatalf("%s gave %+v", c.label, p.rule) + } + } + recurrencePick(t, tt, "Does not repeat") + if p.rule.Unit != RepeatNone || p.summary != "Does not repeat" { + t.Fatalf("none gave %+v", p.rule) + } +} + +func TestRecurrenceEditorCustomAndEnds(t *testing.T) { + p := &recurrenceProbe{rule: Recurrence{Unit: RepeatDaily, Interval: 1}} + tt := p.tester() + recurrencePick(t, tt, "Custom") + if p.rule.Interval != 2 || !tt.HasText("Every") { + t.Fatalf("custom %+v", p.rule) + } + _ = tt.Click("Interval") + tt.Key(0, ui.KeyUp) + _ = tt.Click("After") + if p.rule.Interval != 3 || p.rule.End != RepeatCount || p.rule.Count != 10 || p.summary != "Every 3 days, 10 times" { + t.Fatalf("custom count %+v %q", p.rule, p.summary) + } + _ = tt.Click("On date") + if p.rule.End != RepeatUntil || p.rule.Until != (Date{2026, time.November, 5}) || p.problem != "" { + t.Fatalf("until %+v %q", p.rule, p.problem) + } +} + +func TestRecurrenceEditorProblems(t *testing.T) { + p := &recurrenceProbe{rule: Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}} + tt := p.tester() + _ = tt.Click("Monday") + if len(p.rule.Weekdays) != 0 || p.problem != "Choose at least one day." || !tt.HasText("Choose at least one day.") { + t.Fatalf("no-day problem %+v %q", p.rule, p.problem) + } + p.rule = Recurrence{Unit: RepeatDaily, Interval: 1, End: RepeatUntil, Until: Date{2026, time.September, 1}} + tt.Frame() + if p.problem != "The end date must not precede the start." { + t.Fatalf("until problem %q", p.problem) + } + for _, bad := range []Recurrence{{Unit: 9}, {Unit: RepeatDaily}, {Interval: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", bad) + } + }() + q := &recurrenceProbe{rule: bad} + q.tester() + }() + } +} + +func TestRecurrenceEditorDisabled(t *testing.T) { + p := &recurrenceProbe{rule: Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}, disabled: true} + tt := p.tester() + _ = tt.Click("Repeat") + _ = tt.Click("Daily") + _ = tt.Click("Friday") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if p.rule.Unit != RepeatWeekly || len(p.rule.Weekdays) != 1 || p.changes != 0 { + t.Fatalf("disabled editor changed: %+v", p.rule) + } +} + +func TestRecurrenceEditorSnapshot(t *testing.T) { + r := Recurrence{Unit: RepeatWeekly, Interval: 2, Weekdays: []time.Weekday{time.Monday, time.Thursday}, End: RepeatCount, Count: 8} + snapshot(t, "recurrence_editor", 640, 280, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { RecurrenceEditor(c, &r, RecurrenceEditorOptions{Start: oct(5)}) }) + }) +} diff --git a/relative_time.go b/relative_time.go new file mode 100644 index 0000000..eb91cfa --- /dev/null +++ b/relative_time.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" +) + +// RelativeTimeOptions configure a RelativeTime. +type RelativeTimeOptions struct { + // Location is the tooltip's zone; required. + Location *time.Location + // Now fixes the clock and stops updates. + Now time.Time + Hour12 bool +} + +// RelativeTime shows t as "3 minutes ago", refreshing while shown. +func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element { + loc := eventLocation("RelativeTime", opts.Location) + now := opts.Now + if now.IsZero() { + now = c.Now() + c.After(relativeTimeNext(now.Sub(t))) + } + text := RelativeTimeText(c, now.Sub(t)) + w := t.In(loc) + exact := DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), 0}, timeConfig{hour12: opts.Hour12}) + " " + w.Format("MST") + return ui.Text(c, text).Label(text + ", " + exact).Tooltip(exact).FontFeatures("tnum") +} + +// RelativeTimeText words d ago (positive) or ahead (negative). +func RelativeTimeText(c *ui.Context, d time.Duration) string { + past := d >= 0 + if !past { + d = -d + } + var key string + var n int + switch { + case d < 45*time.Second: + return msg(c, "relative_time.now") + case d < 45*time.Minute: + key, n = "minutes", max(1, int(d/time.Minute)) + case d < 22*time.Hour: + key, n = "hours", max(1, int(d/time.Hour)) + case d < 26*24*time.Hour: + key, n = "days", max(1, int(d/(24*time.Hour))) + case d < 320*24*time.Hour: + key, n = "months", max(1, int(d/(30*24*time.Hour))) + default: + key, n = "years", max(1, int(d/(365*24*time.Hour))) + } + unit := calendarCount(c, "relative_time."+key, n) + if past { + return fmt.Sprintf(msg(c, "relative_time.ago"), unit) + } + return fmt.Sprintf(msg(c, "relative_time.in"), unit) +} + +// relativeTimeNext is how long until the wording may change. +func relativeTimeNext(d time.Duration) time.Duration { + if d < 0 { + d = -d + } + step := time.Hour + switch { + case d < 2*time.Minute: + step = time.Second + case d < 2*time.Hour: + step = time.Minute + } + return step - d%step +} diff --git a/relative_time_test.go b/relative_time_test.go new file mode 100644 index 0000000..8501255 --- /dev/null +++ b/relative_time_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestRelativeTimeWording(t *testing.T) { + var en, zh []string + cases := []time.Duration{10 * time.Second, -90 * time.Second, 3 * time.Minute, 5 * time.Hour, -25 * time.Hour, 40 * 24 * time.Hour, 800 * 24 * time.Hour} + for _, loc := range []Locale{EnUS, ZhCN} { + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: loc}) + var out []string + for _, d := range cases { + out = append(out, RelativeTimeText(c, d)) + } + if loc == EnUS { + en = out + } else { + zh = out + } + }, 10, 10) + } + wantEn := []string{"just now", "in 1 minute", "3 minutes ago", "5 hours ago", "in 1 day", "1 month ago", "2 years ago"} + wantZh := []string{"刚刚", "1 分钟后", "3 分钟前", "5 小时前", "1 天后", "1 个月前", "2 年前"} + for i := range cases { + if en[i] != wantEn[i] || zh[i] != wantZh[i] { + t.Fatalf("%v: %q %q", cases[i], en[i], zh[i]) + } + } +} + +func TestRelativeTimeRefreshFollowsDistance(t *testing.T) { + for _, c := range []struct{ d, next time.Duration }{ + {10*time.Second + 300*time.Millisecond, 700 * time.Millisecond}, + {5*time.Minute + 20*time.Second, 40 * time.Second}, + {3*time.Hour + 15*time.Minute, 45 * time.Minute}, + {-(90 * time.Second), 1 * time.Second}, + } { + if got := relativeTimeNext(c.d); got != c.next { + t.Errorf("%v: next %v, want %v", c.d, got, c.next) + } + } +} + +func TestRelativeTimeShowsExactTimeAndStopsWhenHidden(t *testing.T) { + at := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + shown := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if shown { + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(3 * time.Hour)}) + } + }, 300, 100) + if !tt.HasText("3 hours ago, 2026-10-05 18:00 JST") { + t.Fatalf("texts %v", tt.Texts()) + } + shown = false + tt.Frame() + if tt.HasText("ago") { + t.Fatal("hidden text remains") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without Location") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); RelativeTime(c, at, RelativeTimeOptions{}) }, 10, 10) +} + +func TestRelativeTimeSnapshot(t *testing.T) { + at := time.Date(2026, time.October, 5, 9, 0, 0, 0, calTokyo) + snapshot(t, "relative_time", 260, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(20 * time.Second)}) + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(-2 * time.Hour)}) + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(72 * time.Hour)}) + }) + }) +} diff --git a/reminder_picker.go b/reminder_picker.go new file mode 100644 index 0000000..9d7c761 --- /dev/null +++ b/reminder_picker.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" +) + +// ReminderPickerOptions configure a ReminderPicker. +type ReminderPickerOptions struct { + // Choices are the lead times offered; empty uses defaults. + Choices []time.Duration + // Max limits how many are set; zero means any. + Max int + Disabled bool + Label string +} + +var reminderDefaults = []time.Duration{0, 5 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour, 24 * time.Hour, 7 * 24 * time.Hour} + +// ReminderPicker toggles reminder lead times, kept in choice order. +func ReminderPicker(c *ui.Context, value *[]time.Duration, opts ReminderPickerOptions) ChoiceResult { + choices := opts.Choices + if len(choices) == 0 { + choices = reminderDefaults + } + for i, d := range choices { + if d < 0 || d%time.Minute != 0 || slices.Contains(choices[:i], d) { + panic(fmt.Sprintf("mujica: ReminderPicker choice %v is negative, sub-minute or repeated", d)) + } + } + for _, d := range *value { + if !slices.Contains(choices, d) { + panic(fmt.Sprintf("mujica: ReminderPicker value %v is not a choice", d)) + } + } + if opts.Max < 0 || opts.Max > 0 && len(*value) > opts.Max { + panic(fmt.Sprintf("mujica: ReminderPicker Max %d is negative or below the value", opts.Max)) + } + full := opts.Max > 0 && len(*value) >= opts.Max + items := make([]ToggleItem[time.Duration], len(choices)) + for i, d := range choices { + items[i] = ToggleItem[time.Duration]{Value: d, Label: ReminderText(c, d), Disabled: full && !slices.Contains(*value, d)} + } + label := opts.Label + if label == "" { + label = msg(c, "reminder_picker.label") + } + r := ToggleGroup(c, value, items, ToggleGroupOptions{Multiple: true, AllowEmpty: true, Vertical: true, Label: label, Disabled: opts.Disabled}) + if r.Changed() { + slices.SortFunc(*value, func(a, b time.Duration) int { return slices.Index(choices, a) - slices.Index(choices, b) }) + slog.Debug("mujica: reminder_picker change", "value", *value) + } + return r +} + +// ReminderText words a lead time, as "15 minutes before". +func ReminderText(c *ui.Context, d time.Duration) string { + switch { + case d == 0: + return msg(c, "reminder_picker.at_start") + case d%(7*24*time.Hour) == 0: + return calendarCount(c, "reminder_picker.weeks", int(d/(7*24*time.Hour))) + case d%(24*time.Hour) == 0: + return calendarCount(c, "reminder_picker.days", int(d/(24*time.Hour))) + case d%time.Hour == 0: + return calendarCount(c, "reminder_picker.hours", int(d/time.Hour)) + } + return calendarCount(c, "reminder_picker.minutes", int(d/time.Minute)) +} diff --git a/reminder_picker_test.go b/reminder_picker_test.go new file mode 100644 index 0000000..69f1abf --- /dev/null +++ b/reminder_picker_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func reminderTester(v *[]time.Duration, opts ReminderPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ReminderPicker(c, v, opts).Changed() { + *n++ + } + }, 640, 200) +} + +func TestReminderPickerTogglesInChoiceOrder(t *testing.T) { + v, n := []time.Duration{}, 0 + tt := reminderTester(&v, ReminderPickerOptions{}, &n) + _ = tt.Click("1 day before") + _ = tt.Click("15 minutes before") + _ = tt.Click("At start") + if !slices.Equal(v, []time.Duration{0, 15 * time.Minute, 24 * time.Hour}) || n != 3 { + t.Fatalf("value %v (%d)", v, n) + } + _ = tt.Click("15 minutes before") + if !slices.Equal(v, []time.Duration{0, 24 * time.Hour}) { + t.Fatalf("untoggle left %v", v) + } +} + +func TestReminderPickerMaxKeyboardDisabled(t *testing.T) { + v, n := []time.Duration{time.Hour}, 0 + tt := reminderTester(&v, ReminderPickerOptions{Max: 2}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + _ = tt.Click("1 week before") + _ = tt.Click("5 minutes before") + if !slices.Equal(v, []time.Duration{0, time.Hour}) { + t.Fatalf("Max ignored: %v", v) + } + d := []time.Duration{} + tt = reminderTester(&d, ReminderPickerOptions{Disabled: true}, &n) + _ = tt.Click("At start") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if len(d) != 0 { + t.Fatal("disabled picker changed") + } +} + +func TestReminderTextAndInvalid(t *testing.T) { + var got []string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + got = []string{ReminderText(c, time.Minute), ReminderText(c, 2*time.Hour), ReminderText(c, 14*24*time.Hour)} + }, 10, 10) + if !slices.Equal(got, []string{"提前 1 分钟", "提前 2 小时", "提前 2 周"}) { + t.Fatalf("texts %q", got) + } + for _, opts := range []ReminderPickerOptions{{Choices: []time.Duration{30 * time.Second}}, {Choices: []time.Duration{0, 0}}, {Max: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", opts) + } + }() + v := []time.Duration{} + reminderTester(&v, opts, new(int)) + }() + } +} + +func TestReminderPickerSnapshot(t *testing.T) { + v := []time.Duration{15 * time.Minute, 24 * time.Hour} + snapshot(t, "reminder_picker", 300, 330, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { ReminderPicker(c, &v, ReminderPickerOptions{}) }) + }) +} diff --git a/stopwatch.go b/stopwatch.go new file mode 100644 index 0000000..9fc6e24 --- /dev/null +++ b/stopwatch.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// StopwatchState is the caller's run and laps. +type StopwatchState struct { + running bool + since time.Time + base time.Duration + // Laps are the elapsed times at each lap, oldest first. + Laps []time.Duration +} + +// Elapsed returns the time measured at now. +func (s *StopwatchState) Elapsed(now time.Time) time.Duration { + if s.running { + return s.base + now.Sub(s.since) + } + return s.base +} + +// Running reports whether the stopwatch is counting. +func (s *StopwatchState) Running() bool { return s.running } + +// StopwatchOptions configure a Stopwatch. +type StopwatchOptions struct { + // Now fixes the clock and stops ticking. + Now time.Time + Disabled bool + Label string +} + +// Stopwatch measures time with laps, ticking while running and shown. +func Stopwatch(c *ui.Context, s *StopwatchState, opts StopwatchOptions) ChoiceResult { + k := Tokens(c) + d := Density(c) + now := opts.Now + live := now.IsZero() + if live { + now = c.Now() + } + label := opts.Label + if label == "" { + label = msg(c, "stopwatch.label") + } + changed := false + col := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + act := func(what string, fn func()) { + if col.IsDisabled() { + return + } + fn() + changed = true + slog.Debug("mujica: stopwatch "+what, "elapsed", s.Elapsed(now)) + } + col.Children(func() { + el := s.Elapsed(now) + ui.Text(c, StopwatchText(el)).Font(theme.MonoFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum"). + Label(label + ", " + StopwatchText(el)) + ui.Row(c).Gap(d.Unit() * 2).Children(func() { + start, variant := "stopwatch.start", Primary + if s.running { + start, variant = "stopwatch.pause", Secondary + } else if el > 0 { + start = "stopwatch.resume" + } + if Button(c, msg(c, start), ButtonOptions{Variant: variant}).Clicked() { + act("toggle", func() { + if s.running { + s.base, s.running = s.Elapsed(now), false + } else { + s.since, s.running = now, true + } + }) + } + if Button(c, msg(c, "stopwatch.lap"), ButtonOptions{Variant: Secondary, Disabled: !s.running}).Clicked() { + act("lap", func() { s.Laps = append(s.Laps, s.Elapsed(now)) }) + } + if Button(c, msg(c, "stopwatch.reset"), ButtonOptions{Variant: Ghost, Disabled: s.running || el == 0 && len(s.Laps) == 0}).Clicked() { + act("reset", func() { *s = StopwatchState{} }) + } + }) + if len(s.Laps) > 0 { + ui.Column(c).Role(ui.RoleList).Children(func() { + for i := len(s.Laps) - 1; i >= 0; i-- { + split := s.Laps[i] + if i > 0 { + split -= s.Laps[i-1] + } + row := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit() * 4).Role(ui.RoleListItem) + row.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Rule(p, r.X, r.X+r.W, r.Y+r.H-1, k.Border) }) + row.Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "stopwatch.lap_n"), i+1)).Width(d.ControlHeight() * 2).TextColor(k.TextMuted) + ui.Text(c, StopwatchText(split)).Font(theme.MonoFont).FontFeatures("tnum").TextColor(k.Text).Grow(1) + ui.Text(c, StopwatchText(s.Laps[i])).Font(theme.MonoFont).FontFeatures("tnum").TextColor(k.TextMuted) + }) + } + }) + } + }) + if s.running && live { + c.After(100*time.Millisecond - s.Elapsed(now)%(100*time.Millisecond)) + } + return ChoiceResult{Element: col, changed: changed} +} + +// StopwatchText formats elapsed time as 01:02.3, with hours when needed. +func StopwatchText(d time.Duration) string { + t := int(d / (100 * time.Millisecond)) + h, m, s, tenth := t/36000, t%36000/600, t%600/10, t%10 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d.%d", h, m, s, tenth) + } + return fmt.Sprintf("%02d:%02d.%d", m, s, tenth) +} diff --git a/stopwatch_test.go b/stopwatch_test.go new file mode 100644 index 0000000..8709593 --- /dev/null +++ b/stopwatch_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestStopwatchStartLapPauseReset(t *testing.T) { + var s StopwatchState + now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + n, disabled := 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Stopwatch(c, &s, StopwatchOptions{Now: now, Disabled: disabled}).Changed() { + n++ + } + }, 500, 500) + _ = tt.Click("Start") + now = now.Add(12*time.Second + 340*time.Millisecond) + tt.Frame() + if !tt.HasText("00:12.3") || !s.Running() { + t.Fatalf("running %v", tt.Texts()) + } + _ = tt.Click("Lap") + now = now.Add(5 * time.Second) + _ = tt.Click("Lap") + if len(s.Laps) != 2 || !tt.HasText("Lap 2") || !tt.HasText("00:05.0") { + t.Fatalf("laps %v %v", s.Laps, tt.Texts()) + } + _ = tt.Click("Pause") + now = now.Add(time.Minute) + tt.Frame() + if s.Running() || s.Elapsed(now) != 17340*time.Millisecond || !tt.HasText("Resume") { + t.Fatalf("paused at %v", s.Elapsed(now)) + } + tt.Key(0, ui.KeyEnter) + now = now.Add(time.Hour) + tt.Frame() + if !tt.HasText("1:00:17.3") { + t.Fatalf("resumed by keyboard: %v", tt.Texts()) + } + _ = tt.Click("Pause") + _ = tt.Click("Reset") + if s.Elapsed(now) != 0 || len(s.Laps) != 0 || n != 7 { + t.Fatalf("reset %v laps %v changes %d", s.Elapsed(now), s.Laps, n) + } + disabled = true + tt.Frame() + _ = tt.Click("Start") + if s.Running() { + t.Fatal("disabled stopwatch started") + } +} + +func TestStopwatchSnapshot(t *testing.T) { + now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + s := StopwatchState{base: 83*time.Second + 400*time.Millisecond, Laps: []time.Duration{31 * time.Second, 62*time.Second + 500*time.Millisecond}} + snapshot(t, "stopwatch", 420, 260, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { Stopwatch(c, &s, StopwatchOptions{Now: now}) }) + }) +} diff --git a/testdata/snapshots/darwin/agenda_view-dark.png b/testdata/snapshots/darwin/agenda_view-dark.png new file mode 100644 index 0000000..4cba669 Binary files /dev/null and b/testdata/snapshots/darwin/agenda_view-dark.png differ diff --git a/testdata/snapshots/darwin/agenda_view-light.png b/testdata/snapshots/darwin/agenda_view-light.png new file mode 100644 index 0000000..6d5ab80 Binary files /dev/null and b/testdata/snapshots/darwin/agenda_view-light.png differ diff --git a/testdata/snapshots/darwin/attendee_list-dark.png b/testdata/snapshots/darwin/attendee_list-dark.png new file mode 100644 index 0000000..6f044e5 Binary files /dev/null and b/testdata/snapshots/darwin/attendee_list-dark.png differ diff --git a/testdata/snapshots/darwin/attendee_list-light.png b/testdata/snapshots/darwin/attendee_list-light.png new file mode 100644 index 0000000..ff96bc2 Binary files /dev/null and b/testdata/snapshots/darwin/attendee_list-light.png differ diff --git a/testdata/snapshots/darwin/availability_picker-dark.png b/testdata/snapshots/darwin/availability_picker-dark.png new file mode 100644 index 0000000..2e92e34 Binary files /dev/null and b/testdata/snapshots/darwin/availability_picker-dark.png differ diff --git a/testdata/snapshots/darwin/availability_picker-light.png b/testdata/snapshots/darwin/availability_picker-light.png new file mode 100644 index 0000000..1708736 Binary files /dev/null and b/testdata/snapshots/darwin/availability_picker-light.png differ diff --git a/testdata/snapshots/darwin/calendar_day_view-dark.png b/testdata/snapshots/darwin/calendar_day_view-dark.png new file mode 100644 index 0000000..e3c891e Binary files /dev/null and b/testdata/snapshots/darwin/calendar_day_view-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_day_view-light.png b/testdata/snapshots/darwin/calendar_day_view-light.png new file mode 100644 index 0000000..6e4983d Binary files /dev/null and b/testdata/snapshots/darwin/calendar_day_view-light.png differ diff --git a/testdata/snapshots/darwin/calendar_month_view-dark.png b/testdata/snapshots/darwin/calendar_month_view-dark.png new file mode 100644 index 0000000..5819be7 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_month_view-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_month_view-light.png b/testdata/snapshots/darwin/calendar_month_view-light.png new file mode 100644 index 0000000..e9fb1e8 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_month_view-light.png differ diff --git a/testdata/snapshots/darwin/calendar_week_view-dark.png b/testdata/snapshots/darwin/calendar_week_view-dark.png new file mode 100644 index 0000000..6be02c2 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_week_view-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_week_view-light.png b/testdata/snapshots/darwin/calendar_week_view-light.png new file mode 100644 index 0000000..65583a1 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_week_view-light.png differ diff --git a/testdata/snapshots/darwin/countdown-dark.png b/testdata/snapshots/darwin/countdown-dark.png new file mode 100644 index 0000000..e8b146a Binary files /dev/null and b/testdata/snapshots/darwin/countdown-dark.png differ diff --git a/testdata/snapshots/darwin/countdown-light.png b/testdata/snapshots/darwin/countdown-light.png new file mode 100644 index 0000000..daf8755 Binary files /dev/null and b/testdata/snapshots/darwin/countdown-light.png differ diff --git a/testdata/snapshots/darwin/cron_editor-dark.png b/testdata/snapshots/darwin/cron_editor-dark.png new file mode 100644 index 0000000..433762c Binary files /dev/null and b/testdata/snapshots/darwin/cron_editor-dark.png differ diff --git a/testdata/snapshots/darwin/cron_editor-light.png b/testdata/snapshots/darwin/cron_editor-light.png new file mode 100644 index 0000000..e8b898b Binary files /dev/null and b/testdata/snapshots/darwin/cron_editor-light.png differ diff --git a/testdata/snapshots/darwin/current_time_indicator-dark.png b/testdata/snapshots/darwin/current_time_indicator-dark.png new file mode 100644 index 0000000..892631a Binary files /dev/null and b/testdata/snapshots/darwin/current_time_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/current_time_indicator-light.png b/testdata/snapshots/darwin/current_time_indicator-light.png new file mode 100644 index 0000000..56daacd Binary files /dev/null and b/testdata/snapshots/darwin/current_time_indicator-light.png differ diff --git a/testdata/snapshots/darwin/duration_picker-dark.png b/testdata/snapshots/darwin/duration_picker-dark.png new file mode 100644 index 0000000..18263ae Binary files /dev/null and b/testdata/snapshots/darwin/duration_picker-dark.png differ diff --git a/testdata/snapshots/darwin/duration_picker-light.png b/testdata/snapshots/darwin/duration_picker-light.png new file mode 100644 index 0000000..84a22db Binary files /dev/null and b/testdata/snapshots/darwin/duration_picker-light.png differ diff --git a/testdata/snapshots/darwin/event_chip-dark.png b/testdata/snapshots/darwin/event_chip-dark.png new file mode 100644 index 0000000..d19dfbc Binary files /dev/null and b/testdata/snapshots/darwin/event_chip-dark.png differ diff --git a/testdata/snapshots/darwin/event_chip-light.png b/testdata/snapshots/darwin/event_chip-light.png new file mode 100644 index 0000000..50ac79d Binary files /dev/null and b/testdata/snapshots/darwin/event_chip-light.png differ diff --git a/testdata/snapshots/darwin/event_editor-dark.png b/testdata/snapshots/darwin/event_editor-dark.png new file mode 100644 index 0000000..5678325 Binary files /dev/null and b/testdata/snapshots/darwin/event_editor-dark.png differ diff --git a/testdata/snapshots/darwin/event_editor-light.png b/testdata/snapshots/darwin/event_editor-light.png new file mode 100644 index 0000000..ac2de16 Binary files /dev/null and b/testdata/snapshots/darwin/event_editor-light.png differ diff --git a/testdata/snapshots/darwin/event_popover-dark.png b/testdata/snapshots/darwin/event_popover-dark.png new file mode 100644 index 0000000..625cd6c Binary files /dev/null and b/testdata/snapshots/darwin/event_popover-dark.png differ diff --git a/testdata/snapshots/darwin/event_popover-light.png b/testdata/snapshots/darwin/event_popover-light.png new file mode 100644 index 0000000..67ff2e4 Binary files /dev/null and b/testdata/snapshots/darwin/event_popover-light.png differ diff --git a/testdata/snapshots/darwin/recurrence_editor-dark.png b/testdata/snapshots/darwin/recurrence_editor-dark.png new file mode 100644 index 0000000..fc99a37 Binary files /dev/null and b/testdata/snapshots/darwin/recurrence_editor-dark.png differ diff --git a/testdata/snapshots/darwin/recurrence_editor-light.png b/testdata/snapshots/darwin/recurrence_editor-light.png new file mode 100644 index 0000000..200edd4 Binary files /dev/null and b/testdata/snapshots/darwin/recurrence_editor-light.png differ diff --git a/testdata/snapshots/darwin/relative_time-dark.png b/testdata/snapshots/darwin/relative_time-dark.png new file mode 100644 index 0000000..a915c4b Binary files /dev/null and b/testdata/snapshots/darwin/relative_time-dark.png differ diff --git a/testdata/snapshots/darwin/relative_time-light.png b/testdata/snapshots/darwin/relative_time-light.png new file mode 100644 index 0000000..ed172f4 Binary files /dev/null and b/testdata/snapshots/darwin/relative_time-light.png differ diff --git a/testdata/snapshots/darwin/reminder_picker-dark.png b/testdata/snapshots/darwin/reminder_picker-dark.png new file mode 100644 index 0000000..9941062 Binary files /dev/null and b/testdata/snapshots/darwin/reminder_picker-dark.png differ diff --git a/testdata/snapshots/darwin/reminder_picker-light.png b/testdata/snapshots/darwin/reminder_picker-light.png new file mode 100644 index 0000000..b69a82b Binary files /dev/null and b/testdata/snapshots/darwin/reminder_picker-light.png differ diff --git a/testdata/snapshots/darwin/stopwatch-dark.png b/testdata/snapshots/darwin/stopwatch-dark.png new file mode 100644 index 0000000..0808f83 Binary files /dev/null and b/testdata/snapshots/darwin/stopwatch-dark.png differ diff --git a/testdata/snapshots/darwin/stopwatch-light.png b/testdata/snapshots/darwin/stopwatch-light.png new file mode 100644 index 0000000..b513e4c Binary files /dev/null and b/testdata/snapshots/darwin/stopwatch-light.png differ diff --git a/testdata/snapshots/darwin/timezone_select-dark.png b/testdata/snapshots/darwin/timezone_select-dark.png new file mode 100644 index 0000000..f226c09 Binary files /dev/null and b/testdata/snapshots/darwin/timezone_select-dark.png differ diff --git a/testdata/snapshots/darwin/timezone_select-light.png b/testdata/snapshots/darwin/timezone_select-light.png new file mode 100644 index 0000000..30762f9 Binary files /dev/null and b/testdata/snapshots/darwin/timezone_select-light.png differ diff --git a/testdata/snapshots/darwin/week_picker-dark.png b/testdata/snapshots/darwin/week_picker-dark.png new file mode 100644 index 0000000..1221b30 Binary files /dev/null and b/testdata/snapshots/darwin/week_picker-dark.png differ diff --git a/testdata/snapshots/darwin/week_picker-light.png b/testdata/snapshots/darwin/week_picker-light.png new file mode 100644 index 0000000..74305e3 Binary files /dev/null and b/testdata/snapshots/darwin/week_picker-light.png differ diff --git a/testdata/snapshots/darwin/year_view-dark.png b/testdata/snapshots/darwin/year_view-dark.png new file mode 100644 index 0000000..6a7827e Binary files /dev/null and b/testdata/snapshots/darwin/year_view-dark.png differ diff --git a/testdata/snapshots/darwin/year_view-light.png b/testdata/snapshots/darwin/year_view-light.png new file mode 100644 index 0000000..442d9b1 Binary files /dev/null and b/testdata/snapshots/darwin/year_view-light.png differ diff --git a/timezone_select.go b/timezone_select.go new file mode 100644 index 0000000..c66fd90 --- /dev/null +++ b/timezone_select.go @@ -0,0 +1,208 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + "sync" + "time" + _ "time/tzdata" // zones resolve offline, without the system database + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TimezoneSelectOptions configure a TimezoneSelect. +type TimezoneSelectOptions struct { + // Zones are the IANA names offered; empty uses built-ins. + Zones []string + // Common are pinned first; empty uses a built-in short list. + Common []string + // Now dates the offsets; zero uses the system clock. + Now time.Time + Disabled bool + Label string +} + +var timezoneCommon = []string{"UTC", "Asia/Shanghai", "Asia/Tokyo", "Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles"} + +var timezoneWorld = []string{"Pacific/Honolulu", "America/Anchorage", "America/Los_Angeles", "America/Denver", "America/Chicago", + "America/Mexico_City", "America/New_York", "America/Toronto", "America/Bogota", "America/Lima", "America/Caracas", + "America/Santiago", "America/Halifax", "America/St_Johns", "America/Sao_Paulo", "America/Argentina/Buenos_Aires", + "Atlantic/Azores", "UTC", "Europe/London", "Europe/Dublin", "Europe/Lisbon", "Africa/Lagos", "Europe/Paris", + "Europe/Berlin", "Europe/Madrid", "Europe/Rome", "Europe/Amsterdam", "Europe/Stockholm", "Europe/Warsaw", + "Africa/Cairo", "Africa/Johannesburg", "Europe/Athens", "Europe/Helsinki", "Europe/Kyiv", "Europe/Istanbul", + "Asia/Jerusalem", "Europe/Moscow", "Asia/Riyadh", "Africa/Nairobi", "Asia/Tehran", "Asia/Dubai", "Asia/Kabul", + "Asia/Karachi", "Asia/Tashkent", "Asia/Kolkata", "Asia/Kathmandu", "Asia/Dhaka", "Asia/Yangon", "Asia/Bangkok", + "Asia/Jakarta", "Asia/Ho_Chi_Minh", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Taipei", "Asia/Singapore", + "Asia/Manila", "Australia/Perth", "Asia/Seoul", "Asia/Tokyo", "Australia/Darwin", "Australia/Adelaide", + "Australia/Brisbane", "Australia/Sydney", "Pacific/Guam", "Pacific/Noumea", "Pacific/Auckland", "Pacific/Fiji", + "Pacific/Chatham", "Pacific/Tongatapu", "Pacific/Kiritimati"} + +var timezoneCache sync.Map + +// timezoneLoad resolves an IANA name once or panics. +func timezoneLoad(name string) *time.Location { + if v, ok := timezoneCache.Load(name); ok { + return v.(*time.Location) + } + loc, err := time.LoadLocation(name) + if err != nil { + panic(fmt.Sprintf("mujica: TimezoneSelect zone %q: %v", name, err)) + } + timezoneCache.Store(name, loc) + return loc +} + +// TimezoneOffset formats a zone's offset at t, as "UTC+09:00". +func TimezoneOffset(loc *time.Location, t time.Time) string { + _, off := t.In(loc).Zone() + sign := "+" + if off < 0 { + sign, off = "−", -off + } + return fmt.Sprintf("UTC%s%02d:%02d", sign, off/3600, off%3600/60) +} + +// TimezoneSelect searches zones by name or offset into *zone. +func TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) ChoiceResult { + zones, common := opts.Zones, opts.Common + if len(zones) == 0 { + zones = timezoneWorld + } + if len(common) == 0 { + common = timezoneCommon + } + for _, z := range append(append([]string{}, zones...), common...) { + timezoneLoad(z) + } + if *zone != "" { + timezoneLoad(*zone) + } + now := opts.Now + if now.IsZero() { + now = c.Now() + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "timezone_select.label") + } + old := *zone + col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled) + open := ui.Local(col, "timezone-open", func() bool { return false }) + query := ui.Local(col, "timezone-query", func() string { return "" }) + hi := ui.Local(col, "timezone-highlight", func() int { return 0 }) + shown := ui.Local(col, "timezone-shown", func() int { return -1 }) + text := "" + if *zone != "" { + text = TimezoneOffset(timezoneLoad(*zone), now) + " · " + timezoneName(*zone) + } + col.Children(func() { + field := datetimeField(c, ui.Row(c), false, false) + field.Children(func() { + t := datetimeTrigger(c, text, msg(c, "timezone_select.placeholder"), "clock").Label(label).Description(text) + if !t.IsDisabled() && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) { + *open, *query, *hi = !*open, "", 0 + } + }) + datetimePopup(c, field, open, false, func() { + list := timezoneFilter(zones, common, *query, now) + in := TextInput(c, query, TextInputOptions{Placeholder: msg(c, "timezone_select.search"), Label: msg(c, "timezone_select.search")}) + in.Input.AutoFocus() + if in.Changed() { + *hi = 0 + } + if !in.Input.IsDisabled() { + switch { + case in.Input.Shortcut(0, ui.KeyDown): + *hi = min(*hi+1, len(list)-1) + case in.Input.Shortcut(0, ui.KeyUp): + *hi = max(*hi-1, 0) + case in.Submitted() && len(list) > 0: + *zone, *open = list[*hi].name, false + } + } + defer func() { *shown = *hi }() + _, wh := c.Size() + ui.Scroll(c).MaxHeight(min(d.ControlHeight()*8, wh/2)).Children(func() { + if len(list) == 0 { + ui.Text(c, msg(c, "timezone_select.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 2) + } + for i, z := range list { + if i == 0 || z.common != list[i-1].common { + key := "timezone_select.all" + if z.common { + key = "timezone_select.common" + } + ui.Text(c, msg(c, key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit(), d.Unit()*2) + } + if timezoneRow(c, z, i == *hi, i == *hi && *hi != *shown, z.name == *zone) { + *zone, *open = z.name, false + } + } + }) + }) + }) + changed := *zone != old + if changed { + slog.Debug("mujica: timezone_select change", "from", old, "to", *zone) + } + return ChoiceResult{Element: col, changed: changed} +} + +type timezoneEntry struct { + name, offset string + common bool +} + +// timezoneFilter matches names and offsets, common zones first. +func timezoneFilter(zones, common []string, query string, now time.Time) []timezoneEntry { + q := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(query, " ", "_"))) + var out []timezoneEntry + add := func(names []string, isCommon bool) { + for _, z := range names { + off := TimezoneOffset(timezoneLoad(z), now) + if q == "" || strings.Contains(strings.ToLower(z), q) || strings.Contains(strings.ToLower(off), q) { + out = append(out, timezoneEntry{z, off, isCommon}) + } + } + } + add(common, true) + add(zones, false) + return out +} + +// timezoneName is a zone's city part, readable. +func timezoneName(z string) string { + return strings.ReplaceAll(z, "_", " ") +} + +// timezoneRow draws one zone; it reports a click. +func timezoneRow(c *ui.Context, z timezoneEntry, highlighted, reveal, chosen bool) bool { + k := Tokens(c) + d := Density(c) + b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(d.Unit() * 3).PaddingX(d.Unit() * 2).Radius(theme.ControlRadius). + Label(z.offset + " " + timezoneName(z.name)) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case chosen || highlighted: + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + if reveal { + b.ScrollIntoView() + } + b.Children(func() { + ui.Text(c, z.offset).Width(d.ControlHeight() * 2.4).Shrink(0).FontFeatures("tnum").TextColor(k.TextMuted) + ui.Text(c, timezoneName(z.name)).TextColor(k.Text).Grow(1).Shrink(1).SingleLine().Ellipsis("…") + if chosen { + ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "check", k.AccentText) }) + } + }) + return b.Clicked() && !b.IsDisabled() +} diff --git a/timezone_select_test.go b/timezone_select_test.go new file mode 100644 index 0000000..084a170 --- /dev/null +++ b/timezone_select_test.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var tzNow = time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC) + +func timezoneTester(zone *string, opts *TimezoneSelectOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := *opts + o.Now = tzNow + ui.Column(c).Padding(16).Children(func() { + if TimezoneSelect(c, zone, o).Changed() { + *n++ + } + }) + }, 520, 640) +} + +func TestTimezoneSelectSearchByNameAndOffset(t *testing.T) { + zone, n := "", 0 + tt := timezoneTester(&zone, &TimezoneSelectOptions{}, &n) + _ = tt.Click("Time zone") + if !tt.HasText("Common") || !tt.HasText("UTC+09:00 Asia/Tokyo") || !tt.HasText("UTC−04:00 America/New York") { + t.Fatalf("summer offsets and sections: %v", tt.Texts()[:12]) + } + tt.Type("kolk") + if !tt.HasText("UTC+05:30 Asia/Kolkata") || tt.HasText("UTC+09:00 Asia/Tokyo") { + t.Fatalf("name search: %v", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if zone != "Asia/Kolkata" || n != 1 || !tt.HasText("UTC+05:30 · Asia/Kolkata") { + t.Fatalf("Enter picked %q", zone) + } + _ = tt.Click("Time zone") + tt.Type("+12:45") + tt.Key(0, ui.KeyEnter) + if zone != "Pacific/Chatham" { + t.Fatalf("offset search picked %q", zone) + } +} + +func TestTimezoneSelectKeysCommonClickEmpty(t *testing.T) { + zone, n := "UTC", 0 + tt := timezoneTester(&zone, &TimezoneSelectOptions{Common: []string{"Europe/Paris"}, Zones: []string{"Asia/Tokyo", "Europe/Paris"}}, &n) + _ = tt.Click("Time zone") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if zone != "Asia/Tokyo" || n != 1 { + t.Fatalf("Down then Enter picked %q", zone) + } + _ = tt.Click("Time zone") + _ = tt.Click("UTC+02:00 Europe/Paris") + if zone != "Europe/Paris" { + t.Fatalf("click picked %q", zone) + } + _ = tt.Click("Time zone") + tt.Type("atlantis") + if !tt.HasText("No matching time zone.") { + t.Fatal("no empty state") + } +} + +func TestTimezoneSelectDisabledAndInvalid(t *testing.T) { + zone, n := "UTC", 0 + tt := timezoneTester(&zone, &TimezoneSelectOptions{Disabled: true}, &n) + _ = tt.Click("Time zone") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if tt.HasText("Common") { + t.Fatal("disabled select opened") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic for an unknown zone") + } + }() + bad := "Mars/Olympus" + timezoneTester(&bad, &TimezoneSelectOptions{}, &n) +} + +func TestTimezoneOffsetHalfHours(t *testing.T) { + if got := TimezoneOffset(timezoneLoad("America/St_Johns"), tzNow); got != "UTC−02:30" { + t.Fatalf("offset %q", got) + } +} + +func TestTimezoneSelectSnapshot(t *testing.T) { + zone := "Asia/Shanghai" + snapshot(t, "timezone_select", 420, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { TimezoneSelect(c, &zone, TimezoneSelectOptions{Now: tzNow}) }) + }) +} diff --git a/week_picker.go b/week_picker.go new file mode 100644 index 0000000..d01665a --- /dev/null +++ b/week_picker.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// WeekPickerOptions configure a WeekPicker. +type WeekPickerOptions struct { + WeekStart WeekStart + // Min and Max bound the chosen week's days. + Min, Max Date + // Today is marked; zero uses the system clock. + Today Date + Clearable bool + Disabled bool + Label string +} + +// WeekPicker chooses a week; *value is its first day. +func WeekPicker(c *ui.Context, value *Date, opts WeekPickerOptions) ChoiceResult { + datetimeCheckDate("WeekPicker value", *value) + datetimeCheckBounds("WeekPicker", opts.Min, opts.Max) + first := calendarFirstWeekday(c, opts.WeekStart) + weekOf := func(d Date) Date { return d.AddDays(-((int(d.Weekday()) - int(first) + 7) % 7)) } + if !value.IsZero() && weekOf(*value) != *value { + panic(fmt.Sprintf("mujica: WeekPicker value %v does not start a week", *value)) + } + d := Density(c) + today := datetimeToday(c, opts.Today) + label := opts.Label + if label == "" { + label = msg(c, "week_picker.label") + } + enabled := func(x Date) bool { + w := weekOf(x) + return w.AddDays(6).within(opts.Min, Date{}) && w.within(Date{}, opts.Max) + } + col := ui.Column(c).Width(d.ControlHeight() * 8).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled) + open := ui.Local(col, "week-picker-open", func() bool { return false }) + cursor := ui.Local(col, "week-picker-cursor", func() Date { return Date{} }) + hover := ui.Local(col, "week-picker-hover", func() Date { return Date{} }) + old := *value + text := "" + if !value.IsZero() { + text = weekViewTitle(c, *value, value.AddDays(6)) + } + col.Children(func() { + field := datetimeField(c, ui.Row(c), false, false) + field.Children(func() { + t := datetimeTrigger(c, text, msg(c, "week_picker.placeholder"), "calendar").Label(label).Description(text) + if !t.IsDisabled() && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) { + *open, *hover = !*open, Date{} + *cursor = *value + if cursor.IsZero() { + *cursor = today + } + } + if opts.Clearable && !value.IsZero() { + if b := iconAction(c, icons.Must("x"), msg(c, "calendar.clear")); b.Clicked() && !b.IsDisabled() { + *value = Date{} + } + } + }) + datetimePopup(c, field, open, false, func() { + band := *hover + if band.IsZero() { + band = *cursor + } + picked, hovered := calendarMonth(c, cursor, calendarConfig{ + chosen: func(x Date) bool { return !value.IsZero() && weekOf(x) == *value }, + inRange: func(x Date) bool { return weekOf(x) == weekOf(band) }, + enabled: enabled, + today: today, + weekStart: opts.WeekStart, + autoFocus: true, + }) + if hovered != *hover { + *hover = hovered + c.Invalidate() + } + if !picked.IsZero() { + *value, *open = weekOf(picked), false + } + }) + }) + changed := *value != old + if changed { + slog.Debug("mujica: week_picker change", "from", old, "to", *value) + } + return ChoiceResult{Element: col, changed: changed} +} diff --git a/week_picker_test.go b/week_picker_test.go new file mode 100644 index 0000000..d8c3d8f --- /dev/null +++ b/week_picker_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func weekPickerTester(v *Date, opts *WeekPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := *opts + o.Today, o.Label = calendarToday, "Sprint" + ui.Column(c).Padding(16).Children(func() { + if WeekPicker(c, v, o).Changed() { + *n++ + } + }) + }, 480, 480) +} + +func TestWeekPickerPicksWeekStart(t *testing.T) { + v, n := Date{}, 0 + opts := &WeekPickerOptions{} + tt := weekPickerTester(&v, opts, &n) + _ = tt.Click("Sprint") + _ = tt.Click(datetimeTestLabel(oct(15))) + if v != oct(11) || n != 1 || !tt.HasText("October 11 – October 17, 2026") { + t.Fatalf("Sunday-start week of Oct 15: %v %v", v, tt.Texts()) + } + v, opts.WeekStart = oct(12), WeekStartMonday + tt.Frame() + _ = tt.Click("Sprint") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if v != oct(19) || n != 2 { + t.Fatalf("Monday-start keyboard pick %v", v) + } +} + +func TestWeekPickerBoundsClearDisabled(t *testing.T) { + v, n := oct(11), 0 + opts := &WeekPickerOptions{Min: oct(4), Max: oct(24), Clearable: true} + tt := weekPickerTester(&v, opts, &n) + _ = tt.Click("Sprint") + _ = tt.Click(datetimeTestLabel(oct(26))) + _ = tt.Click(datetimeTestLabel(oct(2))) + if v != oct(11) || n != 0 { + t.Fatalf("out-of-range week chosen: %v", v) + } + tt.Key(0, ui.KeyEscape) + _ = tt.Click("Clear") + if !v.IsZero() || n != 1 { + t.Fatalf("clear left %v", v) + } + opts.Disabled = true + tt.Frame() + _ = tt.Click("Sprint") + if tt.HasText("October 2026") { + t.Fatal("disabled picker opened") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic for a mid-week value") + } + }() + mid := oct(14) + weekPickerTester(&mid, &WeekPickerOptions{}, &n) +} + +func TestWeekPickerSnapshot(t *testing.T) { + v := oct(11) + snapshot(t, "week_picker", 400, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { WeekPicker(c, &v, WeekPickerOptions{Today: calendarToday, Clearable: true}) }) + }) +} diff --git a/year_view.go b/year_view.go new file mode 100644 index 0000000..812c48d --- /dev/null +++ b/year_view.go @@ -0,0 +1,168 @@ +package mujica + +import ( + "fmt" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// YearViewOptions configure a YearView. +type YearViewOptions 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 + // Today is marked; zero uses the system clock. + Today Date + Disabled bool + Label string +} + +// YearView shows twelve months with busy-day dots. +func YearView(c *ui.Context, year *int, events []CalendarEvent, opts YearViewOptions) CalendarViewResult { + loc := eventLocation("YearView", opts.Location) + if *year < 1 { + panic(fmt.Sprintf("mujica: YearView year %d is not valid", *year)) + } + eventsCheck("YearView", events) + d := Density(c) + today := datetimeToday(c, opts.Today) + first := Date{*year, time.January, 1} + label := opts.Label + if label == "" { + label = strconv.Itoa(*year) + } + r := CalendarViewResult{} + root := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + counts := eventsDerived(root, "year-view-counts", events, opts.Version, eventDayStart(first, loc), eventDayStart(first.AddMonths(12), loc), + func(hits []int) map[Date]int { + m := map[Date]int{} + for _, h := range hits { + ev := events[h] + for day := DateOf(ev.Start.In(loc)); eventDayStart(day, loc).Before(ev.End); day = day.AddDays(1) { + m[day]++ + } + } + return m + }) + cursor := ui.Local(root, "year-view-cursor", func() Date { return Date{} }) + if cursor.Year != *year { + *cursor = Date{*year, today.Month, min(today.Day, 28)} + } + root.Children(func() { + calendarViewHeader(c, strconv.Itoa(*year), msg(c, "calendar.prev_year"), msg(c, "calendar.next_year"), func(dir int) { + *year += dir + }) + grid := ui.Row(c).Wrap().AlignItems(ui.Start).Gap(d.Unit() * 4).FocusGroup(ui.Horizontal | ui.Vertical) + moved := false + if !grid.IsDisabled() { + for key, days := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1, ui.KeyUp: -7, ui.KeyDown: 7} { + if grid.Shortcut(0, key) { + *cursor, moved = cursor.AddDays(days), true + } + } + for key, months := range map[ui.Key]int{ui.KeyPageUp: -1, ui.KeyPageDown: 1} { + if grid.Shortcut(0, key) { + *cursor, moved = cursor.AddMonths(months), true + } + } + if moved && cursor.Year != *year { + *year = cursor.Year + } + } + grid.Children(func() { + for m := time.January; m <= time.December; m++ { + if day, ok := yearMonth(c, *year, m, counts, today, *cursor, moved, opts.WeekStart); ok && !grid.IsDisabled() { + r.day, *cursor = day, day + } + } + }) + }) + return r +} + +// yearMonth draws one small month and reports a day clicked. +func yearMonth(c *ui.Context, year int, m time.Month, counts map[Date]int, today, cursor Date, focus bool, ws WeekStart) (Date, bool) { + k := Tokens(c) + cell := theme.MinHitSize + first := calendarFirstWeekday(c, ws) + start := Date{year, m, 1} + lead := (int(start.Weekday()) - int(first) + 7) % 7 + var picked Date + ui.Column(c).Width(cell * 7).Gap(2).Children(func() { + ui.Text(c, datetimeMonthName(c, m)).Font(theme.SerifFont).TextColor(k.Text).Bold() + ui.Row(c).Children(func() { + for i := range 7 { + wd := (int(first) + i) % 7 + ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + last := start.AddMonths(1).AddDays(-1).Day + for w := 0; w*7 < lead+last; w++ { + ui.Row(c).Children(func() { + for i := range 7 { + n := w*7 + i - lead + 1 + if n < 1 || n > last { + ui.Box(c).Size(cell, cell) + continue + } + day := Date{year, m, n} + busy := counts[day] + text := datetimeDayLabel(c, day) + if busy > 0 { + text += ", " + calendarCount(c, "year_view.events", busy) + } + b := ui.ButtonBase(c).Size(cell, cell).Radius(theme.ControlRadius).Label(text).Column() + internal.Ring(b, k.Focus, theme.ControlRadius) + if day == cursor && focus { + b.Focus() + } + switch { + case day == today: + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + dots := yearDots(busy) + b.Draw(func(p *ui.Painter, r ui.Rect) { + x0 := r.X + r.W/2 - float32(dots*5-2)/2 + for j := range dots { + p.Fill(ui.Rect{X: x0 + float32(j)*5, Y: r.Y + r.H - 6, W: 3, H: 3}, k.AccentText, 1.5) + } + }) + b.Children(func() { + t := ui.Text(c, strconv.Itoa(n)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).FontFeatures("tnum") + if day == today { + t.Bold().TextColor(k.AccentText) + } + }) + if b.Clicked() { + picked = day + } + } + }) + } + }) + return picked, !picked.IsZero() +} + +// yearDots maps an event count to one to three dots. +func yearDots(n int) int { + switch { + case n == 0: + return 0 + case n <= 2: + return 1 + case n <= 5: + return 2 + } + return 3 +} diff --git a/year_view_test.go b/year_view_test.go new file mode 100644 index 0000000..6a42408 --- /dev/null +++ b/year_view_test.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func yearViewTester(year *int, events []CalendarEvent, opts YearViewOptions, days *[]Date) *ui.Tester { + opts.Location, opts.Today = calTokyo, calendarToday + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + if d, ok := YearView(c, year, events, opts).DayChosen(); ok { + *days = append(*days, d) + } + }, 900, 1200) +} + +func TestYearViewDensityMarks(t *testing.T) { + var evs []CalendarEvent + for i := range 4 { + evs = append(evs, calEvent(fmt.Sprint(i), 14, 9+i, 0, 30)) + } + evs = append(evs, calEvent("x", 20, 9, 0, 30)) + year := 2026 + var days []Date + tt := yearViewTester(&year, evs, YearViewOptions{}, &days) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("Wednesday, October 14, 2026, 4 events") || !tt.HasText("Tuesday, October 20, 2026, 1 event") { + t.Fatalf("labels lack counts: %v", tt.Texts()[:5]) + } + img := tt.Image() + dotsAt := func(label string) int { + r, _ := tt.Find(label) + n, prev := 0, false + for x := int(r.X); x < int(r.X+r.W); x++ { + on := datetimeNear(img.RGBAAt(x, int(r.Y+r.H-5)), theme.Light().AccentText) + if on && !prev { + n++ + } + prev = on + } + return n + } + if a, b, z := dotsAt("Wednesday, October 14, 2026, 4 events"), dotsAt("Tuesday, October 20, 2026, 1 event"), dotsAt(datetimeTestLabel(oct(21))); a != 2 || b != 1 || z != 0 { + t.Fatalf("dots %d %d %d", a, b, z) + } + if yearDots(6) != 3 { + t.Fatal("6 events should show three dots") + } +} + +func TestYearViewClickKeysAndYearChange(t *testing.T) { + year := 2026 + var days []Date + tt := yearViewTester(&year, nil, YearViewOptions{}, &days) + _ = tt.Click(datetimeTestLabel(Date{2026, time.March, 3})) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(days) != 2 || days[0] != (Date{2026, time.March, 3}) || days[1] != (Date{2026, time.March, 11}) { + t.Fatalf("days %v", days) + } + _ = tt.Click(datetimeTestLabel(Date{2026, time.December, 31})) + tt.Key(0, ui.KeyRight) + if year != 2027 || !tt.Focused(datetimeTestLabel(Date{2027, time.January, 1})) { + t.Fatalf("crossing the year: %d", year) + } + _ = tt.Click("Previous year") + if year != 2026 { + t.Fatalf("year %d", year) + } +} + +func TestYearViewDisabledAndInvalid(t *testing.T) { + year := 2026 + var days []Date + tt := yearViewTester(&year, nil, YearViewOptions{Disabled: true}, &days) + _ = tt.Click(datetimeTestLabel(oct(3))) + _ = tt.Click("Next year") + if len(days) != 0 || year != 2026 { + t.Fatal("disabled year view reacted") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic for year 0") + } + }() + zero := 0 + yearViewTester(&zero, nil, YearViewOptions{}, &days) +} + +func TestYearViewSnapshot(t *testing.T) { + var evs []CalendarEvent + for i := range 40 { + evs = append(evs, calEvent(fmt.Sprint(i), 1+i%28, 9+i%5, 0, 30)) + } + year := 2026 + snapshot(t, "year_view", 680, 960, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + YearView(c, &year, evs, YearViewOptions{Location: calTokyo, Today: calendarToday}) + }) + }) +}