Calendar: components 421-440

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:35:41 -04:00
1 parent 609b4928ec
commit 3676cfc2c5
110 files changed
+7335 -23

No files matched your search

+149
View File
@@ -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()
}
+95
View File
@@ -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})
})
})
}
+2 -2
View File
@@ -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
+82
View File
@@ -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}) })
})
}
+180
View File
@@ -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)
}
}
})
}
+108
View File
@@ -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}}})
})
})
}
+50
View File
@@ -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
}
+79
View File
@@ -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)})
})
})
}
+50 -16
View File
@@ -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 {
+248
View File
@@ -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)
}
+171
View File
@@ -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})
})
})
}
+369
View File
@@ -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}
}
+80
View File
@@ -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)
}
+175
View File
@@ -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)})
})
})
}
+103
View File
@@ -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
}
+69
View File
@@ -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})
})
})
}
+346
View File
@@ -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<<h) == 0 {
continue
}
for m := 0; m < 60 && len(out) < n; m++ {
if s.fields[0]&(1<<m) == 0 {
continue
}
at, k := dateTimeResolve(day, TimeOfDay{h, m, 0}, loc)
if k == 0 || !at.After(t) {
continue
}
out = append(out, CronRun{at, k > 1})
}
}
}
return out
}
// dayMatches applies cron's either-day-field rule.
func (s CronSchedule) dayMatches(d Date) bool {
if s.fields[3]&(1<<int(d.Month)) == 0 {
return false
}
dom := s.fields[2]&(1<<d.Day) != 0
dow := s.fields[4]&(1<<int(d.Weekday())) != 0
if !s.domAny && !s.dowAny {
return dom || dow
}
return dom && dow
}
// CronEditorOptions configure a CronEditor.
type CronEditorOptions struct {
// Location is the zone runs are computed in; required.
Location *time.Location
// Now starts the preview; zero uses the system clock.
Now time.Time
// Runs is how many next runs show; zero means 5.
Runs int
Hour12 bool
Disabled bool
Label string
}
// CronEditor edits, explains and previews a cron expression.
func CronEditor(c *ui.Context, expr *string, opts CronEditorOptions) ChoiceResult {
loc := eventLocation("CronEditor", opts.Location)
if opts.Runs < 0 {
panic("mujica: CronEditor Runs is negative")
}
runs := opts.Runs
if runs == 0 {
runs = 5
}
now := opts.Now
if now.IsZero() {
now = c.Now()
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "cron_editor.label")
}
old := *expr
col := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
cache := ui.Local(col, "cron-parse", func() *cronParsed { return &cronParsed{} })
col.Children(func() {
cp := *cache
if !cp.ok || cp.expr != *expr || !cp.from.Equal(now.Truncate(time.Minute)) {
cp.s, cp.err = ParseCron(*expr)
cp.expr, cp.ok, cp.from = *expr, true, now.Truncate(time.Minute)
if cp.err == nil {
cp.runs = cp.s.Next(now, loc, runs)
}
}
problem := ""
if cp.err != nil {
problem = cronProblem(c, cp.err.(*CronError))
}
in := TextInput(c, expr, TextInputOptions{Label: msg(c, "cron_editor.expression"), Placeholder: "*/15 9-17 * * MON-FRI", Disabled: opts.Disabled, Error: problem})
in.Input.Font(theme.MonoFont)
bad := -2
if cp.err != nil {
bad = cp.err.(*CronError).Field
}
fields := strings.Fields(*expr)
ui.Row(c).Gap(d.Unit() * 2).Wrap().Children(func() {
for f := range 5 {
tok := ""
if f < len(fields) {
tok = fields[f]
}
cronFieldCard(c, f, tok, f == bad || bad == -1)
}
})
if cp.err != nil {
return
}
ui.Text(c, msg(c, "cron_editor.next")).Font(theme.SerifFont).TextColor(k.Text)
ui.Column(c).Gap(d.Unit()).Role(ui.RoleList).Children(func() {
list := cp.runs
if len(list) == 0 {
ui.Text(c, msg(c, "cron_editor.never")).TextColor(k.TextMuted)
}
for _, run := range list {
w := run.At.In(loc)
text := msg(c, "calendar.weekday_"+strconv.Itoa(int(w.Weekday()))) + " " + DateOf(w).String() + " " +
timeText(c, TimeOfDay{w.Hour(), w.Minute(), 0}, timeConfig{hour12: opts.Hour12}) + " " + w.Format("MST")
if run.Repeated {
text += " · " + msg(c, "cron_editor.repeated")
}
ui.Text(c, text).FontFeatures("tnum").TextColor(k.Text).Role(ui.RoleListItem)
}
})
})
changed := *expr != old && !col.IsDisabled()
if changed {
slog.Debug("mujica: cron_editor change", "valid", (*cache).err == nil)
}
return ChoiceResult{Element: col, changed: changed}
}
// cronParsed caches the parse and the runs from one minute.
type cronParsed struct {
expr string
from time.Time
s CronSchedule
err error
runs []CronRun
ok bool
}
var cronFieldKeys = [5]string{"cron_editor.minute", "cron_editor.hour", "cron_editor.dom", "cron_editor.month", "cron_editor.dow"}
func cronProblem(c *ui.Context, e *CronError) string {
reason := msg(c, cronProblemKeys[e.Problem])
switch e.Problem {
case CronFieldCount, CronBadStep, CronNotNumber, CronBackwards:
reason = fmt.Sprintf(reason, e.Text)
case CronOutOfRange:
reason = fmt.Sprintf(reason, e.Text, cronBounds[e.Field][0], cronBounds[e.Field][1])
}
if e.Field < 0 {
return reason
}
return fmt.Sprintf(msg(c, "cron_editor.field_error"), msg(c, cronFieldKeys[e.Field]), reason)
}
// cronFieldCard shows one field's token and meaning.
func cronFieldCard(c *ui.Context, f int, tok string, bad bool) {
k := Tokens(c)
d := Density(c)
border := k.Border
if bad {
border = k.Danger
}
ui.Column(c).MinWidth(d.ControlHeight()*3).Padding(d.Unit()*2).Gap(d.Unit()).Radius(theme.CardRadius).
Border(theme.BorderWidth, border).Background(k.Surface).Children(func() {
ui.Text(c, msg(c, cronFieldKeys[f])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
ui.Text(c, tok).Font(theme.MonoFont).TextColor(k.Text)
desc := cronDescribe(c, f, tok)
if bad {
desc = msg(c, "cron_editor.invalid")
}
ui.Text(c, desc).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
}
// cronDescribe words a field token, as "every 15" or "9–17".
func cronDescribe(c *ui.Context, f int, tok string) string {
if tok == "*" {
return msg(c, "cron_editor.any")
}
var parts []string
for _, item := range strings.Split(tok, ",") {
rng, step, hasStep := strings.Cut(item, "/")
rng = strings.ReplaceAll(rng, "-", "–")
switch {
case hasStep && rng == "*":
parts = append(parts, fmt.Sprintf(msg(c, "cron_editor.every"), step))
case hasStep:
parts = append(parts, fmt.Sprintf(msg(c, "cron_editor.every_in"), step, rng))
default:
parts = append(parts, rng)
}
}
return strings.Join(parts, ", ")
}
+136
View File
@@ -0,0 +1,136 @@
package mujica
import (
"errors"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func cronRuns(t *testing.T, expr string, from time.Time, loc *time.Location, n int) []CronRun {
t.Helper()
s, err := ParseCron(expr)
if err != nil {
t.Fatalf("%s: %v", expr, err)
}
return s.Next(from, loc, n)
}
func TestParseCronErrors(t *testing.T) {
for _, c := range []struct {
expr string
field int
p CronProblem
}{{"* * * *", -1, CronFieldCount}, {"60 * * * *", 0, CronOutOfRange}, {"* 9-5 * * *", 1, CronBackwards},
{"*/0 * * * *", 0, CronBadStep}, {"* * x * *", 2, CronNotNumber}, {"1,,2 * * * *", 0, CronEmptyItem}, {"* * * 13 *", 3, CronOutOfRange}} {
_, err := ParseCron(c.expr)
var ce *CronError
if !errors.As(err, &ce) || ce.Field != c.field || ce.Problem != c.p {
t.Errorf("%q: %v", c.expr, err)
}
}
}
func TestCronNextRuns(t *testing.T) {
from := time.Date(2026, time.October, 5, 8, 50, 0, 0, calTokyo) // Monday
got := cronRuns(t, "*/15 9-10 * * MON-FRI", from, calTokyo, 3)
if len(got) != 3 || !got[0].At.Equal(calAt(t, 9, 0, 5)) || !got[2].At.Equal(calAt(t, 9, 30, 5)) {
t.Fatalf("weekday runs %v", got)
}
got = cronRuns(t, "0 12 1 * SUN", from, calTokyo, 2)
if !got[0].At.Equal(calAt(t, 12, 0, 11)) || !got[1].At.Equal(calAt(t, 12, 0, 18)) {
t.Fatalf("day-of-month or day-of-week: %v", got)
}
got = cronRuns(t, "0 0 29 2 *", from, calTokyo, 1)
if len(got) != 1 || got[0].At.Year() != 2028 {
t.Fatalf("leap day %v", got)
}
if got = cronRuns(t, "0 0 31 2 *", from, calTokyo, 1); len(got) != 0 {
t.Fatalf("Feb 31 never runs: %v", got)
}
}
func TestCronNextRunsAcrossDST(t *testing.T) {
ny, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
got := cronRuns(t, "30 2 * * *", time.Date(2026, time.March, 7, 3, 0, 0, 0, ny), ny, 2)
if got[0].At.Day() != 9 {
t.Fatalf("02:30 on the gap day must not run: %v", got)
}
got = cronRuns(t, "30 1 * * *", time.Date(2026, time.October, 31, 3, 0, 0, 0, ny), ny, 1)
if !got[0].Repeated || got[0].At.Day() != 1 {
t.Fatalf("01:30 on fall-back is repeated: %v", got)
}
}
func cronTester(expr *string, opts CronEditorOptions, n *int) *ui.Tester {
opts.Location, opts.Now = calTokyo, time.Date(2026, time.October, 5, 8, 50, 0, 0, calTokyo)
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
if CronEditor(c, expr, opts).Changed() {
*n++
}
})
}, 760, 520)
}
func TestCronEditorEditAndPreview(t *testing.T) {
expr, n := "0 9 * * MON-FRI", 0
tt := cronTester(&expr, CronEditorOptions{Runs: 3}, &n)
for _, s := range []string{"Mo 2026-10-05 09:00 JST", "Tu 2026-10-06 09:00 JST", "MON–FRI", "Every value"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %v", s, tt.Texts())
}
}
_ = tt.Click("Expression")
tt.Command("selectAll")
tt.Type("0 25 * * *")
if n == 0 || !tt.HasText("Hour: 25 is outside 0–23.") || tt.HasText("Next runs") {
t.Fatalf("error not shown: %v", tt.Texts())
}
tt.Command("selectAll")
tt.Type("*/30 * * * *")
if !tt.HasText("Every 30") || !tt.HasText("Mo 2026-10-05 09:00 JST") {
t.Fatalf("fixed expression: %v", tt.Texts())
}
}
func TestCronEditorDisabledAndLocale(t *testing.T) {
expr, n := "0 9 * * *", 0
tt := cronTester(&expr, CronEditorOptions{Disabled: true}, &n)
_ = tt.Click("Expression")
tt.Type("x")
if expr != "0 9 * * *" || n != 0 {
t.Fatal("disabled editor changed")
}
bad := "* *"
tt = ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
CronEditor(c, &bad, CronEditorOptions{Location: calTokyo})
}, 760, 400)
if !tt.HasText("需要 5 个字段,而不是 2 个。") {
t.Fatalf("zh error: %v", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("expected panic without Location")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CronEditor(c, &expr, CronEditorOptions{})
}, 10, 10)
}
func TestCronEditorSnapshot(t *testing.T) {
expr := "*/15 9-17 * * MON-FRI"
snapshot(t, "cron_editor", 720, 400, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CronEditor(c, &expr, CronEditorOptions{Location: calTokyo, Now: time.Date(2026, time.October, 5, 8, 50, 0, 0, calTokyo)})
})
})
}
+51
View File
@@ -0,0 +1,51 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
)
// CurrentTimeIndicatorOptions configure a CurrentTimeIndicator.
type CurrentTimeIndicatorOptions struct {
// Location is the zone of the day shown; required.
Location *time.Location
// Day is the day the parent spans; zero means today.
Day Date
// Now overrides the clock; zero uses the system clock.
Now time.Time
Hour12 bool
}
// CurrentTimeIndicator rules the current time across its parent.
func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element {
loc := eventLocation("CurrentTimeIndicator", opts.Location)
k := Tokens(c)
now := opts.Now
if now.IsZero() {
now = c.Now()
c.After(now.Truncate(time.Minute).Add(time.Minute).Sub(now))
}
wall := now.In(loc)
day := opts.Day
if day.IsZero() {
day = DateOf(wall)
}
datetimeCheckDate("CurrentTimeIndicator Day", day)
e := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).PassThrough()
if DateOf(wall) != day {
return e.Role(ui.RoleNone)
}
frac := float32(wall.Hour()*60+wall.Minute()) / (24 * 60)
when := timeText(c, TimeOfDay{wall.Hour(), wall.Minute(), 0}, timeConfig{hour12: opts.Hour12})
e.Role(ui.RoleStatus).Label(fmt.Sprintf(msg(c, "current_time_indicator.label"), when))
e.Draw(func(p *ui.Painter, r ui.Rect) {
y := internal.Snap(p, r.Y+r.H*frac)
p.Line(r.X, y, r.X+r.W, y, 2, k.Danger)
p.FillPath(internal.Diamond(internal.Snap(p, r.X+4), y, 8), k.Danger)
})
return e
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestCurrentTimeIndicatorPositionAndZone(t *testing.T) {
now := time.Date(2026, time.October, 14, 3, 0, 0, 0, time.UTC) // 12:00 in Tokyo
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Box(c).Size(200, 240).Children(func() {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Day: oct(14), Now: now})
})
}, 200, 240)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !tt.HasText("Now, 12:00") {
t.Fatalf("label %v", tt.Texts())
}
img := tt.Image()
if !datetimeNear(img.RGBAAt(100, 120), theme.Light().Danger) || datetimeNear(img.RGBAAt(100, 60), theme.Light().Danger) {
t.Fatal("the line should sit halfway down at noon")
}
}
func TestCurrentTimeIndicatorOtherDayHidden(t *testing.T) {
now := time.Date(2026, time.October, 14, 12, 0, 0, 0, calTokyo)
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Box(c).Size(200, 240).Children(func() {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Day: oct(15), Now: now})
})
}, 200, 240)
if tt.HasText("Now,") {
t.Fatal("no line on another day")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic without Location")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{})
}, 10, 10)
}
func TestCurrentTimeIndicatorUsesClockWhileShown(t *testing.T) {
shown := true
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Box(c).Size(200, 240).Children(func() {
if shown {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: time.Local})
}
})
}, 200, 240)
if !tt.HasText("Now, ") {
t.Fatalf("today's line missing: %v", tt.Texts())
}
shown = false
tt.Frame()
if tt.HasText("Now, ") {
t.Fatal("hidden indicator still shows")
}
}
func TestCurrentTimeIndicatorSnapshot(t *testing.T) {
now := time.Date(2026, time.October, 14, 15, 30, 0, 0, calTokyo)
snapshot(t, "current_time_indicator", 240, 160, func(c *ui.Context) {
ui.Box(c).Margin(16).Size(208, 128).Border(1, Tokens(c).Border).Children(func() {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Now: now})
})
})
}
@@ -0,0 +1,57 @@
# 421 CalendarMonthView · 月视图
## 用途
按月显示事件格。每天最多显示若干行事件,其余折叠为“+N”,点击后在浮层中列出;事件可拖到其他日期改期。同一天时间重叠的事件并排放在同一行。
## 最小示例
```go
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 CalendarMonthView(c *ui.Context, month *YearMonth, events []CalendarEvent, opts CalendarMonthViewOptions) CalendarViewResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
| `MaxPerDay` | `int` | 0(即 3) | 每天显示的事件行数,超出部分折叠为“+N”。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、悬停、聚焦(焦点环)、今日(圆形底色 + 加粗)、非本月(弱化)、拖动经过的日期(选中底色 + 内嵌框)、禁用;改期失败时下方显示错误。
## 事件
`Opened()` 返回被激活的事件 ID;`DayChosen()` 返回点击的日期;`Changed()` 返回 `CalendarMove` 变更(保持墙上时间与时长)。上一月/下一月直接修改 `*month`。
## 键盘操作
- Tab 到达日期数字与事件块,方向键在其间移动。
- Enter 或 Space 打开事件或选择日期。
- 事件块上 Alt+←/→ 移动一天,Alt+↑/↓ 移动一周。
- Escape 关闭“+N”浮层。
## 限制
- 拖动改期使用 MyGo 的 `Drag`/`Drop`;拖动预览是 MyGo 绘制的元素副本。
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
+58
View File
@@ -0,0 +1,58 @@
# 422 CalendarWeekView · 周视图
## 用途
一周的时间网格,顶部为全天行。在空白处拖动新建事件,拖动事件块移动,拖动底边调整时长;重叠事件按列并排。
## 最小示例
```go
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 CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
| `SlotMinutes` | `int` | 0(即 30) | 拖动与键盘的时间格长度,须整除一小时。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
| `Now` | `time.Time` | 零值(系统时钟) | 当前时间线的位置。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、悬停、聚焦列(时间格光标:选中底色 + 内嵌框)、拖动中(新建区域虚线框、被拖事件内嵌框)、今日表头、当前时间线、禁用;无法表示的时间显示错误。
## 事件
`Changed()` 返回 `CalendarCreate`、`CalendarMove` 或 `CalendarResize`;`Opened()` 返回点击的事件;`DayChosen()` 返回点击的表头日期。
## 键盘操作
- Tab 聚焦某一天的列,↑/↓ 移动时间格光标,Enter 新建一格长的事件。
- 事件块上 Alt+↑/↓ 按格移动,Alt+←/→ 移动一天,Alt+Shift+↑/↓ 调整结束时间。
## 限制
- 调整时长的底边手柄只有 6 DIP 高,低于 28 DIP 命中区域要求;同样的操作可用事件块上的 Alt+Shift+↑/↓ 完成。
- 网格高 24 小时,默认滚动到 8:00;跨越 24 小时以上的事件放在全天行。
- 时间按墙上时间排布,夏令时当天的重复或缺失小时不改变格高。
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
+53
View File
@@ -0,0 +1,53 @@
# 423 CalendarDayView · 日视图
## 用途
单日时间网格;重叠事件按列并排,交互与周视图相同。
## 最小示例
```go
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 CalendarDayView(c *ui.Context, day *Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
| `SlotMinutes` | `int` | 0(即 30) | 拖动与键盘的时间格长度,须整除一小时。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
| `Now` | `time.Time` | 零值(系统时钟) | 当前时间线的位置。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
同周视图。
## 事件
同周视图:`Changed()`、`Opened()`、`DayChosen()`;前一天/后一天修改 `*day`。
## 键盘操作
- 同周视图。
## 限制
- 与周视图共用网格实现。
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
+53
View File
@@ -0,0 +1,53 @@
# 424 AgendaView · 日程列表
## 用途
按日分组的日程列表,日期标题在滚动时固定在顶部;只构建可见行,适合大量事件。
## 最小示例
```go
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 AgendaView(c *ui.Context, events []CalendarEvent, opts AgendaViewOptions) CalendarViewResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
| `From` | `Date` | 零值(今天) | 列表的第一天。 |
| `Days` | `int` | 0(即 30) | 列出的天数。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、悬停、聚焦、今日标题(强调色 + “今天”)、空状态、禁用。
## 事件
`Opened()` 返回被激活事件的 ID。
## 键盘操作
- Tab 进入列表,↑/↓ 在事件间移动,Enter 打开。
## 限制
- 跨越多天的事件在每一天下各列一次。
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
+52
View File
@@ -0,0 +1,52 @@
# 425 YearView · 年视图
## 用途
十二个月的小日历,每天下方用 1~3 个点表示事件密度,读屏会读出事件数量。
## 最小示例
```go
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 YearView(c *ui.Context, year *int, events []CalendarEvent, opts YearViewOptions) CalendarViewResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、悬停、聚焦、今日(底色 + 加粗)、密度点(1~2 项一点、3~5 项两点、6 项以上三点)、禁用。
## 事件
`DayChosen()` 返回被激活的日期;上一年/下一年及跨年移动修改 `*year`。
## 键盘操作
- Tab 进入日期,←/→ 移动一天,↑/↓ 移动一周,PageUp/PageDown 移动一月,可跨年。
- Enter 选择。
## 限制
- 密度不只靠颜色:用点数区分,并写入标签。
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
+47
View File
@@ -0,0 +1,47 @@
# 426 EventChip · 事件块
## 用途
显示单个事件:左侧色条、标题与时间,超出时截断为省略号。
## 最小示例
```go
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 EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Inline` | `bool` | false | 开始时间与标题放在一行。 |
| `Selected` | `bool` | false | 选中:选中底色 + 内嵌框。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
## 状态
默认、悬停与按下(色调加深)、聚焦、选中、禁用。
## 事件
返回的元素可用 `.Clicked()` 判断激活。
## 键盘操作
- Tab 聚焦,Enter 或 Space 激活。
## 限制
- 事件颜色来自主题:AccentText、Info、Success、Warning、Danger、TextMuted,均在两套主题下对 Surface 达到 3:1;Ornament 在浅色主题下仅 2.8:1,不纳入。
+49
View File
@@ -0,0 +1,49 @@
# 427 EventEditor · 事件编辑器
## 用途
编辑事件的标题、时间、地点、参与者、提醒与重复规则。编辑的是副本,点击保存并校验通过后才写回。
## 最小示例
```go
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 EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) EventEditorResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、字段错误(保存后显示:缺少标题、结束早于开始、重复规则问题)、全天模式(只选日期)、禁用。
## 事件
`Saved()`:校验通过并写回 `*ev`;`Canceled()`:丢弃草稿。
## 键盘操作
- Tab 在字段间移动,各字段保留自身按键;保存与取消为按钮。
## 限制
- 全天事件保存时规整为当天零点到次日零点。
- 参与者按姓名添加,新参与者状态为“未回复”。
+52
View File
@@ -0,0 +1,52 @@
# 428 EventPopover · 事件浮层
## 用途
在锚点旁显示事件摘要:时间、地点、重复、参与者与提醒,并提供编辑与删除入口。
## 最小示例
```go
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 EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `ReadOnly` | `bool` | false | 隐藏编辑与删除。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
## 状态
打开(160 ms 淡入,减少动画时直接显示)、只读、禁用(自动关闭)。
## 事件
`Edit()`、`Delete()` 报告点击并关闭浮层;删除只报告,确认由调用方决定。
## 键盘操作
- Tab 到达关闭、删除与编辑;Escape 或点击外部关闭。
## 限制
- 浮层尺寸受窗口限制,内容超出时滚动。
@@ -0,0 +1,44 @@
# 429 CurrentTimeIndicator · 当前时间线
## 用途
填满父元素,按当前时间在当天中的比例画一条横线,用于时间网格。
## 最小示例
```go
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 CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Day` | `Date` | 零值(今天) | 父元素自上而下代表的日期;不是今天时不显示。 |
| `Now` | `time.Time` | 零值(系统时钟) | 当前时间;固定后不再定时刷新,便于测试。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
## 状态
显示(Danger 色线与菱形)、不在当天时隐藏。
## 事件
无事件;读屏通过标签读出当前时间。
## 键盘操作
- 不可聚焦。
## 限制
- 仅在被构建(可见)时用 `c.After` 预约下一分钟的重绘;隐藏后不再预约,自动停止。
+46
View File
@@ -0,0 +1,46 @@
# 430 RecurrenceEditor · 重复规则编辑器
## 用途
选择每天、每周、每月、每年或自定义间隔,每周可选星期,并设置结束条件(永不、次数、日期)。
## 最小示例
```go
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 RecurrenceEditor(c *ui.Context, rule *Recurrence, opts RecurrenceEditorOptions) RecurrenceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Start` | `Date` | 零值 | 首次发生日期;结束日期不能早于它,每周规则默认取其星期。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、自定义(显示间隔与单位)、每周(星期按钮)、结束条件、问题提示(无星期、次数小于 1、结束日期过早)、禁用。
## 事件
`Changed()` 报告规则变化;`Problem()` 返回当前不可保存的原因;`RecurrenceSummary` 生成本地化描述。
## 键盘操作
- Tab 在重复选择、间隔、星期与结束条件之间移动,各控件保留自身按键。
## 限制
- 只描述规则,不展开具体日期。
@@ -0,0 +1,49 @@
# 431 AvailabilityPicker · 空闲时段选择
## 用途
按天与时间的网格,拖动涂抹自己的空闲时段,并显示其他人在每个时段的空闲人数。
## 最小示例
```go
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 AvailabilityPicker(c *ui.Context, mine *[][]bool, opts AvailabilityPickerOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Days` | `[]Date` | 必填 | 网格的列。 |
| `From / To` | `int` | 9 / 18 | 起止小时,0 ≤ From < To ≤ 24。 |
| `SlotMinutes` | `int` | 0(即 30) | 时段长度。 |
| `Others` | `[]AvailabilityPerson` | nil | 其他人的空闲表,维度须与值一致。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、自己空闲(选中底色 + 勾)、他人空闲(数量 + 条形)、全员可行(加粗强调)、键盘光标(内嵌框)、禁用。
## 事件
`Changed()` 报告自己的空闲表变化。
## 键盘操作
- Tab 聚焦网格,方向键移动光标,Space 或 Enter 切换。
## 限制
- 值的维度为 [天][时段],与选项不符时 panic。
+46
View File
@@ -0,0 +1,46 @@
# 432 TimezoneSelect · 时区选择
## 用途
按城市名或偏移搜索 IANA 时区,常用时区置顶,显示当前偏移。
## 最小示例
```go
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 TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Zones` | `[]string` | 内置世界列表 | 可选时区。 |
| `Common` | `[]string` | 内置常用列表 | 置顶的常用时区。 |
| `Now` | `time.Time` | 零值(系统时钟) | 计算偏移所用的时刻(夏令时会改变偏移)。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
关闭、打开(搜索框 + 分组列表)、高亮、已选(勾)、无结果、禁用。
## 事件
`Changed()` 报告选中的时区变化。
## 键盘操作
- Enter、Space 或 ↓ 打开;输入搜索;↑/↓ 移动高亮;Enter 选择;Escape 关闭。
## 限制
- 内嵌 `time/tzdata`,离线可用;未知时区名 panic。
+45
View File
@@ -0,0 +1,45 @@
# 433 ReminderPicker · 提醒选择
## 用途
多选事件前的提醒时间,按选项顺序保存。
## 最小示例
```go
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 ReminderPicker(c *ui.Context, value *[]time.Duration, opts ReminderPickerOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Choices` | `[]time.Duration` | 0、5 分、15 分、30 分、1 时、1 天、1 周 | 可选的提前量,须为整分钟且不重复。 |
| `Max` | `int` | 0(不限) | 最多选择的数量,达到后其余选项禁用。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、选中(选中底色 + 下划线标记)、达到上限的禁用项、禁用。
## 事件
`Changed()` 报告选择变化。
## 键盘操作
- Tab 进入列表,↑/↓ 移动,Space 切换。
## 限制
- 只支持事件开始前的提醒;提前量须为整分钟,否则 panic。
+44
View File
@@ -0,0 +1,44 @@
# 434 AttendeeList · 参与者列表
## 用途
列出参与者、头像、邮箱与回复状态(文字 + 图标),顶部汇总各状态人数。
## 最小示例
```go
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 AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Removable` | `bool` | false | 除组织者外显示移除按钮。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
接受、待定、拒绝、未回复(各有图标与文字)、组织者、可选、空状态、禁用。
## 事件
`Removed()` 返回被移除者的下标,由调用方从切片中删除。
## 键盘操作
- Tab 到达各移除按钮,Enter 移除。
## 限制
- 回复状态不由组件修改。
+47
View File
@@ -0,0 +1,47 @@
# 435 WeekPicker · 周选择器
## 用途
选择整周:悬停时预览整周,值为该周第一天。
## 最小示例
```go
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 WeekPicker(c *ui.Context, value *Date, opts WeekPickerOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
| `Min / Max` | `Date` | 零值 | 所选周必须落在范围内。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
| `Clearable` | `bool` | false | 显示清空按钮。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
关闭、打开(整周预览带、已选周)、禁用。
## 事件
`Changed()` 报告选中周变化。
## 键盘操作
- Enter、Space 或 ↓ 打开;方向键移动;Enter 选择所在周;Escape 关闭。
## 限制
- 值不是周起始日时 panic;更换周起始日后由调用方重设值。
+46
View File
@@ -0,0 +1,46 @@
# 436 DurationPicker · 时长选择器
## 用途
以时、分、秒三个数字框编辑时长,并检查范围。
## 最小示例
```go
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 DurationPicker(c *ui.Context, value *time.Duration, opts DurationPickerOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Seconds` | `bool` | false | 显示秒字段;关闭时值须为整分钟。 |
| `Min / Max` | `time.Duration` | 0 / 99 时 59 分 59 秒 | 允许的范围。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
默认、聚焦、超出范围(错误说明,值不写入)、禁用。
## 事件
`Changed()` 报告合法的新时长。
## 键盘操作
- Tab 在三个字段间移动;↑/↓ 步进;键入数字后 Enter。
## 限制
- 超出范围的输入保留并报告,不会被夹取。
+51
View File
@@ -0,0 +1,51 @@
# 437 CronEditor · Cron 编辑器
## 用途
编辑标准 5 字段 cron 表达式,逐字段解释、就地报告错误,并预览接下来的运行时间。
## 最小示例
```go
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 CronEditor(c *ui.Context, expr *string, opts CronEditorOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Now` | `time.Time` | 零值(系统时钟) | 预览的起点。 |
| `Runs` | `int` | 0(即 5) | 预览的次数。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
有效(字段卡片 + 运行时间)、错误(出错字段红框 + 说明,不显示预览)、禁用。
## 事件
`Changed()` 报告表达式文本变化;`ParseCron` 可单独校验并计算 `Next`。
## 键盘操作
- 在表达式框中输入;解释与预览随输入更新。
## 限制
- 支持列表、范围、步长、`*` 与月份/星期英文缩写;日与星期同时受限时任一匹配即运行(cron 惯例)。
- 夏令时跳过的时间不运行;重复的时间取第一次并标注。
- 只搜索未来五年。
+42
View File
@@ -0,0 +1,42 @@
# 438 RelativeTime · 相对时间
## 用途
把时间显示为“3 分钟前”“2 天后”,并在提示与标签中给出精确时间。
## 最小示例
```go
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 RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 |
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
## 状态
刚刚、分钟、小时、天、月、年。
## 事件
无事件。
## 键盘操作
- 不可聚焦;精确时间写在标签与提示中。
## 限制
- 刷新频率随距离变化:2 分钟内每秒、2 小时内每分钟、更远每小时;仅在可见时用 `c.After` 预约,隐藏即停止。
+44
View File
@@ -0,0 +1,44 @@
# 439 Countdown · 倒计时
## 用途
显示距截止时间的剩余时间,归零时报告一次并朗读提示。
## 最小示例
```go
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 Countdown(c *ui.Context, deadline time.Time, opts CountdownOptions) CountdownResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Format` | `CountdownFormat` | `CountdownClock` | 时钟格式 01:02:03 或文字格式“1 小时 2 分 3 秒”。 |
| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
计时中、归零(强调色)。
## 事件
`Finished()` 在每个截止时间归零的那一帧报告一次。
## 键盘操作
- 不可聚焦;归零时通过 `Announce` 通知读屏。
## 限制
- 仅在可见时每秒用 `c.After` 预约重绘,隐藏或归零后停止。
+44
View File
@@ -0,0 +1,44 @@
# 440 Stopwatch · 秒表
## 用途
秒表:开始、暂停、继续、计次与复位,计次列表显示单圈与累计时间。
## 最小示例
```go
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)
```
## 参数
`func Stopwatch(c *ui.Context, s *StopwatchState, opts StopwatchOptions) ChoiceResult`
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 |
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
## 状态
未开始、运行中、暂停(显示“继续”)、有计次、禁用。
## 事件
`Changed()` 报告开始、暂停、计次或复位;`StopwatchState.Elapsed` 与 `Laps` 读取结果。
## 键盘操作
- Tab 到达开始/暂停、计次与复位,Enter 或 Space 按下。
## 限制
- 运行且可见时每 100 ms 用 `c.After` 预约重绘;暂停或隐藏后停止。
+100
View File
@@ -0,0 +1,100 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
)
// DurationPickerOptions configure a DurationPicker.
type DurationPickerOptions struct {
// Seconds adds a seconds field.
Seconds bool
// Min and Max bound the duration; zero Max means 99:59:59.
Min, Max time.Duration
Disabled bool
Label string
}
// DurationPicker edits hours, minutes and seconds within a range.
func DurationPicker(c *ui.Context, value *time.Duration, opts DurationPickerOptions) ChoiceResult {
hi := opts.Max
if hi == 0 {
hi = 99*time.Hour + 59*time.Minute + 59*time.Second
}
unit := time.Minute
if opts.Seconds {
unit = time.Second
}
switch {
case opts.Min < 0 || hi < opts.Min:
panic(fmt.Sprintf("mujica: DurationPicker range %v..%v is not valid", opts.Min, hi))
case *value < 0 || *value%unit != 0:
panic(fmt.Sprintf("mujica: DurationPicker value %v is negative or finer than the fields", *value))
}
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "duration_picker.label")
}
col := ui.Column(c).Gap(d.Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
draft := ui.Local(col, "duration-draft", func() [3]float64 { return durationParts(*value) })
seen := ui.Local(col, "duration-seen", func() time.Duration { return *value })
if *seen != *value {
*draft, *seen = durationParts(*value), *value
}
changed, edited := false, false
col.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
fields := []struct {
key string
max float64
}{{"duration_picker.hours", math.Floor(hi.Hours())}, {"duration_picker.minutes", 59}, {"duration_picker.seconds", 59}}
for i, f := range fields {
if i == 2 && !opts.Seconds {
break
}
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
ui.Box(c).Width(d.ControlHeight() * 3.2).Children(func() {
r := NumberInput(c, &draft[i], NumberInputOptions{Min: 0, Max: f.max, Label: msg(c, f.key), Disabled: opts.Disabled})
edited = edited || r.Changed()
})
ui.Text(c, msg(c, f.key+"_unit")).TextColor(Tokens(c).TextMuted)
})
}
})
total := time.Duration(draft[0])*time.Hour + time.Duration(draft[1])*time.Minute + time.Duration(draft[2])*time.Second
problem := ""
if total < opts.Min || total > 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
}
+87
View File
@@ -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}) })
})
}
+13 -4
View File
@@ -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})
}
+125
View File
@@ -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})
})
})
}
+171
View File
@@ -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
}
+112
View File
@@ -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}) })
})
}
+114
View File
@@ -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
}
+110
View File
@@ -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})
})
})
}
+262
View File
@@ -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)
}
+44 -1
View File
@@ -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."},
}
+253
View File
@@ -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)`
)
+217
View File
@@ -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:]...)
}
})
}
+128
View File
@@ -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())))
}
})
}
+194
View File
@@ -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
}
+9
View File
@@ -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)
}
+195
View File
@@ -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 圈"},
})
}
+223
View File
@@ -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
}
+133
View File
@@ -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)}) })
})
}
+75
View File
@@ -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
}
+83
View File
@@ -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)})
})
})
}
+73
View File
@@ -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))
}
+82
View File
@@ -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{}) })
})
}
+124
View File
@@ -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)
}
+63
View File
@@ -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}) })
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more