Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
package datetime
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 core.Date
// Days is how many days are listed; zero means 30.
Days int
// Today is marked; zero uses the system clock.
Today core.Date
Hour12 bool
Disabled bool
Label string
}
// agendaRow is a day heading (ev < 0) or event.
type agendaRow struct {
day core.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 := core.Tokens(c)
d := core.Density(c)
today := core.DatetimeToday(c, opts.Today)
from := opts.From
if from.IsZero() {
from = today
}
core.DatetimeCheckDate("AgendaView From", from)
label := opts.Label
if label == "" {
label = core.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 core.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, core.DatetimeDayLabel(c, row.day)).Font(theme.SerifFont).TextColor(k.Text)
if row.day == today {
t.Bold().TextColor(k.AccentText)
ui.Text(c, core.Msg(c, "agenda_view.today")).FontSize(core.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(core.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 core.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 := core.Tokens(c)
d := core.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 := EventTone(k, ev.Color)
b.Children(func() {
ui.Text(c, when).Width(d.ControlHeight() * 3.5).Shrink(0).FontSize(core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…")
}
})
})
return b.Clicked()
}
+97
View File
@@ -0,0 +1,97 @@
package datetime
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func agendaTester(events []CalendarEvent, opts AgendaViewOptions, opened *[]string) *ui.Tester {
opts.Location, opts.Today = calTokyo, calendarToday
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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})
})
})
}
+121
View File
@@ -0,0 +1,121 @@
package datetime
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AttendeeStatus is an attendee's reply.
type AttendeeStatus int
const (
AttendeePending AttendeeStatus = iota
AttendeeAccepted
AttendeeTentative
AttendeeDeclined
)
// Attendee is one person invited to an event.
type Attendee struct {
Name string
Email string
Status AttendeeStatus
Organizer bool
Optional bool
}
// AttendeeListOptions configure an AttendeeList.
type AttendeeListOptions struct {
// Removable shows a remove button on everyone but the organizer.
Removable bool
Disabled bool
Label string
}
// AttendeeListResult is the list and the attendee the user removed.
type AttendeeListResult struct {
Element *ui.Element
removed int
}
// Removed returns the index of the attendee the user removed.
func (r AttendeeListResult) Removed() (int, bool) { return r.removed, r.removed >= 0 }
// AttendeeList shows invitees, their replies and a summary.
func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult {
k := core.Tokens(c)
d := core.Density(c)
var counts [4]int
for _, a := range attendees {
if a.Status < AttendeePending || a.Status > AttendeeDeclined {
panic(fmt.Sprintf("mujica: AttendeeList status %d out of range", a.Status))
}
counts[a.Status]++
}
label := opts.Label
if label == "" {
label = core.Msg(c, "attendee_list.label")
}
r := AttendeeListResult{removed: -1}
r.Element = ui.Column(c).Gap(d.Unit()).Role(ui.RoleList).Label(label).Disabled(opts.Disabled)
r.Element.Children(func() {
summary := fmt.Sprintf(core.Msg(c, "attendee_list.summary"), counts[AttendeeAccepted], counts[AttendeeTentative],
counts[AttendeeDeclined], counts[AttendeePending])
ui.Text(c, summary).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
if len(attendees) == 0 {
ui.Text(c, core.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() {
display.Avatar(c, a.Name, display.AvatarOptions{Size: 28})
ui.Column(c).Grow(1).Shrink(1).Children(func() {
name := a.Name
if a.Organizer {
name += " · " + core.Msg(c, "attendee_list.organizer")
}
if a.Optional {
name += " · " + core.Msg(c, "attendee_list.optional")
}
ui.Text(c, name).TextColor(k.Text).SingleLine().Ellipsis("…")
if a.Email != "" {
ui.Text(c, a.Email).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…")
}
})
icon, fg := attendeeStatusLook(k, a.Status)
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Shrink(0).TextColor(fg).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16)
ui.Text(c, core.Msg(c, attendeeStatusKeys[a.Status])).FontSize(core.FontSize(c, theme.CaptionSize))
})
if opts.Removable && !a.Organizer {
b := core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "attendee_list.remove"), a.Name))
if b.Clicked() && !b.IsDisabled() {
slog.Debug("mujica: attendee_list remove", "index", i)
r.removed = i
}
}
})
}
})
return r
}
var attendeeStatusKeys = [...]string{"attendee_list.pending", "attendee_list.accepted", "attendee_list.tentative", "attendee_list.declined"}
func attendeeStatusLook(k theme.Tokens, s AttendeeStatus) (icon string, fg ui.Color) {
switch s {
case AttendeeAccepted:
return "circle-check", k.Success
case AttendeeTentative:
return "circle-alert", k.Warning
case AttendeeDeclined:
return "circle-x", k.Danger
}
return "clock", k.TextMuted
}
+84
View File
@@ -0,0 +1,84 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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) {
core.Use(c, core.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}) })
})
}
+181
View File
@@ -0,0 +1,181 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 []core.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) core.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 := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.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, core.Msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))+" "+strconv.Itoa(day.Day)).Grow(1).Basis(0).
TextAlign(ui.Center).FontSize(core.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(core.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 core.NewChoiceResult(grid, 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 core.Date, m int, mine bool, free, others int, cursor, hour12 bool) {
k := core.Tokens(c)
text := core.DatetimeDayLabel(c, day) + " " + timeText(c, TimeOfDay{m / 60, m % 60, 0}, timeConfig{hour12: hour12})
if mine {
text += ", " + core.Msg(c, "availability_picker.mine")
}
if others > 0 {
text += ", " + fmt.Sprintf(core.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 {
core.DatetimeSelectedMark(p, r, k.Focus)
}
})
cell.Children(func() {
if others > 0 && free > 0 {
t := ui.Text(c, strconv.Itoa(free)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
if everyone {
t.Bold().TextColor(k.AccentText)
}
}
})
}
+110
View File
@@ -0,0 +1,110 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func availabilityGrid(days, slots int) [][]bool {
v := make([][]bool, days)
for i := range v {
v[i] = make([]bool, slots)
}
return v
}
var availabilityDays = []core.Date{oct(12), oct(13), oct(14)}
func availabilityTester(v *[][]bool, opts *AvailabilityPickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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}}})
})
})
}
+270
View File
@@ -0,0 +1,270 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// WeekStart is a month's first column; zero follows the locale.
type WeekStart int
const (
WeekStartLocale WeekStart = iota
WeekStartSunday
WeekStartMonday
WeekStartSaturday
)
// CalendarOptions configure a Calendar.
type CalendarOptions struct {
// Min and Max bound choosable days; zero is open.
Min, Max core.Date
// DisableDate marks single days that cannot be chosen.
DisableDate func(core.Date) bool
WeekStart WeekStart
// Today is marked; zero uses the system clock.
Today core.Date
Disabled bool
// Label names the calendar for assistive technology.
Label string
}
// Calendar shows one month and chooses a day into *value.
func Calendar(c *ui.Context, value *core.Date, opts CalendarOptions) DateTimeResult {
core.DatetimeCheckDate("Calendar value", *value)
core.DatetimeCheckBounds("Calendar", opts.Min, opts.Max)
box := ui.Column(c)
cursor := ui.Local(box, "calendar-cursor", func() core.Date { return core.Date{} })
seen := ui.Local(box, "calendar-seen", func() core.Date { return core.Date{} })
today := core.DatetimeToday(c, opts.Today)
if *seen != *value || cursor.IsZero() {
*seen = *value
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
changed := false
box.Disabled(opts.Disabled).Children(func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(d core.Date) bool { return d == *value },
enabled: calendarEnabled(opts.Min, opts.Max, opts.DisableDate),
today: today,
weekStart: opts.WeekStart,
label: opts.Label,
})
if !picked.IsZero() && picked != *value {
slog.Debug("mujica: calendar change", "from", *value, "to", picked)
*value, *seen = picked, picked
changed = true
}
})
return DateTimeResult{Element: box, changed: changed}
}
// calendarConfig is how calendarMonth marks and limits its days.
type calendarConfig struct {
chosen func(core.Date) bool
inRange func(core.Date) bool
enabled func(core.Date) bool
today core.Date
weekStart WeekStart
autoFocus bool
label string
}
// calendarEnabled combines bounds and a day filter.
func calendarEnabled(lo, hi core.Date, disable func(core.Date) bool) func(core.Date) bool {
return func(d core.Date) bool { return d.Within(lo, hi) && (disable == nil || !disable(d)) }
}
// CalendarFirstWeekday resolves the first column for the window's locale.
func CalendarFirstWeekday(c *ui.Context, w WeekStart) time.Weekday {
switch w {
case WeekStartSunday:
return time.Sunday
case WeekStartMonday:
return time.Monday
case WeekStartSaturday:
return time.Saturday
case WeekStartLocale:
if core.CurrentLocale(c) == core.ZhCN {
return time.Monday
}
return time.Sunday
}
panic(fmt.Sprintf("mujica: unknown WeekStart %d", w))
}
// calendarSeek returns the first enabled day from d stepping by dir, or d.
func calendarSeek(d core.Date, dir int, enabled func(core.Date) bool) core.Date {
for i, x := 0, d; i < 31; i, x = i+1, x.AddDays(dir) {
if enabled(x) {
return x
}
}
return d
}
// calendarMonth draws *cursor's month; returns picked and hovered days.
func calendarMonth(c *ui.Context, cursor *core.Date, cfg calendarConfig) (picked, hovered core.Date) {
k := core.Tokens(c)
d := core.Density(c)
cur := *cursor
title := core.DatetimeMonthTitle(c, cur.Year, cur.Month)
label := cfg.label
if label == "" {
label = core.Msg(c, "calendar.label")
}
cell := d.ControlHeight()
root := ui.Column(c).Gap(d.Unit() * 2).Shrink(0).Width(cell * 7).Role(ui.RoleGroup).Label(label)
keyed := ui.Local(root, "calendar-keys", func() bool { return false })
move := func(to core.Date, dir int, keys bool) {
*cursor = calendarSeek(to, dir, cfg.enabled)
*keyed = keys
}
root.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).Children(func() {
if core.IconAction(c, icons.Must("chevrons-left"), core.Msg(c, "calendar.prev_year")).Clicked() {
move(cur.AddMonths(-12), -1, false)
}
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "calendar.prev_month")).Clicked() {
move(cur.AddMonths(-1), -1, false)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.BodySize+2)).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "calendar.next_month")).Clicked() {
move(cur.AddMonths(1), 1, false)
}
if core.IconAction(c, icons.Must("chevrons-right"), core.Msg(c, "calendar.next_year")).Clicked() {
move(cur.AddMonths(12), 1, false)
}
})
first := CalendarFirstWeekday(c, cfg.weekStart)
ui.Row(c).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).PaddingY(d.Unit()).Children(func() {
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, core.Msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center).
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleColumnHeader).
Label(core.Msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
}
})
days := ui.Column(c).Role(ui.RoleTable).Label(title).FocusGroup(ui.Horizontal | ui.Vertical)
keysMoved := true
switch {
case days.Shortcut(0, ui.KeyLeft):
move(cur.AddDays(-1), -1, true)
case days.Shortcut(0, ui.KeyRight):
move(cur.AddDays(1), 1, true)
case days.Shortcut(0, ui.KeyUp):
move(cur.AddDays(-7), -1, true)
case days.Shortcut(0, ui.KeyDown):
move(cur.AddDays(7), 1, true)
case days.Shortcut(0, ui.KeyPageUp):
move(cur.AddMonths(-1), -1, true)
case days.Shortcut(0, ui.KeyPageDown):
move(cur.AddMonths(1), 1, true)
case days.Shortcut(ui.Shift, ui.KeyPageUp):
move(cur.AddMonths(-12), -1, true)
case days.Shortcut(ui.Shift, ui.KeyPageDown):
move(cur.AddMonths(12), 1, true)
case days.Shortcut(0, ui.KeyHome):
move(core.Date{Year: cur.Year, Month: cur.Month, Day: 1}, 1, true)
case days.Shortcut(0, ui.KeyEnd):
move(core.Date{Year: cur.Year, Month: cur.Month, Day: 1}.AddMonths(1).AddDays(-1), -1, true)
default:
keysMoved = false
}
cur = *cursor
start := core.Date{Year: cur.Year, Month: cur.Month, Day: 1}
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
days.Children(func() {
for w := range 6 {
ui.Row(c).Role(ui.RoleRow).Children(func() {
for i := range 7 {
day := start.AddDays(w*7 + i)
clicked, hover := calendarDay(c, day, cur, cfg, cell, keysMoved, *keyed)
if clicked {
picked = day
move(day, 1, false)
}
if hover {
hovered = day
}
}
})
}
})
})
return picked, hovered
}
// calendarDay draws one day cell and reports whether it was picked or hovered.
func calendarDay(c *ui.Context, day, cur core.Date, cfg calendarConfig, size float32, focus, keyed bool) (picked, hovered bool) {
k := core.Tokens(c)
enabled := cfg.enabled(day)
chosen := cfg.chosen(day)
sel := core.Date{}
if chosen {
sel = day
}
cell := ui.RadioBase(c, &sel, day).Size(size, size-4).Center().Radius(theme.ControlRadius).
Label(core.DatetimeDayLabel(c, day)).Disabled(!enabled).FocusRing(false)
if day == cur && enabled {
if focus {
cell.Focus()
} else if cfg.autoFocus {
cell.AutoFocus()
}
}
band := cfg.inRange != nil && cfg.inRange(day)
fg := k.Text
if day.Month != cur.Month {
fg = k.TextMuted
}
switch {
case chosen:
cell.Background(k.Accent)
fg = k.OnAccent
if cell.Hovered() {
cell.Background(k.AccentHover)
}
case band:
cell.Background(k.Selection).Radius(0)
case cell.Pressed():
cell.Background(k.Selection)
case cell.Hovered():
cell.Background(k.SurfaceHover)
}
ring := cell.FocusVisible() || cell.Focused() && keyed
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if chosen {
core.DatetimeSelectedMark(p, r, k.OnAccent)
}
if day == cfg.today {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-5, 5), fg)
}
if ring {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
cell.Children(func() {
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(fg).FontFeatures("tnum")
if day == cfg.today {
t.Bold()
}
if !enabled {
t.Strikethrough()
}
})
return cell.Clicked() && enabled, cell.Hovered()
}
+52
View File
@@ -0,0 +1,52 @@
package datetime
import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// 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 core.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 *core.Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult {
core.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: core.DatetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12}
eventsCheck("CalendarDayView", events)
title := core.DatetimeDayLabel(c, *day)
label := opts.Label
if label == "" {
label = title
}
r := CalendarViewResult{}
root := ui.Column(c).Gap(core.Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
root.Children(func() {
calendarViewHeader(c, title, core.Msg(c, "calendar_day_view.prev"), core.Msg(c, "calendar_day_view.next"), func(dir int) {
*day = day.AddDays(dir)
})
timeGrid(c, root, []core.Date{*day}, events, cfg, &r)
})
return r
}
+81
View File
@@ -0,0 +1,81 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func dayViewTester(day *core.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) {
core.Use(c, core.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 := core.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)})
})
})
}
+239
View File
@@ -0,0 +1,239 @@
package datetime
import (
"fmt"
"sort"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// EventColor picks an event's color from the theme's event palette.
type EventColor int
const (
EventAccent EventColor = iota
EventInfo
EventSuccess
EventWarning
EventDanger
EventNeutral
)
// CalendarEvent is one entry of the views; End is exclusive.
type CalendarEvent struct {
ID string
Title string
Start time.Time
End time.Time
AllDay bool
Color EventColor
Location string
// Attendees, Reminders and Repeat are edited by EventEditor.
Attendees []Attendee
Reminders []time.Duration
Repeat Recurrence
}
// CalendarChangeKind says what a view gesture did.
type CalendarChangeKind int
const (
CalendarMove CalendarChangeKind = iota
CalendarResize
CalendarCreate
)
// CalendarChange is a move, resize or creation to apply.
type CalendarChange struct {
Kind CalendarChangeKind
ID string
Start, End time.Time
AllDay bool
}
// CalendarViewResult is a view and what the user asked.
type CalendarViewResult struct {
Element *ui.Element
opened string
change *CalendarChange
day core.Date
}
// Opened returns the event the user activated.
func (r CalendarViewResult) Opened() (string, bool) { return r.opened, r.opened != "" }
// Changed returns the move, resize or creation the user made.
func (r CalendarViewResult) Changed() (CalendarChange, bool) {
if r.change == nil {
return CalendarChange{}, false
}
return *r.change, true
}
// DayChosen returns the day the user activated.
func (r CalendarViewResult) DayChosen() (core.Date, bool) { return r.day, !r.day.IsZero() }
// 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}
}
func EventTone(k theme.Tokens, c EventColor) ui.Color {
if c < EventAccent || c > EventNeutral {
panic(fmt.Sprintf("mujica: EventColor %d out of range", c))
}
return eventPalette(k)[c]
}
// eventsCheck panics on events no view can place.
func eventsCheck(name string, events []CalendarEvent) {
for _, e := range events {
if e.ID == "" {
panic("mujica: " + name + " event without ID")
}
if !e.End.After(e.Start) {
panic(fmt.Sprintf("mujica: %s event %q ends before it starts", name, e.ID))
}
}
}
// eventsCache holds data derived from events overlapping a span.
type eventsCache[T any] struct {
first *CalendarEvent
n int
version int
from, to time.Time
value T
valid bool
}
// 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 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 {
if ev.Start.Before(to) && ev.End.After(from) {
hits = append(hits, i)
}
}
sort.SliceStable(hits, func(a, b int) bool {
x, y := events[hits[a]], events[hits[b]]
if !x.Start.Equal(y.Start) {
return x.Start.Before(y.Start)
}
return x.End.After(y.End)
})
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 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))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool { return spans[order[a]].from < spans[order[b]].from })
var ends []float64
var cluster []int
clusterEnd := -1.0
flush := func() {
for _, i := range cluster {
cols[i] = len(ends)
}
cluster, ends = cluster[:0], ends[:0]
}
for _, i := range order {
s := spans[i]
if len(cluster) > 0 && s.from >= clusterEnd {
flush()
}
placed := false
for c, end := range ends {
if end <= s.from {
ends[c], col[i], placed = s.to, c, true
break
}
}
if !placed {
col[i] = len(ends)
ends = append(ends, s.to)
}
cluster = append(cluster, i)
clusterEnd = max(clusterEnd, s.to)
}
flush()
return col, cols
}
// eventDayStart is d's first instant in loc.
func eventDayStart(d core.Date, loc *time.Location) time.Time {
return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, loc)
}
// eventTimeText formats an event's wall-clock span in loc.
func eventTimeText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) string {
if ev.AllDay {
return core.Msg(c, "event_chip.all_day")
}
cfg := timeConfig{hour12: hour12}
s, e := ev.Start.In(loc), ev.End.In(loc)
text := timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, cfg) + " – " + timeText(c, TimeOfDay{e.Hour(), e.Minute(), 0}, cfg)
return text
}
// 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(core.DateOf(s).AddDays(days), loc), eventDayStart(core.DateOf(e).AddDays(days), loc)
return ch, ""
}
day := core.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 core.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(core.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 {
panic("mujica: " + name + " needs a Location")
}
return loc
}
+249
View File
@@ -0,0 +1,249 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 core.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 := core.Tokens(c)
d := core.Density(c)
today := core.DatetimeToday(c, opts.Today)
first := CalendarFirstWeekday(c, opts.WeekStart)
start := core.Date{Year: month.Year, Month: month.Month, Day: 1}
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
label := opts.Label
if label == "" {
label = core.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() core.Date { return core.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 core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "calendar.prev_month")).Clicked() && !disabled() {
*month = YearMonth{start.AddDays(7).AddMonths(-1).Year, start.AddDays(7).AddMonths(-1).Month}
}
ui.Text(c, core.DatetimeMonthTitle(c, month.Year, month.Month)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).
TextColor(k.Text).Grow(1).TextAlign(ui.Center)
if core.IconAction(c, icons.Must("chevron-right"), core.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, core.Msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Grow(1).Basis(0).TextAlign(ui.Center).
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Label(core.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)
}
})
}
})
core.DatetimeError(c, *problem)
})
return r
}
// monthLayout buckets events per day, concurrent ones side by side.
func monthLayout(events []CalendarEvent, hits []int, start core.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 core.Date, month YearMonth, today core.Date, lay monthDay, events []CalendarEvent, maxRows int,
loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, more *core.Date, apply func(CalendarChange, string)) {
k := core.Tokens(c)
d := core.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) { core.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(core.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(core.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(core.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(core.Msg(c, "calendar_month_view.more"), hidden)).FontSize(core.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) {
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
_, wh := c.Size()
panel.Padding(d.Unit() * 2).Gap(d.Unit()).MinWidth(d.ControlHeight() * 6).MaxHeight(wh / 2)
ui.Text(c, core.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 = core.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 core.Date) int {
return int(day.Civil().Sub(core.DateOf(t).Civil()).Hours() / 24)
}
+173
View File
@@ -0,0 +1,173 @@
package datetime
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type monthProbe struct {
month YearMonth
events []CalendarEvent
opts CalendarMonthViewOptions
opened []string
changes []CalendarChange
days []core.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) {
core.Use(c, core.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) {
core.Use(c, core.Settings{})
if ch, ok := CalendarMonthView(c, &p.month, p.events, CalendarMonthViewOptions{Location: ny, Today: core.Date{Year: 2026, Month: time.March, Day: 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})
})
})
}
+167
View File
@@ -0,0 +1,167 @@
package datetime
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var calendarToday = core.Date{Year: 2026, Month: time.October, Day: 5}
// datetimeTestLabel is the en-US spoken name of a day cell.
func datetimeTestLabel(d core.Date) string {
return fmt.Sprintf("%v, %v %d, %d", d.Weekday(), d.Month, d.Day, d.Year)
}
func calendarTester(value *core.Date, opts CalendarOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
if Calendar(c, value, opts).Changed() {
*changes++
}
}, 400, 400)
}
func TestCalendarClickChoosesDay(t *testing.T) {
v, n := core.Date{}, 0
tt := calendarTester(&v, CalendarOptions{}, &n)
if !tt.HasText("October 2026") {
t.Fatalf("empty value should show today's month, got %v", tt.Texts())
}
if err := tt.Click("Monday, October 12, 2026"); err != nil {
t.Fatal(err)
}
if v != (core.Date{Year: 2026, Month: time.October, Day: 12}) || n != 1 {
t.Fatalf("value %v changes %d", v, n)
}
if err := tt.Click("Monday, October 12, 2026"); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("same day reported change again: %d", n)
}
}
func TestCalendarKeyboard(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 15}, 0
tt := calendarTester(&v, CalendarOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Thursday, October 15, 2026") {
t.Fatal("Tab should land on the chosen day")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Friday, October 23, 2026") {
t.Fatal("arrows should move the focus by day and week")
}
tt.Key(0, ui.KeyPageDown)
if !tt.HasText("November 2026") || !tt.Focused("Monday, November 23, 2026") {
t.Fatalf("PageDown should move a month: %v", tt.Texts()[:3])
}
tt.Key(ui.Shift, ui.KeyPageUp)
if !tt.HasText("November 2025") {
t.Fatal("Shift+PageUp should move a year")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2025, Month: time.November, Day: 30}) || n != 1 {
t.Fatalf("Enter chose %v (%d changes)", v, n)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeySpace)
if v != (core.Date{Year: 2025, Month: time.November, Day: 1}) || n != 2 {
t.Fatalf("Space chose %v", v)
}
}
func TestCalendarLimits(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 10}, 0
opts := CalendarOptions{
Min: core.Date{Year: 2026, Month: time.October, Day: 5},
Max: core.Date{Year: 2026, Month: time.October, Day: 20},
DisableDate: func(d core.Date) bool { return d.Weekday() == time.Sunday },
}
tt := calendarTester(&v, opts, &n)
for _, label := range []string{"Thursday, October 1, 2026", "Sunday, October 11, 2026", "Wednesday, October 21, 2026"} {
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
if v != (core.Date{Year: 2026, Month: time.October, Day: 10}) || n != 0 {
t.Fatalf("disabled days changed the value to %v", v)
}
if err := tt.Click("Saturday, October 10, 2026"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyRight)
if !tt.Focused("Monday, October 12, 2026") {
t.Fatal("arrows should skip the disabled Sunday")
}
}
func TestCalendarWeekStart(t *testing.T) {
for _, tc := range []struct {
ws WeekStart
loc core.Locale
first string
}{{WeekStartLocale, core.EnUS, "Sunday"}, {WeekStartLocale, core.ZhCN, "星期一"}, {WeekStartMonday, core.EnUS, "Monday"}, {WeekStartSaturday, core.EnUS, "Saturday"}} {
v := core.Date{Year: 2026, Month: time.October, Day: 5}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: tc.loc})
Calendar(c, &v, CalendarOptions{WeekStart: tc.ws, Today: calendarToday})
}, 400, 400)
texts := tt.Texts()
var first string
for i, s := range texts {
if s == "2026年10月" || s == "October 2026" {
first = texts[i+3]
break
}
}
if first != tc.first {
t.Errorf("%v %v: first column %q, want %q", tc.ws, tc.loc, first, tc.first)
}
}
}
func TestCalendarDisabled(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 10}, 0
tt := calendarTester(&v, CalendarOptions{Disabled: true}, &n)
_ = tt.Click("Monday, October 12, 2026")
_ = tt.Click("Next month")
if v != (core.Date{Year: 2026, Month: time.October, Day: 10}) || n != 0 || !tt.HasText("October 2026") {
t.Fatalf("disabled calendar changed: %v", v)
}
}
func TestCalendarRejectsInvalidValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic for February 30")
}
}()
v := core.Date{Year: 2026, Month: time.February, Day: 30}
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
Calendar(c, &v, CalendarOptions{})
}, 400, 400)
}
func TestCalendarSnapshot(t *testing.T) {
v := core.Date{Year: 2026, Month: time.October, Day: 14}
snapshot(t, "calendar", 320, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
Calendar(c, &v, CalendarOptions{Today: calendarToday, Max: core.Date{Year: 2026, Month: time.October, Day: 28},
DisableDate: func(d core.Date) bool { return d.Day == 20 }})
})
})
}
+370
View File
@@ -0,0 +1,370 @@
package datetime
import (
"fmt"
"log/slog"
"math"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// timeGridConfig is what the week and day views share.
type timeGridConfig struct {
name string
loc *time.Location
version int
slot int
today core.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 []core.Date, events []CalendarEvent, cfg timeGridConfig, r *CalendarViewResult) {
k := core.Tokens(c)
d := core.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 := core.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(core.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(core.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, core.Msg(c, "calendar_week_view.all_day")).Width(gutter).Shrink(0).FontSize(core.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(core.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)
}
})
})
core.DatetimeError(c, *problem)
}
// timeGridLayout places each day's events in minutes with side-by-side columns.
func timeGridLayout(events []CalendarEvent, hits []int, days []core.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 core.Date, lay timeGridDay, events []CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
g *timeGesture, cursor *int, r *CalendarViewResult, apply func(CalendarChange, string)) {
k := core.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(core.Msg(c, "calendar_week_view.column"), core.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)
core.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 == core.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(core.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 core.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 := core.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 := core.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{}, core.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 core.Date, m int, loc *time.Location) (time.Time, int) {
return dateTimeResolve(timeDay(day, m), timeWall(m), loc)
}
func timeDay(day core.Date, m int) core.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}
}
+81
View File
@@ -0,0 +1,81 @@
package datetime
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/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 core.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 *core.Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult {
core.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: core.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([]core.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(core.Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
root.Children(func() {
calendarViewHeader(c, title, core.Msg(c, "calendar_week_view.prev"), core.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 := core.Tokens(c)
ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit()).Children(func() {
if b := core.IconAction(c, icons.Must("chevron-left"), prev); b.Clicked() && !b.IsDisabled() {
step(-1)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).TextAlign(ui.Center)
if b := core.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 core.Date) string {
return fmt.Sprintf(core.Msg(c, "calendar_week_view.title"), core.DatetimeMonthName(c, a.Month), a.Day, core.DatetimeMonthName(c, b.Month), b.Day, b.Year)
}
+177
View File
@@ -0,0 +1,177 @@
package datetime
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type weekProbe struct {
week core.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) {
core.Use(c, core.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)})
})
})
}
+104
View File
@@ -0,0 +1,104 @@
package datetime
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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 := core.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 = core.Msg(c, "countdown.label")
}
text := CountdownText(c, shown, opts.Format)
e := ui.Text(c, text).Font(theme.MonoFont).FontSize(core.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(core.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(core.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(core.Msg(c, []string{"countdown.d", "countdown.h", "countdown.m", "countdown.s"}[i]), v)
}
}
return out
}
+71
View File
@@ -0,0 +1,71 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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) {
core.Use(c, core.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) {
core.Use(c, core.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) {
core.Use(c, core.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})
})
})
}
+348
View File
@@ -0,0 +1,348 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/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 := core.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 core.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) core.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 := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.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 := input.TextInput(c, expr, input.TextInputOptions{Label: core.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, core.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, core.Msg(c, "cron_editor.never")).TextColor(k.TextMuted)
}
for _, run := range list {
w := run.At.In(loc)
text := core.Msg(c, "calendar.weekday_"+strconv.Itoa(int(w.Weekday()))) + " " + core.DateOf(w).String() + " " +
timeText(c, TimeOfDay{w.Hour(), w.Minute(), 0}, timeConfig{hour12: opts.Hour12}) + " " + w.Format("MST")
if run.Repeated {
text += " · " + core.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 core.NewChoiceResult(col, 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 := core.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(core.Msg(c, "cron_editor.field_error"), core.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 := core.Tokens(c)
d := core.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, core.Msg(c, cronFieldKeys[f])).FontSize(core.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 = core.Msg(c, "cron_editor.invalid")
}
ui.Text(c, desc).FontSize(core.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 core.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(core.Msg(c, "cron_editor.every"), step))
case hasStep:
parts = append(parts, fmt.Sprintf(core.Msg(c, "cron_editor.every_in"), step, rng))
default:
parts = append(parts, rng)
}
}
return strings.Join(parts, ", ")
}
+138
View File
@@ -0,0 +1,138 @@
package datetime
import (
"errors"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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) {
core.Use(c, core.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) {
core.Use(c, core.Settings{Locale: core.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) {
core.Use(c, core.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)})
})
})
}
+52
View File
@@ -0,0 +1,52 @@
package datetime
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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 core.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 := core.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 = core.DateOf(wall)
}
core.DatetimeCheckDate("CurrentTimeIndicator Day", day)
e := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).PassThrough()
if core.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(core.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
}
+80
View File
@@ -0,0 +1,80 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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) {
core.Use(c, core.Settings{Mode: core.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) {
core.Use(c, core.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) {
core.Use(c, core.Settings{})
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{})
}, 10, 10)
}
func TestCurrentTimeIndicatorUsesClockWhileShown(t *testing.T) {
shown := true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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, core.Tokens(c).Border).Children(func() {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Now: now})
})
})
}
+171
View File
@@ -0,0 +1,171 @@
package datetime
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// DatePickerOptions configure a DatePicker.
type DatePickerOptions struct {
// Layout is the Go time layout typed and shown; empty uses 2006-01-02.
Layout string
// Min and Max bound the dates accepted; zero leaves a side open.
Min, Max core.Date
DisableDate func(core.Date) bool
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today core.Date
// Clearable shows a clear button and accepts empty text as no date.
Clearable bool
// Placeholder shows in the empty field; empty uses the built-in copy.
Placeholder string
Disabled bool
// ReadOnly keeps the text focusable and copyable but blocks edits and the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// DatePicker types or picks *value; invalid text is kept and reported.
func DatePicker(c *ui.Context, value *core.Date, opts DatePickerOptions) DateTimeResult {
core.DatetimeCheckDate("DatePicker value", *value)
core.DatetimeCheckBounds("DatePicker", opts.Min, opts.Max)
layout := opts.Layout
if layout == "" {
layout = "2006-01-02"
}
format := func(d core.Date) string {
if d.IsZero() {
return ""
}
return d.Format(layout)
}
d := core.Density(c)
today := core.DatetimeToday(c, opts.Today)
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
col := ui.Column(c).Width(d.ControlHeight() * 7).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
text := ui.Local(col, "date-picker-text", func() string { return format(*value) })
seen := ui.Local(col, "date-picker-seen", func() core.Date { return *value })
problem := ui.Local(col, "date-picker-error", func() string { return "" })
open := ui.Local(col, "date-picker-open", func() bool { return false })
cursor := ui.Local(col, "date-picker-cursor", func() core.Date { return core.Date{} })
wasFocused := ui.Local(col, "date-picker-focused", func() bool { return false })
shownLayout := ui.Local(col, "date-picker-layout", func() string { return layout })
if *shownLayout != layout {
if *text == core.Date.Format(*value, *shownLayout) || value.IsZero() && *text == "" {
*text = format(*value)
}
*shownLayout = layout
}
if *seen != *value {
*seen, *text, *problem = *value, format(*value), ""
}
changed := false
commit := func(to core.Date, how string) {
if to != *value {
slog.Debug("mujica: date_picker change", "from", *value, "to", to, "by", how)
*value = to
changed = true
}
*seen, *text, *problem = to, format(to), ""
}
submit := func() {
s := strings.TrimSpace(*text)
if s == "" && opts.Clearable {
commit(core.Date{}, "text")
return
}
t, err := time.Parse(layout, s)
if err != nil {
*problem = fmt.Sprintf(core.Msg(c, "date_picker.invalid"), format(today))
slog.Debug("mujica: date_picker invalid", "text", s)
return
}
if p := datePickerProblem(c, core.DateOf(t), opts.Min, opts.Max, enabled, format); p != "" {
*problem = p
return
}
commit(core.DateOf(t), "text")
}
openAt := func() {
*open = true
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
placeholder := opts.Placeholder
if placeholder == "" {
placeholder = core.Msg(c, "date_picker.placeholder")
}
var field, inputEl *ui.Element
col.Children(func() {
field = core.DatetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
field.Children(func() {
in := input.TextInputEditor(c, field, text, opts.ReadOnly, ui.TextInputBase).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
inputEl = in
label := opts.Label
if label == "" {
label = placeholder
}
in.Label(label).Error(*problem)
focused := in.Focused()
if !opts.ReadOnly && !in.IsDisabled() && (in.Submitted() || *wasFocused && !focused && *text != format(*value)) {
submit()
}
*wasFocused = focused
if opts.ReadOnly {
return
}
if opts.Clearable && !value.IsZero() && core.IconAction(c, icons.Must("x"), core.Msg(c, "calendar.clear")).Clicked() {
commit(core.Date{}, "clear")
}
if core.IconAction(c, icons.Must("calendar"), core.Msg(c, "date_picker.open")).Role(ui.RolePopUpButton).Clicked() {
if *open {
*open = false
} else {
openAt()
}
}
})
if !*open && !opts.ReadOnly && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
openAt()
}
core.DatetimePopup(c, field, open, opts.ReadOnly, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x core.Date) bool { return x == *value },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if !picked.IsZero() {
commit(picked, "calendar")
*open = false
}
})
core.DatetimeError(c, *problem)
})
return DateTimeResult{Element: col, Input: inputEl, changed: changed}
}
// datePickerProblem explains why an existing day d cannot be chosen, or returns "".
func datePickerProblem(c *ui.Context, d core.Date, lo, hi core.Date, enabled func(core.Date) bool, format func(core.Date) string) string {
switch {
case !lo.IsZero() && d.Compare(lo) < 0:
return fmt.Sprintf(core.Msg(c, "calendar.before_min"), format(lo))
case !hi.IsZero() && d.Compare(hi) > 0:
return fmt.Sprintf(core.Msg(c, "calendar.after_max"), format(hi))
case !enabled(d):
return fmt.Sprintf(core.Msg(c, "calendar.unavailable"), format(d))
}
return ""
}
+155
View File
@@ -0,0 +1,155 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func datePickerTester(value *core.Date, opts DatePickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Gap(8).Children(func() {
if DatePicker(c, value, opts).Changed() {
*changes++
}
input.Button(c, "Elsewhere", input.ButtonOptions{})
})
}, 420, 460)
}
func datePickerType(tt *ui.Tester, label, text string) {
_ = tt.Click(label)
tt.Command("selectAll")
tt.Type(text)
}
func TestDatePickerTyping(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due"}, &n)
datePickerType(tt, "Due", "2026-11-03")
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.November, Day: 3}) || n != 1 {
t.Fatalf("Enter committed %v (%d)", v, n)
}
datePickerType(tt, "Due", "2026-12-24")
if err := tt.Click("Elsewhere"); err != nil {
t.Fatal(err)
}
if v != (core.Date{Year: 2026, Month: time.December, Day: 24}) || n != 2 {
t.Fatalf("blur committed %v (%d)", v, n)
}
}
func TestDatePickerInvalidInputKept(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Max: core.Date{Year: 2026, Month: time.December, Day: 31}}, &n)
datePickerType(tt, "Due", "2026-02-30")
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.October, Day: 5}) || n != 0 {
t.Fatalf("invalid day was corrected into %v", v)
}
if !tt.HasText("Enter a date like 2026-10-05.") {
t.Fatalf("expected an error: %v", tt.Texts())
}
// The typed text stays: fixing its day makes it valid.
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
tt.Type("28")
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.February, Day: 28}) || n != 1 || tt.HasText("Enter a date like") {
t.Fatalf("edited text committed %v (%d)", v, n)
}
datePickerType(tt, "Due", "2027-01-02")
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.February, Day: 28}) || !tt.HasText("Choose 2026-12-31 or earlier.") {
t.Fatalf("out of range accepted: %v %v", v, tt.Texts())
}
datePickerType(tt, "Due", "")
tt.Key(0, ui.KeyEnter)
if v.IsZero() {
t.Fatal("empty text cleared a non-clearable picker")
}
}
func TestDatePickerCalendarPopup(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due"}, &n)
if err := tt.Click("Open calendar"); err != nil {
t.Fatal(err)
}
if !tt.Focused("Monday, October 5, 2026") {
t.Fatal("the popup should focus the chosen day")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.October, Day: 6}) || n != 1 || tt.HasText("October 2026") {
t.Fatalf("keyboard pick %v, popup open %v", v, tt.HasText("October 2026"))
}
_ = tt.Click("Due")
tt.Key(0, ui.KeyDown)
if !tt.HasText("October 2026") {
t.Fatal("Down should open the calendar")
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("October 2026") || v != (core.Date{Year: 2026, Month: time.October, Day: 6}) {
t.Fatal("Escape should close without change")
}
_ = tt.Click("Open calendar")
if err := tt.Click("Thursday, October 22, 2026"); err != nil {
t.Fatal(err)
}
if v != (core.Date{Year: 2026, Month: time.October, Day: 22}) || n != 2 {
t.Fatalf("click pick %v", v)
}
}
func TestDatePickerClearAndLayout(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Clearable: true, Layout: "02/01/2006"}, &n)
_ = tt.Click("Due")
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.October, Day: 5}) || n != 0 || tt.HasText("Enter a date like") {
t.Fatalf("shown text did not parse back with the layout: %v", tt.Texts())
}
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if !v.IsZero() || n != 1 {
t.Fatalf("clear left %v", v)
}
datePickerType(tt, "Due", "31/12/2026")
tt.Key(0, ui.KeyEnter)
if v != (core.Date{Year: 2026, Month: time.December, Day: 31}) {
t.Fatalf("custom layout parsed %v", v)
}
}
func TestDatePickerDisabled(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n)
_ = tt.Click("Open calendar")
if tt.HasText("October 2026") {
t.Fatal("disabled picker opened")
}
}
func TestDatePickerSnapshot(t *testing.T) {
a, b, e := core.Date{Year: 2026, Month: time.October, Day: 14}, core.Date{}, core.Date{Year: 2026, Month: time.October, Day: 5}
snapshot(t, "date_picker", 640, 130, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).AlignItems(ui.Start).Children(func() {
ui.Column(c).Gap(12).Width(260).Children(func() {
DatePicker(c, &b, DatePickerOptions{Today: calendarToday})
DatePicker(c, &e, DatePickerOptions{Today: calendarToday, Disabled: true})
})
ui.Column(c).Width(300).Children(func() {
DatePicker(c, &a, DatePickerOptions{Today: calendarToday, Clearable: true, Label: "Due"})
})
})
})
}
+201
View File
@@ -0,0 +1,201 @@
package datetime
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// DateRange is an inclusive span of days. The zero value means no range.
type DateRange struct {
Start, End core.Date
}
// IsZero reports whether r is the empty range.
func (r DateRange) IsZero() bool { return r == DateRange{} }
// Days returns how many days r covers, counting both ends.
func (r DateRange) Days() int {
if r.Start.IsZero() || r.End.IsZero() {
return 0
}
return int(r.End.Civil().Sub(r.Start.Civil()).Hours()/24) + 1
}
// DateRangePickerOptions configure a DateRangePicker.
type DateRangePickerOptions struct {
// Layout is the Go time layout shown; empty uses 2006-01-02.
Layout string
// Min and Max bound both ends; zero leaves a side open.
Min, Max core.Date
// DisableDate marks days that neither end nor the span may include.
DisableDate func(core.Date) bool
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today core.Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// DateRangePicker picks start and end; Confirm writes *value.
func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult {
core.DatetimeCheckDate("DateRangePicker Start", value.Start)
core.DatetimeCheckDate("DateRangePicker End", value.End)
if value.Start.IsZero() != value.End.IsZero() || !value.IsZero() && value.End.Compare(value.Start) < 0 {
panic(fmt.Sprintf("mujica: DateRangePicker value %v..%v is not a range", value.Start, value.End))
}
core.DatetimeCheckBounds("DateRangePicker", opts.Min, opts.Max)
layout := opts.Layout
if layout == "" {
layout = "2006-01-02"
}
d := core.Density(c)
k := core.Tokens(c)
today := core.DatetimeToday(c, opts.Today)
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "date-range-open", func() bool { return false })
wasOpen := ui.Local(col, "date-range-was-open", func() bool { return false })
draft := ui.Local(col, "date-range-draft", func() DateRange { return DateRange{} })
cursor := ui.Local(col, "date-range-cursor", func() core.Date { return core.Date{} })
hover := ui.Local(col, "date-range-hover", func() core.Date { return core.Date{} })
problem := ui.Local(col, "date-range-error", func() string { return "" })
changed, canceled, done := false, false, false
finish := func(to DateRange, how string) {
if to != *value {
slog.Debug("mujica: date_range_picker change", "from", *value, "to", to, "by", how)
*value = to
changed = true
}
*open, done = false, true
}
label := opts.Label
if label == "" {
label = core.Msg(c, "date_range_picker.label")
}
text := ""
if !value.IsZero() {
text = value.Start.Format(layout) + " – " + value.End.Format(layout)
}
var field *ui.Element
col.Children(func() {
field = core.DatetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
field.Children(func() {
t := core.DatetimeTrigger(c, text, core.Msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem)
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open = !*open
if *open {
*draft, *problem, *hover = *value, "", core.Date{}
*cursor = value.Start
if cursor.IsZero() {
*cursor = today
}
slog.Debug("mujica: date_range_picker open")
}
}
})
core.DatetimePopup(c, field, open, opts.ReadOnly, func() {
ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted).
FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").Role(ui.RoleStatus)
end := draft.End
if end.IsZero() && !draft.Start.IsZero() {
end = *hover
if end.IsZero() {
end = *cursor
}
}
picked, hovered := calendarMonth(c, cursor, calendarConfig{
chosen: func(x core.Date) bool { return x == draft.Start || x == draft.End },
inRange: func(x core.Date) bool { return !draft.Start.IsZero() && x.Within(draft.Start, end) },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if hovered != *hover {
*hover = hovered
c.Invalidate()
}
if !picked.IsZero() {
*problem = dateRangePick(c, draft, picked, enabled, layout)
}
core.DatetimeError(c, *problem)
core.DatetimeFooter(c, func() {
if input.Button(c, core.Msg(c, "calendar.clear"), input.ButtonOptions{Variant: input.Ghost, Disabled: value.IsZero() && draft.IsZero()}).Clicked() {
finish(DateRange{}, "clear")
}
if input.Button(c, core.Msg(c, "calendar.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
*open = false
}
ready := !draft.Start.IsZero() && !draft.End.IsZero()
if input.Button(c, core.Msg(c, "calendar.confirm"), input.ButtonOptions{Disabled: !ready}).Clicked() {
if p := dateRangeProblem(c, *draft, opts.Min, opts.Max, enabled, layout); p != "" {
slog.Debug("mujica: date_range_picker invalid confirm", "draft", *draft)
*problem = p
} else {
finish(*draft, "confirm")
}
}
})
})
})
if *wasOpen && !*open && !done {
slog.Debug("mujica: date_range_picker cancel")
canceled = true
}
*wasOpen = *open
if !*open {
*problem = ""
}
return DateTimeResult{Element: col, changed: changed, canceled: canceled}
}
// dateRangeProblem checks a whole draft against the current bounds and filter.
func dateRangeProblem(c *ui.Context, r DateRange, lo, hi core.Date, enabled func(core.Date) bool, layout string) string {
format := func(d core.Date) string { return d.Format(layout) }
for _, d := range []core.Date{r.Start, r.End} {
if p := datePickerProblem(c, d, lo, hi, enabled, format); p != "" {
return p
}
}
for x := r.Start; x.Compare(r.End) <= 0; x = x.AddDays(1) {
if !enabled(x) {
return fmt.Sprintf(core.Msg(c, "date_range_picker.blocked"), format(x))
}
}
return ""
}
// dateRangePick applies a picked day to the draft and returns any problem.
func dateRangePick(c *ui.Context, draft *DateRange, day core.Date, enabled func(core.Date) bool, layout string) string {
if draft.Start.IsZero() || !draft.End.IsZero() || day.Compare(draft.Start) < 0 {
*draft = DateRange{Start: day}
return ""
}
for x := draft.Start; x.Compare(day) <= 0; x = x.AddDays(1) {
if !enabled(x) {
return fmt.Sprintf(core.Msg(c, "date_range_picker.blocked"), x.Format(layout))
}
}
draft.End = day
return ""
}
// dateRangeSummary describes the draft: what to pick next, or the span chosen.
func dateRangeSummary(c *ui.Context, r DateRange, layout string) string {
switch {
case r.Start.IsZero():
return core.Msg(c, "date_range_picker.pick_start")
case r.End.IsZero():
return fmt.Sprintf(core.Msg(c, "date_range_picker.pick_end"), r.Start.Format(layout))
}
return fmt.Sprintf(core.Msg(c, "date_range_picker.summary"), r.Start.Format(layout), r.End.Format(layout), r.Days())
}
+140
View File
@@ -0,0 +1,140 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func dateRangeTester(value *DateRange, opts DateRangePickerOptions, changes, cancels *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Stay"
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Children(func() {
r := DateRangePicker(c, value, opts)
if r.Changed() {
*changes++
}
if r.Canceled() {
*cancels++
}
})
}, 420, 520)
}
func oct(day int) core.Date { return core.Date{Year: 2026, Month: time.October, Day: day} }
func TestDateRangePickerPickAndConfirm(t *testing.T) {
v, n, x := DateRange{}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
if err := tt.Click("Stay"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Choose the start date.") {
t.Fatalf("popup missing: %v", tt.Texts())
}
_ = tt.Click(datetimeTestLabel(oct(12)))
if !tt.HasText("From 2026-10-12, choose the end date.") {
t.Fatal("start not taken")
}
_ = tt.Click(datetimeTestLabel(oct(8)))
if !tt.HasText("From 2026-10-08, choose the end date.") {
t.Fatal("a day before the start should restart the range")
}
_ = tt.Click(datetimeTestLabel(oct(14)))
if !tt.HasText("2026-10-08 – 2026-10-14 · 7 days") || n != 0 {
t.Fatalf("draft should wait for Confirm: %v %d", v, n)
}
if err := tt.Click("Confirm"); err != nil {
t.Fatal(err)
}
if v != (DateRange{oct(8), oct(14)}) || n != 1 || x != 0 || tt.HasText("Confirm") {
t.Fatalf("confirm gave %v, n=%d x=%d", v, n, x)
}
}
func TestDateRangePickerCancelKeepsValue(t *testing.T) {
v, n, x := DateRange{oct(1), oct(3)}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(20)))
tt.Key(0, ui.KeyEscape)
if v != (DateRange{oct(1), oct(3)}) || n != 0 || x != 1 {
t.Fatalf("Escape: %v n=%d x=%d", v, n, x)
}
_ = tt.Click("Stay")
_ = tt.Click("Cancel")
if x != 2 || tt.HasText("Confirm") {
t.Fatalf("Cancel: x=%d", x)
}
_ = tt.Click("Stay")
_ = tt.Click("Clear")
if !v.IsZero() || n != 1 || x != 2 {
t.Fatalf("Clear: %v n=%d x=%d", v, n, x)
}
}
func TestDateRangePickerLimits(t *testing.T) {
v, n, x := DateRange{}, 0, 0
opts := DateRangePickerOptions{Min: oct(5), DisableDate: func(d core.Date) bool { return d == oct(16) }}
tt := dateRangeTester(&v, opts, &n, &x)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(2)))
if !tt.HasText("Choose the start date.") {
t.Fatal("a day before Min was taken")
}
_ = tt.Click(datetimeTestLabel(oct(14)))
_ = tt.Click(datetimeTestLabel(oct(18)))
if !tt.HasText("The range cannot include 2026-10-16.") {
t.Fatalf("span over a disabled day accepted: %v", tt.Texts())
}
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() {
t.Fatal("confirm without an end changed the value")
}
_ = tt.Click(datetimeTestLabel(oct(15)))
_ = tt.Click("Confirm")
if v != (DateRange{oct(14), oct(15)}) {
t.Fatalf("got %v", v)
}
}
func TestDateRangePickerKeyboard(t *testing.T) {
v, n, x := DateRange{}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused(datetimeTestLabel(calendarToday)) {
t.Fatal("the popup should focus today")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if !tt.HasText("From 2026-10-05, choose the end date.") {
t.Fatal("Enter should pick the start")
}
tt.Key(0, ui.KeySpace)
if !tt.HasText("2026-10-05 – 2026-10-12 · 8 days") {
t.Fatalf("Space should pick the end: %v", tt.Texts())
}
}
func TestDateRangeDays(t *testing.T) {
r := DateRange{core.Date{Year: 2026, Month: time.March, Day: 28}, core.Date{Year: 2026, Month: time.April, Day: 2}}
if r.Days() != 6 || (DateRange{}).Days() != 0 {
t.Fatalf("days %d", r.Days())
}
}
func TestDateRangePickerSnapshot(t *testing.T) {
a, b := DateRange{oct(8), oct(14)}, DateRange{}
snapshot(t, "date_range_picker", 640, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
DateRangePicker(c, &b, DateRangePickerOptions{Today: calendarToday})
DateRangePicker(c, &a, DateRangePickerOptions{Today: calendarToday})
})
})
}
+202
View File
@@ -0,0 +1,202 @@
package datetime
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// DateTimePickerOptions configure a DateTimePicker.
type DateTimePickerOptions struct {
// Location is the time zone the user edits in; required.
Location *time.Location
// Layout is the Go layout of the date part; empty uses 2006-01-02.
Layout string
// Min and Max bound the instant; zero leaves a side open.
Min, Max time.Time
Seconds bool
Hour12 bool
MinuteStep int
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today core.Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// DateTimePicker edits *value as wall time in opts.Location.
func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult {
loc := opts.Location
if loc == nil {
panic("mujica: DateTimePicker needs a Location")
}
if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.After(opts.Max) {
panic(fmt.Sprintf("mujica: DateTimePicker Min %v is after Max %v", opts.Min, opts.Max))
}
cfg := timePickerConfig("DateTimePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12, MinuteStep: opts.MinuteStep})
cfg.label = core.Msg(c, "date_time_picker.time")
k := core.Tokens(c)
d := core.Density(c)
today := core.DatetimeToday(c, opts.Today)
var lo, hi core.Date
if !opts.Min.IsZero() {
lo = core.DateOf(opts.Min.In(loc))
}
if !opts.Max.IsZero() {
hi = core.DateOf(opts.Max.In(loc))
}
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "date-time-open", func() bool { return false })
wasOpen := ui.Local(col, "date-time-was-open", func() bool { return false })
day := ui.Local(col, "date-time-day", func() core.Date { return core.Date{} })
clock := ui.Local(col, "date-time-clock", func() TimeOfDay { return TimeOfDay{} })
cursor := ui.Local(col, "date-time-cursor", func() core.Date { return core.Date{} })
problem := ui.Local(col, "date-time-error", func() string { return "" })
typedBad := ui.Local(col, "date-time-typed-bad", func() bool { return false })
if opts.Layout == "" {
opts.Layout = "2006-01-02"
}
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), opts.Layout, cfg) }
changed, canceled, done := false, false, false
label := opts.Label
if label == "" {
label = core.Msg(c, "date_time_picker.placeholder")
}
text := ""
if !value.IsZero() {
text = format(*value)
}
col.Children(func() {
field := core.DatetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
field.Children(func() {
t := core.DatetimeTrigger(c, text, core.Msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem)
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open = !*open
if *open {
*day, *clock, *problem = today, TimeOfDay{}, ""
if !value.IsZero() {
w := value.In(loc)
*day, *clock = core.DateOf(w), TimeOfDay{w.Hour(), w.Minute(), w.Second()}
}
*cursor = *day
slog.Debug("mujica: date_time_picker open")
}
}
})
core.DatetimePopup(c, field, open, opts.ReadOnly, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x core.Date) bool { return x == *day },
enabled: calendarEnabled(lo, hi, nil),
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if !picked.IsZero() {
*day, *problem = picked, ""
}
bad := ""
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
cfg.problem = *problem
_, edited, b := timeEditor(c, clock, cfg)
switch {
case b != "":
*problem, *typedBad, bad = b, true, b
case edited || *typedBad:
*problem, *typedBad = "", false
}
ui.Text(c, loc.String()).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
core.DatetimeError(c, *problem)
core.DatetimeFooter(c, func() {
if input.Button(c, core.Msg(c, "calendar.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
*open = false
}
if input.Button(c, core.Msg(c, "calendar.confirm"), input.ButtonOptions{}).Clicked() {
at, p := dateTimeConfirm(c, *day, *clock, loc, opts, cfg)
if bad != "" {
p = bad
}
*problem = p
if p == "" {
if !at.Equal(*value) {
slog.Debug("mujica: date_time_picker change", "from", *value, "to", at)
*value, changed = at, true
}
*open, done = false, true
}
}
})
})
})
if *wasOpen && !*open && !done {
slog.Debug("mujica: date_time_picker cancel")
canceled = true
}
*wasOpen = *open
if !*open {
*problem = ""
}
return DateTimeResult{Element: col, changed: changed, canceled: canceled}
}
// dateTimeConfirm turns the draft into an instant, or explains why it cannot.
func dateTimeConfirm(c *ui.Context, d core.Date, t TimeOfDay, loc *time.Location, opts DateTimePickerOptions, cfg timeConfig) (time.Time, string) {
if p := timePickerProblem(c, t, cfg); p != "" {
return time.Time{}, p
}
wall := d.Format(opts.Layout) + " " + timeText(c, t, cfg)
at, n := dateTimeResolve(d, t, loc)
switch {
case n == 0:
slog.Debug("mujica: date_time_picker gap", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(core.Msg(c, "date_time_picker.gap"), wall, loc.String())
case n > 1:
slog.Debug("mujica: date_time_picker ambiguous", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(core.Msg(c, "date_time_picker.ambiguous"), wall, loc.String())
case !opts.Min.IsZero() && at.Before(opts.Min):
return at, fmt.Sprintf(core.Msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), opts.Layout, cfg))
case !opts.Max.IsZero() && at.After(opts.Max):
return at, fmt.Sprintf(core.Msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), opts.Layout, cfg))
}
return at, ""
}
// dateTimeResolve counts instants for a wall time: 0 gap, 2 overlap.
func dateTimeResolve(d core.Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
wall := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, time.UTC)
guess := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, loc)
var hits []time.Time
for _, probe := range []time.Time{guess.Add(-24 * time.Hour), guess, guess.Add(24 * time.Hour)} {
_, off := probe.Zone()
u := wall.Add(-time.Duration(off) * time.Second).In(loc)
y, m, dd := u.Date()
if (core.Date{Year: y, Month: m, Day: dd}) != d || u.Hour() != t.Hour || u.Minute() != t.Minute || u.Second() != t.Second {
continue
}
seen := false
for _, h := range hits {
seen = seen || h.Equal(u)
}
if !seen {
hits = append(hits, u)
}
}
if len(hits) == 0 {
return time.Time{}, 0
}
return hits[0], len(hits)
}
// dateTimeText formats a wall time with the picker's date layout and clock style.
func dateTimeText(c *ui.Context, w time.Time, layout string, cfg timeConfig) string {
return w.Format(layout) + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
}
+154
View File
@@ -0,0 +1,154 @@
package datetime
import (
"testing"
"time"
_ "time/tzdata"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func dateTimeTester(t *testing.T, value *time.Time, opts DateTimePickerOptions, changes, cancels *int) *ui.Tester {
t.Helper()
opts.Today = calendarToday
opts.Label = "Meeting"
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Children(func() {
r := DateTimePicker(c, value, opts)
if r.Changed() {
*changes++
}
if r.Canceled() {
*cancels++
}
})
}, 480, 640)
}
func newYork(t *testing.T) *time.Location {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
return loc
}
func TestDateTimeResolve(t *testing.T) {
ny := newYork(t)
if _, n := dateTimeResolve(core.Date{Year: 2026, Month: time.March, Day: 8}, TimeOfDay{2, 30, 0}, ny); n != 0 {
t.Fatalf("spring-forward gap gave %d instants", n)
}
if _, n := dateTimeResolve(core.Date{Year: 2026, Month: time.November, Day: 1}, TimeOfDay{1, 30, 0}, ny); n != 2 {
t.Fatalf("fall-back overlap gave %d instants", n)
}
at, n := dateTimeResolve(core.Date{Year: 2026, Month: time.July, Day: 4}, TimeOfDay{12, 0, 0}, ny)
if n != 1 || !at.Equal(time.Date(2026, time.July, 4, 16, 0, 0, 0, time.UTC)) {
t.Fatalf("plain time %v (%d)", at, n)
}
}
func TestDateTimePickerConfirmInLocation(t *testing.T) {
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
t.Fatal(err)
}
v := time.Date(2026, time.October, 5, 23, 30, 0, 0, time.UTC) // 08:30 on Oct 6 in Tokyo
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: tokyo}, &n, &x)
if !tt.HasText("2026-10-06 08:30 JST") {
t.Fatalf("value should show in the caller's zone: %v", tt.Texts())
}
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(9)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.KeyUp)
if n != 0 {
t.Fatal("the draft should wait for Confirm")
}
_ = tt.Click("Confirm")
want := time.Date(2026, time.October, 9, 9, 30, 0, 0, tokyo)
if !v.Equal(want) || n != 1 || x != 0 || v.Location() != tokyo {
t.Fatalf("confirmed %v, want %v", v, want)
}
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(20)))
_ = tt.Click("Cancel")
if !v.Equal(want) || x != 1 {
t.Fatalf("cancel changed %v", v)
}
}
func TestDateTimePickerReportsDSTGap(t *testing.T) {
ny := newYork(t)
v := time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny, Today: core.Date{Year: 2026, Month: time.March, Day: 1}}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Sunday, March 8, 2026")
_ = tt.Click("Confirm")
if n != 0 || !v.Equal(time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)) {
t.Fatalf("gap time was shifted into %v", v)
}
if !tt.HasText("2026-03-08 02:30 does not exist in America/New_York: the clocks skip it.") {
t.Fatalf("missing gap error: %v", tt.Texts())
}
_ = tt.Click("Time: Hours")
tt.Key(0, ui.KeyUp)
_ = tt.Click("Confirm")
if n != 1 || !v.Equal(time.Date(2026, time.March, 8, 3, 30, 0, 0, ny)) {
t.Fatalf("after fixing: %v", v)
}
}
func TestDateTimePickerReportsAmbiguous(t *testing.T) {
ny := newYork(t)
v := time.Date(2026, time.October, 31, 1, 30, 0, 0, ny)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Sunday, November 1, 2026")
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("2026-11-01 01:30 happens twice in America/New_York: the clocks repeat it.") {
t.Fatalf("ambiguous time accepted: %v %v", v, tt.Texts())
}
}
func TestDateTimePickerRange(t *testing.T) {
v := time.Time{}
n, x := 0, 0
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(11)))
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key3)
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() || !tt.HasText("Choose 2026-10-10 12:00 UTC or earlier.") {
t.Fatalf("past Max accepted: %v %v", v, tt.Texts())
}
}
func TestDateTimePickerNeedsLocation(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic without Location")
}
}()
v := time.Time{}
dateTimeTester(t, &v, DateTimePickerOptions{}, new(int), new(int))
}
func TestDateTimePickerSnapshot(t *testing.T) {
a, b := time.Date(2026, time.October, 14, 18, 45, 0, 0, time.UTC), time.Time{}
snapshot(t, "date_time_picker", 720, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
DateTimePicker(c, &b, DateTimePickerOptions{Location: time.UTC, Today: calendarToday})
DateTimePicker(c, &a, DateTimePickerOptions{Location: time.UTC, Today: calendarToday, Hour12: true})
})
})
}
+199
View File
@@ -0,0 +1,199 @@
package datetime
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// datetimeGridConfig describes a page of month or year cells, addressed by ordinal.
type datetimeGridConfig struct {
// pageOf returns the first ordinal and count of the page holding o.
pageOf func(o int) (start, n int)
cols int
page int
title func(start int) string
prev string
next string
text func(o int) string
label func(o int) string
chosen func(o int) bool
current func(o int) bool
enabled func(o int) bool
// muted greys cells of the page that belong to a neighbor page.
muted func(o, start int) bool
autoFocus bool
}
// datetimeGrid draws the page of *cursor and returns the ordinal picked, if any.
func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked int, ok bool) {
k := core.Tokens(c)
d := core.Density(c)
root := ui.Column(c).Gap(d.Unit() * 2).Shrink(0).Width(d.ControlHeight() * 7)
keyed := ui.Local(root, "datetime-grid-keys", func() bool { return false })
root.Children(func() {
start, _ := cfg.pageOf(*cursor)
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).
PaddingY(d.Unit()).Children(func() {
if core.IconAction(c, icons.Must("chevron-left"), cfg.prev).Clicked() {
*cursor, *keyed = *cursor-cfg.page, false
}
ui.Text(c, cfg.title(start)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.BodySize+2)).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if core.IconAction(c, icons.Must("chevron-right"), cfg.next).Clicked() {
*cursor, *keyed = *cursor+cfg.page, false
}
})
cells := ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Horizontal | ui.Vertical)
moved := true
switch {
case cells.Shortcut(0, ui.KeyLeft):
*cursor--
case cells.Shortcut(0, ui.KeyRight):
*cursor++
case cells.Shortcut(0, ui.KeyUp):
*cursor -= cfg.cols
case cells.Shortcut(0, ui.KeyDown):
*cursor += cfg.cols
case cells.Shortcut(0, ui.KeyPageUp):
*cursor -= cfg.page
case cells.Shortcut(0, ui.KeyPageDown):
*cursor += cfg.page
case cells.Shortcut(0, ui.KeyHome):
*cursor, _ = datetimeGridBounds(*cursor, cfg)
case cells.Shortcut(0, ui.KeyEnd):
_, *cursor = datetimeGridBounds(*cursor, cfg)
default:
moved = false
}
if moved {
*keyed = true
}
start, n := cfg.pageOf(*cursor)
w := d.ControlHeight() * 7 / float32(cfg.cols)
cells.Children(func() {
for r := 0; r < n; r += cfg.cols {
ui.Row(c).Children(func() {
for o := start + r; o < start+min(r+cfg.cols, n); o++ {
if datetimeGridCell(c, o, start, *cursor, cfg, w, moved, *keyed) {
picked, ok = o, true
*cursor, *keyed = o, false
}
}
})
}
})
})
return picked, ok
}
// datetimeGridCell draws one month or year cell and reports whether it was picked.
func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfig, w float32, focus, keyed bool) bool {
k := core.Tokens(c)
d := core.Density(c)
enabled, chosen := cfg.enabled(o), cfg.chosen(o)
sel := o - 1
if chosen {
sel = o
}
cell := ui.RadioBase(c, &sel, o).Size(w, d.ControlHeight()).Center().Radius(theme.ControlRadius).
Label(cfg.label(o)).Disabled(!enabled).FocusRing(false)
if o == cursor && enabled {
if focus {
cell.Focus()
} else if cfg.autoFocus {
cell.AutoFocus()
}
}
fg := k.Text
if cfg.muted(o, start) {
fg = k.TextMuted
}
switch {
case chosen:
cell.Background(k.Accent)
fg = k.OnAccent
if cell.Hovered() {
cell.Background(k.AccentHover)
}
case cell.Pressed():
cell.Background(k.Selection)
case cell.Hovered():
cell.Background(k.SurfaceHover)
}
current := cfg.current(o)
ring := cell.FocusVisible() || cell.Focused() && keyed
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if chosen {
core.DatetimeSelectedMark(p, r, k.OnAccent)
}
if current {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-6, 5), fg)
}
if ring {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
cell.Children(func() {
t := ui.Text(c, cfg.text(o)).TextColor(fg).FontFeatures("tnum").SingleLine()
if current {
t.Bold()
}
if !enabled {
t.Strikethrough()
}
})
return cell.Clicked() && enabled
}
// datetimeGridBounds returns the page's own first and last ordinals.
func datetimeGridBounds(o int, cfg datetimeGridConfig) (first, last int) {
start, n := cfg.pageOf(o)
first, last = start, start+n-1
for first < last && cfg.muted(first, start) {
first++
}
for last > first && cfg.muted(last, start) {
last--
}
return first, last
}
// datetimeGridField is the field and popup shared by MonthPicker and YearPicker.
type datetimeGridField struct {
text, placeholder, label string
clearable, hasValue bool
disabled, readOnly bool
// home is the ordinal the popup opens on.
home int
}
// datetimeGridPicker draws f and its popup; reports a pick or clear.
func datetimeGridPicker(c *ui.Context, f datetimeGridField, cfg datetimeGridConfig) (col *ui.Element, picked int, ok, cleared bool) {
d := core.Density(c)
col = ui.Column(c).Width(d.ControlHeight() * 6).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(f.disabled)
open := ui.Local(col, "datetime-grid-open", func() bool { return false })
cursor := ui.Local(col, "datetime-grid-cursor", func() int { return 0 })
col.Children(func() {
field := core.DatetimeField(c, ui.Row(c), false, f.readOnly)
field.Children(func() {
t := core.DatetimeTrigger(c, f.text, f.placeholder, "calendar").Label(f.label).Description(f.text)
if !f.readOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open, *cursor = !*open, f.home
}
if f.clearable && f.hasValue && !f.readOnly && core.IconAction(c, icons.Must("x"), core.Msg(c, "calendar.clear")).Clicked() {
cleared = true
}
})
core.DatetimePopup(c, field, open, f.readOnly, func() {
cfg.autoFocus = true
if picked, ok = datetimeGrid(c, cursor, cfg); ok {
*open = false
}
})
})
return col, picked, ok, cleared
}
+25
View File
@@ -0,0 +1,25 @@
package datetime
import (
"github.com/egoist/mygo/ui"
)
// DateTimeResult is a date or time component and what happened to it this frame.
type DateTimeResult struct {
Element *ui.Element
// Input is the text field inside, for labels and focus; nil without one.
Input *ui.Element
changed bool
canceled bool
}
// Changed reports whether the component wrote a new value this frame.
func (r DateTimeResult) Changed() bool { return r.changed }
// Canceled reports whether the user discarded a pending edit this frame.
func (r DateTimeResult) Canceled() bool { return r.canceled }
// NewDateTimeResult reports a date or time control built from parts in another component.
func NewDateTimeResult(e, input *ui.Element, changed, canceled bool) DateTimeResult {
return DateTimeResult{e, input, changed, canceled}
}
+223
View File
@@ -0,0 +1,223 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestTimePickerDisabledIgnoresSegmentKeys(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
opts := TimePickerOptions{Label: "Alarm"}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
if TimePicker(c, &v, opts).Changed() {
n++
}
}, 420, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Alarm: Hours") {
t.Fatal("setup: hours not focused")
}
opts.Disabled = true
tt.Frame()
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{9, 30, 0}) || n != 0 {
t.Fatalf("disabled picker took keys: %v (%d)", v, n)
}
}
func TestTimeRangePickerDisabledIgnoresSegmentKeys(t *testing.T) {
v, n := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}, 0
opts := TimeRangePickerOptions{}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
if TimeRangePicker(c, &v, opts).Changed() {
n++
}
}, 520, 300)
_ = tt.Click("End time: Hours")
opts.Disabled = true
tt.Frame()
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != (TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}) || n != 0 {
t.Fatalf("disabled range took keys: %v (%d)", v, n)
}
}
func TestDatePickerDisabledBlurDoesNotCommit(t *testing.T) {
v, n := oct(5), 0
opts := DatePickerOptions{}
tt := datePickerLive(&v, &opts, &n)
datePickerType(tt, "Due", "2026-10-20")
opts.Disabled = true
tt.Frame()
_ = tt.Click("Open calendar")
tt.Key(0, ui.KeyTab)
tt.Frame()
if v != oct(5) || n != 0 {
t.Fatalf("disabled picker committed its draft on blur: %v (%d)", v, n)
}
}
func TestTimePickerSegmentErrorSurvivesOtherSegments(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{}, &n)
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
_ = tt.Click("Alarm: Minutes")
tt.Key(0, ui.KeyUp)
if !tt.HasText("29") || !tt.HasText("Hours must be from 0 to 23.") {
t.Fatalf("editing minutes cleared the hour error: %v", tt.Texts())
}
if v != (TimeOfDay{2, 30, 0}) {
t.Fatalf("value written while a segment is invalid: %v", v)
}
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key8)
if v != (TimeOfDay{8, 31, 0}) || tt.HasText("Hours must be") {
t.Fatalf("fixing the hour should commit both: %v", v)
}
}
func TestDateTimePickerBlocksConfirmWithInvalidSegment(t *testing.T) {
v := time.Date(2026, time.October, 5, 9, 30, 0, 0, time.UTC)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
_ = tt.Click("Time: Minutes")
tt.Key(0, ui.KeyUp)
_ = tt.Click("Confirm")
if n != 0 || !v.Equal(time.Date(2026, time.October, 5, 9, 30, 0, 0, time.UTC)) {
t.Fatalf("confirmed with an invalid hour: %v", v)
}
if !tt.HasText("Hours must be from 0 to 23.") || !tt.HasText("Confirm") {
t.Fatalf("expected the popup to stay with the error: %v", tt.Texts())
}
}
func TestDateRangePickerConfirmChecksCurrentBounds(t *testing.T) {
v, n := DateRange{}, 0
opts := DateRangePickerOptions{}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
o := opts
o.Today, o.Label = calendarToday, "Stay"
if DateRangePicker(c, &v, o).Changed() {
n++
}
}, 420, 520)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click(datetimeTestLabel(oct(15)))
opts.Max = oct(12)
tt.Frame()
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() || !tt.HasText("Choose 2026-10-12 or earlier.") {
t.Fatalf("out-of-range draft committed: %v %v", v, tt.Texts())
}
opts.Max = core.Date{}
opts.DisableDate = func(d core.Date) bool { return d == oct(11) }
tt.Frame()
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("The range cannot include 2026-10-11.") {
t.Fatalf("draft over a newly disabled day committed: %v", v)
}
opts.DisableDate = nil
tt.Frame()
_ = tt.Click("Confirm")
if n != 1 || v != (DateRange{oct(10), oct(15)}) {
t.Fatalf("valid draft not committed: %v", v)
}
}
func TestTimePickerPopupPickClearsSegmentError(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{}, &n)
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
if v != (TimeOfDay{2, 30, 0}) || !tt.HasText("Hours must be from 0 to 23.") {
t.Fatalf("setup: %v %v", v, tt.Texts())
}
n = 0
_ = tt.Click("Choose time")
if err := tt.Click("03"); err != nil {
t.Fatal(err)
}
if v != (TimeOfDay{3, 30, 0}) || n != 1 || tt.HasText("Hours must be") {
t.Fatalf("popup pick: value %v, %d changes, %v", v, n, tt.Texts())
}
}
func TestFormFocusesDatePickerInput(t *testing.T) {
var st input.FormState
day := core.Date{}
var field *ui.Element
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
input.Form(c, &st, input.FormOptions{}, func(f *input.FormScope) {
input.FormItem(c, f, "debut", &day, input.FormItemOptions[core.Date]{Label: "Debut date", Required: true}, func() *ui.Element {
r := DatePicker(c, &day, DatePickerOptions{Today: calendarToday})
field = r.Input
return r.Input
})
})
}, 600, 500)
if err := tt.Click("Submit"); err != nil {
t.Fatal(err)
}
if field == nil || !field.Focused() || !tt.Focused("Debut date") {
t.Fatal("a failed submit should focus the DatePicker's text box")
}
tt.Type("2026-10-12")
tt.Key(0, ui.KeyEnter)
if day != oct(12) {
t.Fatalf("typing after the focus jump gave %v", day)
}
}
func TestSelectedCellsHaveTheirOwnMark(t *testing.T) {
k := theme.Light()
check := func(name string, tt *ui.Tester, label string, shift int) {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("%s: no cell %q", name, label)
}
r.X += float32(shift) * r.W
img := tt.Image()
y := int(r.Y + r.H/2)
frame, fill := img.RGBAAt(int(r.X)+2, y), img.RGBAAt(int(r.X)+5, y)
if !datetimeNear(frame, k.OnAccent) || !datetimeNear(fill, k.Accent) {
t.Fatalf("%s: selected cell frame %v fill %v", name, frame, fill)
}
}
v := oct(14)
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
Calendar(c, &v, CalendarOptions{Today: calendarToday})
}, 400, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
check("calendar", tt, datetimeTestLabel(oct(14)), 0)
y := 2024
ty := yearPickerTester(&y, YearPickerOptions{}, new(int))
ty.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
_ = ty.Click("Vintage")
// The field also shows "2024", so locate the cell beside 2023.
check("year", ty, "2023", 1)
}
+237
View File
@@ -0,0 +1,237 @@
package datetime
import (
"image/color"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// datePickerLive builds a DatePicker whose options a test changes between frames.
func datePickerLive(value *core.Date, opts *DatePickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Gap(8).Children(func() {
o := *opts
o.Today, o.Label = calendarToday, "Due"
if DatePicker(c, value, o).Changed() {
*changes++
}
})
}, 420, 460)
}
func TestDatePickerDisablingClosesPopup(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
opts := DatePickerOptions{}
tt := datePickerLive(&v, &opts, &n)
_ = tt.Click("Open calendar")
day := datetimeTestLabel(oct(5))
if !tt.Focused(day) {
t.Fatal("setup: the popup should focus the chosen day")
}
opts.Disabled = true
tt.Frame()
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
_ = tt.Click(datetimeTestLabel(oct(20)))
if v != oct(5) || n != 0 {
t.Fatalf("disabled picker committed %v (%d events)", v, n)
}
if tt.HasText("October 2026") || tt.Focused(datetimeTestLabel(oct(6))) || tt.Focused("Due") {
t.Fatal("the popup should close and nothing in the disabled picker keep focus")
}
}
func TestDatePickerDisabledBlocksInput(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n)
_ = tt.Click("Due")
tt.Type("2027-01-01")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
_ = tt.Click("Open calendar")
if v != oct(5) || n != 0 || tt.Focused("Due") || tt.HasText("October 2026") {
t.Fatalf("disabled picker took input: %v (%d)", v, n)
}
}
func TestTimePickerKeepsInvalidHour(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{}, &n)
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
if v != (TimeOfDay{2, 30, 0}) || n != 1 {
t.Fatalf("first digit should apply: %v", v)
}
if !tt.HasText("29") || !tt.HasText("Hours must be from 0 to 23.") || !tt.Focused("Alarm: Hours") {
t.Fatalf("29 should stay shown with an error: %v", tt.Texts())
}
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{15, 30, 0}) || n != 3 || tt.HasText("Hours must be") {
t.Fatalf("fixing the hour: %v", v)
}
}
func TestTimeSegmentDescription(t *testing.T) {
var got []string
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
got = []string{timeSegmentDescription(c, timeHourPart, " 9", 1, 12), timeSegmentDescription(c, timePeriodPart, "PM", 0, 59)}
}, 100, 100)
if got[0] != "9, from 1 to 12" || got[1] != "PM" {
t.Fatalf("descriptions %q", got)
}
}
func TestTimePickerSegmentFocusRing(t *testing.T) {
v := TimeOfDay{9, 30, 0}
tt := timePickerTester(&v, TimePickerOptions{}, new(int))
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Key(0, ui.KeyTab)
r, ok := tt.Find("Alarm: Hours")
if !ok || !tt.Focused("Alarm: Hours") {
t.Fatal("setup: hours not focused")
}
want := theme.Light().Focus
img := tt.Image()
found := false
for dy := float32(-4); dy <= -2 && !found; dy++ {
c := img.RGBAAt(int(r.X+r.W/2), int(r.Y+dy))
found = datetimeNear(c, want)
}
if !found {
t.Fatal("no focus ring above the focused segment")
}
}
func datetimeNear(c color.RGBA, w ui.Color) bool {
d := func(a, b uint8) int {
if a > b {
return int(a - b)
}
return int(b - a)
}
return d(c.R, w.R)+d(c.G, w.G)+d(c.B, w.B) < 60
}
func TestDatePickerLayoutChangeReformats(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
opts := DatePickerOptions{}
tt := datePickerLive(&v, &opts, &n)
opts.Layout = "02/01/2006"
tt.Frame()
_ = tt.Click("Due")
tt.Key(0, ui.KeyEnter)
if v != oct(5) || n != 0 || tt.HasText("Enter a date like") {
t.Fatalf("committed text kept the old layout: %v", tt.Texts())
}
_ = tt.Click("Due")
tt.Command("selectAll")
tt.Type("12/10")
opts.Layout = "2006-01-02"
tt.Frame()
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Enter a date like 2026-10-05.") || v != oct(5) {
t.Fatal("a draft being edited should be kept across a layout change")
}
}
func TestYearPickerHomeEndStayOnPage(t *testing.T) {
v := 2026
tt := yearPickerTester(&v, YearPickerOptions{}, new(int))
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyHome)
if !tt.HasText("2020 – 2029") || !tt.Focused("2020") {
t.Fatalf("Home left the page: %v", tt.Texts()[:4])
}
tt.Key(0, ui.KeyEnd)
if !tt.HasText("2020 – 2029") || !tt.Focused("2029") {
t.Fatal("End left the page")
}
}
func TestDatePickerReadOnly(t *testing.T) {
v, n := core.Date{Year: 2026, Month: time.October, Day: 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", ReadOnly: true, Clearable: true}, &n)
_ = tt.Click("Due")
if !tt.Focused("Due") {
t.Fatal("read-only field should take focus")
}
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "2026-10-05" {
t.Fatalf("copied %q", tt.Clipboard())
}
tt.Type("2027-01-01")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if v != oct(5) || n != 0 || tt.HasText("October 2026") || tt.HasText("Open calendar") || tt.HasText("Clear") {
t.Fatalf("read-only picker changed: %v %v", v, tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyTab)
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "2026-10-05" {
t.Fatalf("typed text leaked into the field: %q", tt.Clipboard())
}
}
func TestTimePickerReadOnly(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{ReadOnly: true}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.Key1)
tt.Key(ui.Alt, ui.KeyDown)
if !tt.Focused("Alarm: Hours") || v != (TimeOfDay{9, 30, 0}) || n != 0 || tt.HasText("Choose time") {
t.Fatalf("read-only time changed: %v", v)
}
tt.Key(ui.Cmd, ui.KeyC)
if tt.Clipboard() != "09:30" {
t.Fatalf("copied %q", tt.Clipboard())
}
}
func TestMonthPickerReadOnly(t *testing.T) {
v, n := YearMonth{2026, time.May}, 0
tt := monthPickerTester(&v, MonthPickerOptions{ReadOnly: true, Clearable: true}, &n)
_ = tt.Click("Billing month")
tt.Key(0, ui.KeyDown)
if tt.HasText("January") || tt.HasText("Clear") || !tt.Focused("Billing month") {
t.Fatalf("read-only month opened: %v", tt.Texts())
}
tt.Key(ui.Cmd, ui.KeyC)
if tt.Clipboard() != "May 2026" || v != (YearMonth{2026, time.May}) || n != 0 {
t.Fatalf("copied %q, value %v", tt.Clipboard(), v)
}
}
func TestDateTimePickerLayout(t *testing.T) {
v := time.Time{}
n, x := 0, 0
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT, Layout: "02/01/2006"}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key3)
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("Choose 10/10/2026 12:00 UTC or earlier.") {
t.Fatalf("error ignores the layout: %v", tt.Texts())
}
_ = tt.Click(datetimeTestLabel(oct(9)))
_ = tt.Click("Confirm")
if n != 1 || !tt.HasText("09/10/2026 13:00 UTC") {
t.Fatalf("display ignores the layout: %v", tt.Texts())
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package datetime holds MujicaUI date and time pickers and calendar views.
package datetime
+103
View File
@@ -0,0 +1,103 @@
package datetime
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// 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) core.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 := core.Density(c)
label := opts.Label
if label == "" {
label = core.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 := input.NumberInput(c, &draft[i], input.NumberInputOptions{Min: 0, Max: f.max, Label: core.Msg(c, f.key), Disabled: opts.Disabled})
edited = edited || r.Changed()
})
ui.Text(c, core.Msg(c, f.key+"_unit")).TextColor(core.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(core.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
}
core.DatetimeError(c, problem)
})
return core.NewChoiceResult(col, 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]), core.Msg(c, key))
}
}
return s
}
+89
View File
@@ -0,0 +1,89 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func durationTester(v *time.Duration, opts *DurationPickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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}) })
})
}
+81
View File
@@ -0,0 +1,81 @@
package datetime
import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// EventChipOptions configure an EventChip.
type EventChipOptions struct {
// Location is the zone the time shows in; required.
Location *time.Location
Hour12 bool
// Inline puts the start time and title on one line.
Inline bool
Selected bool
Disabled bool
}
// EventChip is a clickable, truncating event block.
func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element {
loc := eventLocation("EventChip", opts.Location)
k := core.Tokens(c)
d := core.Density(c)
col := EventTone(k, ev.Color)
when := eventTimeText(c, ev, loc, opts.Hour12)
title := ev.Title
if title == "" {
title = core.Msg(c, "event_chip.untitled")
}
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Justify(ui.Start).AlignItems(ui.Start).
Radius(theme.ControlRadius).Padding(d.Unit(), d.Unit()*2).PaddingX(d.Unit() * 2).Gap(d.Unit()).
Label(title + ", " + when).Disabled(opts.Disabled).Clip()
internal.Ring(b, k.Focus, theme.ControlRadius)
bg := col.Alpha(0.14).Over(k.Surface)
switch {
case opts.Selected:
bg = k.Selection
case b.Pressed():
bg = col.Alpha(0.24).Over(k.Surface)
case b.Hovered():
bg = col.Alpha(0.2).Over(k.Surface)
}
b.Background(bg).Transition(core.ColorFade)
if !opts.Inline {
b.Column()
}
b.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, col, 0)
})
selected := opts.Selected
b.DrawOver(func(p *ui.Painter, r ui.Rect) {
if selected {
core.DatetimeSelectedMark(p, r, col)
}
})
b.Children(func() {
small := core.FontSize(c, theme.CaptionSize)
if opts.Inline {
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 {
ui.Text(c, when).FontSize(small).TextColor(k.TextMuted).SingleLine().Ellipsis("…").FontFeatures("tnum")
}
})
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 core.Msg(c, "event_chip.all_day")
}
s := ev.Start.In(loc)
return timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, timeConfig{hour12: hour12})
}
+126
View File
@@ -0,0 +1,126 @@
package datetime
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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) {
core.Use(c, core.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) {
core.Use(c, core.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) {
core.Use(c, core.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; EventTone(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})
})
})
}
+174
View File
@@ -0,0 +1,174 @@
package datetime
import (
"log/slog"
"slices"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// 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 := core.Density(c)
label := opts.Label
if label == "" {
label = core.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() {
input.FormField(c, core.Msg(c, "event_editor.title"), input.FormFieldOptions{Required: true, Error: problems["title"]}, func() *ui.Element {
return input.TextInput(c, &dr.Title, input.TextInputOptions{Label: core.Msg(c, "event_editor.title"), Disabled: opts.Disabled, Error: problems["title"]}).Input
})
input.Checkbox(c, &dr.AllDay, core.Msg(c, "event_editor.all_day"), input.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}} {
input.FormField(c, core.Msg(c, f.key), input.FormFieldOptions{Error: problems[f.key]}, func() *ui.Element {
if dr.AllDay {
day := core.DateOf(f.t.In(loc))
r := DatePicker(c, &day, DatePickerOptions{Label: core.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: core.Msg(c, f.key), Disabled: opts.Disabled}).Element
})
}
})
input.FormField(c, core.Msg(c, "event_editor.where"), input.FormFieldOptions{}, func() *ui.Element {
return input.TextInput(c, &dr.Location, input.TextInputOptions{Label: core.Msg(c, "event_editor.where"), Disabled: opts.Disabled}).Input
})
input.FormField(c, core.Msg(c, "event_editor.guests"), input.FormFieldOptions{Description: core.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 := input.TagsInput(c, &names, input.TagsInputOptions{Label: core.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})
}
input.FormField(c, core.Msg(c, "event_editor.reminders"), input.FormFieldOptions{}, func() *ui.Element {
return ReminderPicker(c, &dr.Reminders, ReminderPickerOptions{Disabled: opts.Disabled}).Element
})
var rec RecurrenceResult
input.FormField(c, core.Msg(c, "recurrence_editor.repeat"), input.FormFieldOptions{}, func() *ui.Element {
rec = RecurrenceEditor(c, &dr.Repeat, RecurrenceEditorOptions{Start: core.DateOf(dr.Start.In(loc)), WeekStart: opts.WeekStart, Disabled: opts.Disabled})
return rec.Element
})
core.DatetimeFooter(c, func() {
if input.Button(c, core.Msg(c, "calendar.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() && !root.IsDisabled() {
slog.Debug("mujica: event_editor cancel", "id", ev.ID)
*st, r.canceled = nil, true
}
if input.Button(c, core.Msg(c, "event_editor.save"), input.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 := core.DateOf(out.Start.In(loc)), core.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"] = core.Msg(c, "event_editor.need_title")
}
if ev.Start.IsZero() {
p["event_editor.starts"] = core.Msg(c, "event_editor.need_start")
}
if !ev.End.After(ev.Start) {
p["event_editor.ends"] = core.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
}
+114
View File
@@ -0,0 +1,114 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type editorProbe struct {
ev CalendarEvent
saves, cancels int
disabled bool
}
func (p *editorProbe) tester() *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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) {
core.Use(c, core.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}) })
})
}
+116
View File
@@ -0,0 +1,116 @@
package datetime
import (
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
d := core.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) {
core.OverlayStyle(c, panel, core.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), EventTone(k, ev.Color))
})
ui.Text(c, ev.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).Shrink(1)
if core.IconAction(c, icons.Must("x"), core.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
}
core.DatetimeFooter(c, func() {
if input.Button(c, core.Msg(c, "event_popover.delete"), input.ButtonOptions{Variant: input.Danger}).Clicked() {
slog.Debug("mujica: event_popover delete", "id", ev.ID)
r.del, *open = true, false
}
if input.Button(c, core.Msg(c, "event_popover.edit"), input.ButtonOptions{Variant: input.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", core.DatetimeDayLabel(c, core.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", core.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, core.Msg(c, "recurrence_editor.list_sep"))})
}
return lines
}
+113
View File
@@ -0,0 +1,113 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
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) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(16).Children(func() {
b := input.Button(c, "Details", input.ButtonOptions{Variant: input.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) {
core.Use(c, core.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 := input.Button(c, "Details", input.ButtonOptions{Variant: input.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})
})
})
}
+255
View File
@@ -0,0 +1,255 @@
package datetime_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
)
func ExampleCalendarMonthView() {
loc := time.Local
events := exampleEvents(loc)
month := datetime.YearMonth{Year: 2026, Month: time.October}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := datetime.CalendarMonthView(c, &month, events, datetime.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 := core.Date{Year: 2026, Month: time.October, Day: 14}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := datetime.CalendarWeekView(c, &week, events, datetime.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15})
if ch, ok := r.Changed(); ok && ch.Kind == datetime.CalendarCreate {
fmt.Println("new event", ch.Start, ch.End)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCalendarDayView() {
loc := time.Local
events := exampleEvents(loc)
day := core.Date{Year: 2026, Month: time.October, Day: 14}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if id, ok := datetime.CalendarDayView(c, &day, events, datetime.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) {
core.Use(c, core.Settings{})
opts := datetime.AgendaViewOptions{Location: loc, From: core.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14}
if id, ok := datetime.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) {
core.Use(c, core.Settings{})
if day, ok := datetime.YearView(c, &year, events, datetime.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) {
core.Use(c, core.Settings{})
if datetime.EventChip(c, ev, datetime.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) {
core.Use(c, core.Settings{})
r := datetime.EventEditor(c, &ev, datetime.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) {
core.Use(c, core.Settings{})
chip := datetime.EventChip(c, ev, datetime.EventChipOptions{Location: loc})
if chip.Clicked() {
open = true
}
r := datetime.EventPopover(c, chip, &open, ev, datetime.EventPopoverOptions{Location: loc})
if r.Delete() {
fmt.Println("delete", ev.ID)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCurrentTimeIndicator() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Box(c).Size(200, 240).Children(func() {
datetime.CurrentTimeIndicator(c, datetime.CurrentTimeIndicatorOptions{Location: time.Local})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRecurrenceEditor() {
rule := datetime.Recurrence{Unit: datetime.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := datetime.RecurrenceEditor(c, &rule, datetime.RecurrenceEditorOptions{Start: core.Date{Year: 2026, Month: time.October, Day: 5}})
if r.Changed() && r.Problem() == "" {
fmt.Println(datetime.RecurrenceSummary(c, rule))
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAvailabilityPicker() {
days := []core.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) {
core.Use(c, core.Settings{})
if datetime.AvailabilityPicker(c, &mine, datetime.AvailabilityPickerOptions{Days: days}).Changed() {
fmt.Println("availability changed")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimezoneSelect() {
zone := "Asia/Shanghai"
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if datetime.TimezoneSelect(c, &zone, datetime.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) {
core.Use(c, core.Settings{})
if datetime.ReminderPicker(c, &reminders, datetime.ReminderPickerOptions{Max: 3}).Changed() {
fmt.Println("reminders", reminders)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAttendeeList() {
people := []datetime.Attendee{{Name: "Sakiko", Status: datetime.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if i, ok := datetime.AttendeeList(c, people, datetime.AttendeeListOptions{Removable: true}).Removed(); ok {
people = append(people[:i], people[i+1:]...)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleWeekPicker() {
var week core.Date
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if datetime.WeekPicker(c, &week, datetime.WeekPickerOptions{WeekStart: datetime.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) {
core.Use(c, core.Settings{})
opts := datetime.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour}
if datetime.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) {
core.Use(c, core.Settings{})
if datetime.CronEditor(c, &expr, datetime.CronEditorOptions{Location: time.Local}).Changed() {
if _, err := datetime.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) {
core.Use(c, core.Settings{})
datetime.RelativeTime(c, posted, datetime.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) {
core.Use(c, core.Settings{})
if datetime.Countdown(c, deadline, datetime.CountdownOptions{}).Finished() {
fmt.Println("happy new year")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleStopwatch() {
var sw datetime.StopwatchState
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if datetime.Stopwatch(c, &sw, datetime.StopwatchOptions{}).Changed() {
fmt.Println("laps", len(sw.Laps))
}
}
ui.Render(view, 400, 300, 1)
}
+111
View File
@@ -0,0 +1,111 @@
package datetime_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
)
func ExampleCalendar() {
day := core.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if datetime.Calendar(c, &day, datetime.CalendarOptions{WeekStart: datetime.WeekStartMonday}).Changed() {
fmt.Println("chosen", day)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDatePicker() {
due := core.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
opts := datetime.DatePickerOptions{Label: "Due date", Clearable: true,
Min: core.Date{Year: 2026, Month: time.January, Day: 1}}
if datetime.DatePicker(c, &due, opts).Changed() {
fmt.Println("due", due)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDateRangePicker() {
var stay datetime.DateRange
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := datetime.DateRangePicker(c, &stay, datetime.DateRangePickerOptions{Label: "Stay"})
if r.Changed() {
fmt.Println("stay", stay.Start, stay.End, stay.Days())
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimePicker() {
alarm := datetime.TimeOfDay{Hour: 7, Minute: 30}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
opts := datetime.TimePickerOptions{Label: "Alarm", Hour12: true, MinuteStep: 5}
if datetime.TimePicker(c, &alarm, opts).Changed() {
fmt.Println("alarm", alarm)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimeRangePicker() {
shift := datetime.TimeRange{Start: datetime.TimeOfDay{Hour: 22}, End: datetime.TimeOfDay{Hour: 6}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
opts := datetime.TimeRangePickerOptions{Label: "Night shift", AllowOvernight: true}
if datetime.TimeRangePicker(c, &shift, opts).Changed() {
fmt.Println("shift", shift.Start, shift.End, shift.Overnight())
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDateTimePicker() {
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
var meeting time.Time
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
opts := datetime.DateTimePickerOptions{Label: "Meeting", Location: tokyo, MinuteStep: 15}
if datetime.DateTimePicker(c, &meeting, opts).Changed() {
fmt.Println("meeting", meeting)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMonthPicker() {
var month datetime.YearMonth
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
opts := datetime.MonthPickerOptions{Label: "Billing month", Clearable: true}
if datetime.MonthPicker(c, &month, opts).Changed() {
fmt.Println("month", month)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleYearPicker() {
year := 2026
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
opts := datetime.YearPickerOptions{Label: "Vintage", Min: 1990, Max: 2030}
if datetime.YearPicker(c, &year, opts).Changed() {
fmt.Println("year", year)
}
}
ui.Render(view, 400, 300, 1)
}
+15
View File
@@ -0,0 +1,15 @@
package datetime_test
import (
"time"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
)
func exampleEvents(loc *time.Location) []datetime.CalendarEvent {
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
return []datetime.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: datetime.EventInfo},
}
}
+24
View File
@@ -0,0 +1,24 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
+114
View File
@@ -0,0 +1,114 @@
package datetime
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// YearMonth is a month of a year. The zero value means no month.
type YearMonth struct {
Year int
Month time.Month
}
// IsZero reports whether m is the empty month.
func (m YearMonth) IsZero() bool { return m == YearMonth{} }
// String formats m as 2006-01, or "" when empty.
func (m YearMonth) String() string {
if m.IsZero() {
return ""
}
return fmt.Sprintf("%04d-%02d", m.Year, m.Month)
}
func (m YearMonth) ordinal() int { return m.Year*12 + int(m.Month) - 1 }
func yearMonthOf(o int) YearMonth {
y := o / 12
if o < 0 && o%12 != 0 {
y--
}
return YearMonth{y, time.Month(o-y*12) + 1}
}
// MonthPickerOptions configure a MonthPicker.
type MonthPickerOptions struct {
// Min and Max bound the months chosen; zero leaves a side open.
Min, Max YearMonth
DisableMonth func(YearMonth) bool
Clearable bool
// Today marks the current month; zero uses the system clock.
Today core.Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup and clearing.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// MonthPicker chooses a month into *value from a year page.
func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult {
for _, m := range []YearMonth{*value, opts.Min, opts.Max} {
if !m.IsZero() && (m.Month < time.January || m.Month > time.December) {
panic(fmt.Sprintf("mujica: MonthPicker month %v is not valid", m))
}
}
if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.ordinal() > opts.Max.ordinal() {
panic(fmt.Sprintf("mujica: MonthPicker Min %v is after Max %v", opts.Min, opts.Max))
}
today := core.DatetimeToday(c, opts.Today)
now := YearMonth{today.Year, today.Month}
title := func(m YearMonth) string { return core.DatetimeMonthTitle(c, m.Year, m.Month) }
home := *value
if home.IsZero() {
home = now
}
text := ""
if !value.IsZero() {
text = title(*value)
}
label := opts.Label
if label == "" {
label = core.Msg(c, "month_picker.placeholder")
}
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
text: text, placeholder: core.Msg(c, "month_picker.placeholder"), label: label,
clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home.ordinal(),
}, datetimeGridConfig{
pageOf: func(o int) (int, int) { return yearMonthOf(o).Year * 12, 12 },
cols: 3,
page: 12,
title: func(start int) string { return fmt.Sprintf(core.Msg(c, "month_picker.year"), yearMonthOf(start).Year) },
prev: core.Msg(c, "calendar.prev_year"),
next: core.Msg(c, "calendar.next_year"),
text: func(o int) string { return core.DatetimeMonthName(c, yearMonthOf(o).Month) },
label: func(o int) string { return title(yearMonthOf(o)) },
chosen: func(o int) bool { return !value.IsZero() && o == value.ordinal() },
current: func(o int) bool { return o == now.ordinal() },
enabled: func(o int) bool {
m := yearMonthOf(o)
return (opts.Min.IsZero() || o >= opts.Min.ordinal()) && (opts.Max.IsZero() || o <= opts.Max.ordinal()) &&
(opts.DisableMonth == nil || !opts.DisableMonth(m))
},
muted: func(int, int) bool { return false },
})
to := *value
switch {
case cleared:
to = YearMonth{}
case ok:
to = yearMonthOf(picked)
}
changed := to != *value
if changed {
slog.Debug("mujica: month_picker change", "from", *value, "to", to)
*value = to
}
return DateTimeResult{Element: col, changed: changed}
}
+98
View File
@@ -0,0 +1,98 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func monthPickerTester(value *YearMonth, opts MonthPickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Billing month"
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Children(func() {
if MonthPicker(c, value, opts).Changed() {
*changes++
}
})
}, 420, 420)
}
func TestMonthPickerClickAndYear(t *testing.T) {
v, n := YearMonth{}, 0
tt := monthPickerTester(&v, MonthPickerOptions{}, &n)
_ = tt.Click("Billing month")
if !tt.HasText("2026") {
t.Fatalf("popup should open on the current year: %v", tt.Texts())
}
_ = tt.Click("Next year")
if err := tt.Click("March 2027"); err != nil {
t.Fatal(err)
}
if v != (YearMonth{2027, time.March}) || n != 1 || tt.HasText("April 2027") {
t.Fatalf("picked %v, popup closed %v", v, !tt.HasText("April 2027"))
}
}
func TestMonthPickerKeyboard(t *testing.T) {
v, n := YearMonth{2026, time.May}, 0
tt := monthPickerTester(&v, MonthPickerOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("May 2026") {
t.Fatal("popup should focus the chosen month")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyPageUp)
if !tt.Focused("September 2025") {
t.Fatal("arrows and PageUp should move the cursor")
}
tt.Key(0, ui.KeyEnter)
if v != (YearMonth{2025, time.September}) || n != 1 {
t.Fatalf("Enter chose %v", v)
}
}
func TestMonthPickerLimitsAndClear(t *testing.T) {
v, n := YearMonth{2026, time.June}, 0
opts := MonthPickerOptions{Min: YearMonth{2026, time.March}, Max: YearMonth{2026, time.October}, Clearable: true,
DisableMonth: func(m YearMonth) bool { return m.Month == time.August }}
tt := monthPickerTester(&v, opts, &n)
_ = tt.Click("Billing month")
for _, m := range []string{"February 2026", "August 2026", "November 2026"} {
_ = tt.Click(m)
}
if v != (YearMonth{2026, time.June}) || n != 0 {
t.Fatalf("disabled month chosen: %v", v)
}
tt.Key(0, ui.KeyEscape)
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if !v.IsZero() || n != 1 {
t.Fatalf("clear left %v", v)
}
}
func TestYearMonthOrdinal(t *testing.T) {
for _, m := range []YearMonth{{2026, time.January}, {2026, time.December}, {1, time.March}} {
if got := yearMonthOf(m.ordinal()); got != m {
t.Fatalf("%v round-tripped to %v", m, got)
}
}
}
func TestMonthPickerSnapshot(t *testing.T) {
a, b := YearMonth{2026, time.October}, YearMonth{}
snapshot(t, "month_picker", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
MonthPicker(c, &b, MonthPickerOptions{Today: calendarToday})
MonthPicker(c, &a, MonthPickerOptions{Today: calendarToday, Clearable: true})
})
})
}
+225
View File
@@ -0,0 +1,225 @@
package datetime
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/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 core.Date
}
// RecurrenceEditorOptions configure a RecurrenceEditor.
type RecurrenceEditorOptions struct {
// Start is the first occurrence; Until must not precede it.
Start core.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 := core.Density(c)
k := core.Tokens(c)
before := recurrenceKey(*rule)
r := RecurrenceResult{}
label := opts.Label
if label == "" {
label = core.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 := []input.SelectOption[int]{}
for i, key := range []string{"none", "daily", "weekly", "monthly", "yearly", "custom"} {
modes = append(modes, input.SelectOption[int]{Value: i, Label: core.Msg(c, "recurrence_editor."+key)})
}
if input.Select(c, &mode, modes, input.SelectOptions{Label: core.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, core.Msg(c, "recurrence_editor.every"))
ui.Box(c).Width(d.ControlHeight() * 4).Children(func() {
n := float64(rule.Interval)
if input.NumberInput(c, &n, input.NumberInputOptions{Min: 1, Max: 999, Label: core.Msg(c, "recurrence_editor.interval"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) {
rule.Interval = int(n)
}
})
units := []input.SelectOption[RepeatUnit]{}
for u := RepeatDaily; u <= RepeatYearly; u++ {
units = append(units, input.SelectOption[RepeatUnit]{Value: u, Label: core.Msg(c, recurrenceUnitKeys[u])})
}
input.Select(c, &rule.Unit, units, input.SelectOptions{Label: core.Msg(c, "recurrence_editor.unit"), Disabled: opts.Disabled})
})
}
if rule.Unit == RepeatWeekly {
first := CalendarFirstWeekday(c, opts.WeekStart)
items := []input.ToggleItem[time.Weekday]{}
for i := range 7 {
wd := time.Weekday((int(first) + i) % 7)
items = append(items, input.ToggleItem[time.Weekday]{Value: wd, Label: core.Msg(c, fmt.Sprintf("calendar.weekday_%d", wd)),
Name: core.Msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd))})
}
input.ToggleGroup(c, &rule.Weekdays, items, input.ToggleGroupOptions{Multiple: true, AllowEmpty: true, Label: core.Msg(c, "recurrence_editor.on_days"), Disabled: opts.Disabled})
}
ends := []input.RadioItem[RepeatEnd]{{Value: RepeatForever, Label: core.Msg(c, "recurrence_editor.forever")},
{Value: RepeatCount, Label: core.Msg(c, "recurrence_editor.after")}, {Value: RepeatUntil, Label: core.Msg(c, "recurrence_editor.until")}}
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Wrap().Children(func() {
if input.RadioGroup(c, &rule.End, ends, input.RadioGroupOptions{Horizontal: true, Label: core.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 input.NumberInput(c, &n, input.NumberInputOptions{Min: 1, Max: 999, Label: core.Msg(c, "recurrence_editor.count"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) {
rule.Count = int(n)
}
})
case RepeatUntil:
DatePicker(c, &rule.Until, DatePickerOptions{Label: core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
core.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")
}
core.DatetimeCheckDate("Recurrence Until", r.Until)
}
// recurrenceProblem explains why a rule cannot be saved.
func recurrenceProblem(c *ui.Context, r Recurrence, start core.Date) string {
switch {
case r.Unit == RepeatNone:
return ""
case r.Unit == RepeatWeekly && len(r.Weekdays) == 0:
return core.Msg(c, "recurrence_editor.no_days")
case r.End == RepeatCount && r.Count < 1:
return core.Msg(c, "recurrence_editor.bad_count")
case r.End == RepeatUntil && (r.Until.IsZero() || !start.IsZero() && r.Until.Compare(start) < 0):
return core.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 core.Msg(c, "recurrence_editor.never")
}
s := fmt.Sprintf(core.Msg(c, "recurrence_editor.every_n"), r.Interval, core.Msg(c, recurrenceUnitKeys[r.Unit]))
if r.Interval == 1 {
s = core.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, core.Msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd)))
}
s += fmt.Sprintf(core.Msg(c, "recurrence_editor.on"), strings.Join(days, core.Msg(c, "recurrence_editor.list_sep")))
}
switch r.End {
case RepeatCount:
s += core.CalendarCount(c, "recurrence_editor.times", r.Count)
case RepeatUntil:
s += fmt.Sprintf(core.Msg(c, "recurrence_editor.until_text"), r.Until)
}
return s
}
+135
View File
@@ -0,0 +1,135 @@
package datetime
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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) {
core.Use(c, core.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 != (core.Date{Year: 2026, Month: time.November, Day: 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: core.Date{Year: 2026, Month: time.September, Day: 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)}) })
})
}
+77
View File
@@ -0,0 +1,77 @@
package datetime
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// 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 := core.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 core.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 := core.CalendarCount(c, "relative_time."+key, n)
if past {
return fmt.Sprintf(core.Msg(c, "relative_time.ago"), unit)
}
return fmt.Sprintf(core.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
}
+85
View File
@@ -0,0 +1,85 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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 []core.Locale{core.EnUS, core.ZhCN} {
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: loc})
var out []string
for _, d := range cases {
out = append(out, RelativeTimeText(c, d))
}
if loc == core.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) {
core.Use(c, core.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) { core.Use(c, core.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)})
})
})
}
+76
View File
@@ -0,0 +1,76 @@
package datetime
import (
"fmt"
"log/slog"
"slices"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// 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) core.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([]input.ToggleItem[time.Duration], len(choices))
for i, d := range choices {
items[i] = input.ToggleItem[time.Duration]{Value: d, Label: ReminderText(c, d), Disabled: full && !slices.Contains(*value, d)}
}
label := opts.Label
if label == "" {
label = core.Msg(c, "reminder_picker.label")
}
r := input.ToggleGroup(c, value, items, input.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 core.Msg(c, "reminder_picker.at_start")
case d%(7*24*time.Hour) == 0:
return core.CalendarCount(c, "reminder_picker.weeks", int(d/(7*24*time.Hour)))
case d%(24*time.Hour) == 0:
return core.CalendarCount(c, "reminder_picker.days", int(d/(24*time.Hour)))
case d%time.Hour == 0:
return core.CalendarCount(c, "reminder_picker.hours", int(d/time.Hour))
}
return core.CalendarCount(c, "reminder_picker.minutes", int(d/time.Minute))
}
+84
View File
@@ -0,0 +1,84 @@
package datetime
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func reminderTester(v *[]time.Duration, opts ReminderPickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.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) {
core.Use(c, core.Settings{Locale: core.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{}) })
})
}
+126
View File
@@ -0,0 +1,126 @@
package datetime
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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) core.ChoiceResult {
k := core.Tokens(c)
d := core.Density(c)
now := opts.Now
live := now.IsZero()
if live {
now = c.Now()
}
label := opts.Label
if label == "" {
label = core.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(core.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", input.Primary
if s.running {
start, variant = "stopwatch.pause", input.Secondary
} else if el > 0 {
start = "stopwatch.resume"
}
if input.Button(c, core.Msg(c, start), input.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 input.Button(c, core.Msg(c, "stopwatch.lap"), input.ButtonOptions{Variant: input.Secondary, Disabled: !s.running}).Clicked() {
act("lap", func() { s.Laps = append(s.Laps, s.Elapsed(now)) })
}
if input.Button(c, core.Msg(c, "stopwatch.reset"), input.ButtonOptions{Variant: input.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(core.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 core.NewChoiceResult(col, 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)
}
+65
View File
@@ -0,0 +1,65 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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) {
core.Use(c, core.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}) })
})
}
+444
View File
@@ -0,0 +1,444 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TimeOfDay is a wall-clock time with no date and no location.
type TimeOfDay struct {
Hour, Minute, Second int
}
// Valid reports whether t is a time between 00:00:00 and 23:59:59.
func (t TimeOfDay) Valid() bool {
return t.Hour >= 0 && t.Hour < 24 && t.Minute >= 0 && t.Minute < 60 && t.Second >= 0 && t.Second < 60
}
// String formats t as 15:04:05.
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second) }
// Compare returns -1, 0 or 1 as t is before, equal to or after o.
func (t TimeOfDay) Compare(o TimeOfDay) int {
a, b := t.seconds(), o.seconds()
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func (t TimeOfDay) seconds() int { return t.Hour*3600 + t.Minute*60 + t.Second }
// TimePickerOptions configure a TimePicker.
type TimePickerOptions struct {
// Seconds shows and edits the seconds.
Seconds bool
// Hour12 shows a 12-hour clock with AM and PM.
Hour12 bool
// MinuteStep and SecondStep must divide 60; zero means 1.
MinuteStep, SecondStep int
// Min and Max bound the time; a zero Max means 23:59:59.
Min, Max TimeOfDay
Disabled bool
// ReadOnly keeps the segments focusable and copyable but blocks edits and the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// TimePicker edits *value in segments or a popup.
func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult {
cfg := timePickerConfig("TimePicker", opts)
if !value.Valid() {
panic(fmt.Sprintf("mujica: TimePicker value %v is not a time of day", *value))
}
col := ui.Column(c).Gap(core.Density(c).Unit()).AlignItems(ui.Start).Disabled(opts.Disabled)
draft := ui.Local(col, "time-picker-draft", func() TimeOfDay { return *value })
seen := ui.Local(col, "time-picker-seen", func() TimeOfDay { return *value })
problem := ui.Local(col, "time-picker-error", func() string { return "" })
pending := ui.Local(col, "time-picker-pending", func() bool { return false })
if *seen != *value {
*draft, *seen, *pending = *value, *value, false
}
changed := false
col.Children(func() {
cfg.problem = *problem
_, edited, bad := timeEditor(c, draft, cfg)
p := bad
if p == "" {
p = timePickerProblem(c, *draft, cfg)
}
if p != *problem {
*problem = p
c.Invalidate()
}
*pending = *pending || edited
if *pending && p == "" {
if *draft != *value {
slog.Debug("mujica: time_picker change", "from", *value, "to", *draft)
*value, *seen, changed = *draft, *draft, true
}
*pending = false
}
core.DatetimeError(c, *problem)
})
return DateTimeResult{Element: col, changed: changed}
}
// timeConfig is the resolved behavior of a time editor.
type timeConfig struct {
seconds, hour12 bool
minuteStep, secondStep int
min, max TimeOfDay
label string
// problem is the error shown last frame, attached to the segments.
problem string
readOnly bool
}
// timePickerConfig checks opts and fills in defaults.
func timePickerConfig(name string, opts TimePickerOptions) timeConfig {
cfg := timeConfig{seconds: opts.Seconds, hour12: opts.Hour12, minuteStep: max(opts.MinuteStep, 1),
secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label, readOnly: opts.ReadOnly}
for _, s := range []int{opts.MinuteStep, opts.SecondStep} {
if s < 0 || s > 0 && 60%s != 0 {
panic(fmt.Sprintf("mujica: %s step %d does not divide 60", name, s))
}
}
if cfg.max == (TimeOfDay{}) {
cfg.max = TimeOfDay{23, 59, 59}
}
if !cfg.min.Valid() || !cfg.max.Valid() || cfg.min.Compare(cfg.max) > 0 {
panic(fmt.Sprintf("mujica: %s range %v..%v is not valid", name, cfg.min, cfg.max))
}
return cfg
}
// timePickerProblem explains why t cannot be written, or returns "".
func timePickerProblem(c *ui.Context, t TimeOfDay, cfg timeConfig) string {
switch {
case t.Minute%cfg.minuteStep != 0:
return fmt.Sprintf(core.Msg(c, "time_picker.minute_step"), cfg.minuteStep)
case cfg.seconds && t.Second%cfg.secondStep != 0:
return fmt.Sprintf(core.Msg(c, "time_picker.second_step"), cfg.secondStep)
case t.Compare(cfg.min) < 0 || t.Compare(cfg.max) > 0:
return fmt.Sprintf(core.Msg(c, "time_picker.out_of_range"), timeText(c, cfg.min, cfg), timeText(c, cfg.max, cfg))
}
return ""
}
// timeText formats t for messages, honoring the 12-hour clock and seconds.
func timeText(c *ui.Context, t TimeOfDay, cfg timeConfig) string {
s := fmt.Sprintf("%02d:%02d", t.Hour, t.Minute)
if cfg.hour12 {
s = fmt.Sprintf("%d:%02d", timeHour12(t.Hour), t.Minute)
}
if cfg.seconds {
s += fmt.Sprintf(":%02d", t.Second)
}
if cfg.hour12 {
s = fmt.Sprintf(core.Msg(c, "time_picker.with_period"), s, timePeriod(c, t.Hour))
}
return s
}
func timeHour12(h int) int {
if h%12 == 0 {
return 12
}
return h % 12
}
func timePeriod(c *ui.Context, h int) string {
if h < 12 {
return core.Msg(c, "time_picker.am")
}
return core.Msg(c, "time_picker.pm")
}
// timePart is one segment of a time editor.
type timePart int
const (
timeHourPart timePart = iota
timeMinutePart
timeSecondPart
timePeriodPart
)
var timePartNames = [...]string{"time_picker.hours", "time_picker.minutes", "time_picker.seconds", "time_picker.period"}
func timeParts(cfg timeConfig) []timePart {
parts := []timePart{timeHourPart, timeMinutePart}
if cfg.seconds {
parts = append(parts, timeSecondPart)
}
if cfg.hour12 {
parts = append(parts, timePeriodPart)
}
return parts
}
// timeEditor draws segments and popup; reports edits and invalid entry.
func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool, bad string) {
k := core.Tokens(c)
d := core.Density(c)
field = core.DatetimeField(c, ui.Row(c), cfg.problem != "", cfg.readOnly).Gap(d.Unit() * 2)
open := ui.Local(field, "time-open", func() bool { return false })
fresh := ui.Local(field, "time-fresh", func() bool { return false })
bads := ui.Local(field, "time-bads", func() [4]timeBad { return [4]timeBad{} })
field.Children(func() {
segs := ui.Row(c).AlignItems(ui.Center).Gap(d.Unit() * 1.5).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label)
if segs.Shortcut(ui.Cmd, ui.KeyC) {
c.WriteClipboard(timeText(c, *draft, cfg))
}
segs.Children(func() {
focusNext := false
for i, part := range timeParts(cfg) {
if i > 0 && part != timePeriodPart {
ui.Text(c, ":").TextColor(k.TextMuted).Role(ui.RoleNone)
}
if part == timePeriodPart {
ui.Box(c).Width(d.Unit())
}
seg, done := timeSegment(c, draft, part, cfg, &bads[part], &edited)
if focusNext {
seg.Focus()
}
focusNext = done
}
})
if cfg.readOnly {
return
}
b := core.IconAction(c, icons.Must("clock"), core.Msg(c, "time_picker.open")).Role(ui.RolePopUpButton)
if b.Clicked() {
*open, *fresh = !*open, true
}
})
if !*open && !cfg.readOnly && field.Shortcut(ui.Alt, ui.KeyDown) {
*open, *fresh = true, true
}
core.DatetimePopup(c, field, open, cfg.readOnly, func() {
if picked, ok := timeColumns(c, draft, cfg, *fresh); ok {
bads[picked] = timeBad{}
edited = true
}
*fresh = false
})
for _, part := range timeParts(cfg) {
if b := &bads[part]; b.text != "" && b.value != timePartValue(*draft, part, cfg) {
*b = timeBad{}
}
if bads[part].text != "" && bad == "" {
lo, hi := timeLimits(part, cfg)
bad = fmt.Sprintf(core.Msg(c, "time_picker.part_range"), core.Msg(c, timePartNames[part]), lo, hi)
}
}
return field, edited, bad
}
// timeBad is a segment's invalid typed text and the value it was typed over.
type timeBad struct {
text string
value int
}
// timeLimits returns the lowest and highest value a segment accepts.
func timeLimits(part timePart, cfg timeConfig) (lo, hi int) {
switch {
case part != timeHourPart:
return 0, 59
case cfg.hour12:
return 1, 12
}
return 0, 23
}
// timeSegment draws one segment; done reports a complete valid entry, bad an invalid one.
func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, bad *timeBad, edited *bool) (seg *ui.Element, done bool) {
k := core.Tokens(c)
d := core.Density(c)
name := core.Msg(c, timePartNames[part])
if cfg.label != "" {
name = cfg.label + ": " + name
}
seg = ui.Box(c).Focusable().Role(ui.RoleStepper).Label(name).
Padding(0, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Shrink(0).Center()
internal.Ring(seg, k.Focus, theme.ControlRadius)
typed := ui.Local(seg, "time-typed", func() string { return "" })
if !seg.Focused() {
*typed = ""
}
if bad.value != timePartValue(*draft, part, cfg) {
*bad = timeBad{}
}
set := func(t TimeOfDay) {
*bad = timeBad{}
if t != *draft {
*draft = t
*edited = true
}
}
t := *draft
lo, hi := timeLimits(part, cfg)
if !cfg.readOnly && !seg.IsDisabled() {
switch {
case seg.Shortcut(0, ui.KeyUp):
set(timeStepPart(t, part, cfg, 1))
case seg.Shortcut(0, ui.KeyDown):
set(timeStepPart(t, part, cfg, -1))
}
t = *draft
if part == timePeriodPart {
if seg.Shortcut(0, ui.KeyA) && t.Hour >= 12 || seg.Shortcut(0, ui.KeyP) && t.Hour < 12 {
t.Hour = (t.Hour + 12) % 24
set(t)
}
} else {
for n := range 10 {
if !seg.Shortcut(0, ui.Key0+ui.Key(n)) {
continue
}
v, ok, complete := timeType(typed, n, lo, hi)
switch {
case ok:
set(timeWithPart(t, part, v, cfg))
done = complete
case complete:
*bad = timeBad{fmt.Sprintf("%02d", v), timePartValue(*draft, part, cfg)}
slog.Debug("mujica: time_picker invalid segment", "part", int(part), "typed", v)
}
}
}
}
t = *draft
text := ""
switch part {
case timeHourPart:
text = fmt.Sprintf("%02d", t.Hour)
if cfg.hour12 {
text = fmt.Sprintf("%2d", timeHour12(t.Hour))
}
case timeMinutePart:
text = fmt.Sprintf("%02d", t.Minute)
case timeSecondPart:
text = fmt.Sprintf("%02d", t.Second)
case timePeriodPart:
text = timePeriod(c, t.Hour)
}
if bad.text != "" {
text = bad.text
}
seg.Description(timeSegmentDescription(c, part, text, lo, hi)).Error(cfg.problem)
fg := k.Text
switch {
case bad.text != "":
fg = k.Danger
case seg.Focused() && !cfg.readOnly:
seg.Background(k.Accent)
fg = k.OnAccent
case seg.Focused():
seg.Background(k.Selection)
case seg.Hovered():
seg.Background(k.SurfaceHover)
}
seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() })
return seg, done
}
// timeSegmentDescription gives assistive technology a segment's value and range.
func timeSegmentDescription(c *ui.Context, part timePart, text string, lo, hi int) string {
if part == timePeriodPart {
return text
}
return fmt.Sprintf(core.Msg(c, "time_picker.segment"), strings.TrimSpace(text), lo, hi)
}
// timePartValue is the value a segment shows, so other segments' edits keep its error.
func timePartValue(t TimeOfDay, part timePart, cfg timeConfig) int {
switch part {
case timeHourPart:
if cfg.hour12 {
return timeHour12(t.Hour)
}
return t.Hour
case timeMinutePart:
return t.Minute
case timeSecondPart:
return t.Second
}
return t.Hour / 12
}
// timeStepPart steps one segment of t by dir, wrapping round its cycle.
func timeStepPart(t TimeOfDay, part timePart, cfg timeConfig, dir int) TimeOfDay {
switch part {
case timeHourPart:
t.Hour = (t.Hour + dir + 24) % 24
case timeMinutePart:
t.Minute = timeStep(t.Minute, cfg.minuteStep, dir)
case timeSecondPart:
t.Second = timeStep(t.Second, cfg.secondStep, dir)
case timePeriodPart:
t.Hour = (t.Hour + 12) % 24
}
return t
}
// timeWithPart sets one segment of t to v, keeping the period on a 12-hour clock.
func timeWithPart(t TimeOfDay, part timePart, v int, cfg timeConfig) TimeOfDay {
switch part {
case timeHourPart:
if cfg.hour12 {
v = v%12 + t.Hour/12*12
}
t.Hour = v
case timeMinutePart:
t.Minute = v
case timeSecondPart:
t.Second = v
}
return t
}
// timeStep moves v to the next multiple of step in dir, wrapping within an hour.
func timeStep(v, step, dir int) int {
if dir > 0 {
return (v/step*step + step) % 60
}
if v%step != 0 {
return v / step * step
}
return (v - step + 60) % 60
}
// timeType appends digit n; reports value, validity and completion.
func timeType(typed *string, n, lo, hi int) (v int, ok, complete bool) {
s := *typed + strconv.Itoa(n)
if len(s) > 2 {
s = strconv.Itoa(n)
}
v, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
complete = len(s) == 2 || v*10 > hi
if complete {
s = ""
}
*typed = s
return v, v >= lo && v <= hi, complete
}
+94
View File
@@ -0,0 +1,94 @@
package datetime
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// timeColumns lists choosable values per segment and reports whether a choice edited *draft.
func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (picked timePart, edited bool) {
k := core.Tokens(c)
d := core.Density(c)
h := d.ControlHeight() - 4
within := func(lo, hi TimeOfDay) bool { return hi.Compare(cfg.min) >= 0 && lo.Compare(cfg.max) <= 0 }
ui.Row(c).Gap(d.Unit()).Children(func() {
for _, part := range timeParts(cfg) {
t := *draft
var options []TimeOfDay
switch part {
case timeHourPart:
for v := range 24 {
if !cfg.hour12 || v/12 == t.Hour/12 {
options = append(options, TimeOfDay{v, t.Minute, t.Second})
}
}
case timeMinutePart:
for v := 0; v < 60; v += cfg.minuteStep {
options = append(options, TimeOfDay{t.Hour, v, t.Second})
}
case timeSecondPart:
for v := 0; v < 60; v += cfg.secondStep {
options = append(options, TimeOfDay{t.Hour, t.Minute, v})
}
case timePeriodPart:
options = []TimeOfDay{{t.Hour % 12, t.Minute, t.Second}, {t.Hour%12 + 12, t.Minute, t.Second}}
}
name := core.Msg(c, timePartNames[part])
ui.Scroll(c).Height(h * 6).Width(d.ControlHeight() * 1.5).Children(func() {
ui.Column(c).FocusGroup(ui.Vertical).Role(ui.RoleRadioGroup).Label(name).Children(func() {
for _, o := range options {
var lo, hi TimeOfDay
text := ""
switch part {
case timeHourPart:
lo, hi = TimeOfDay{o.Hour, 0, 0}, TimeOfDay{o.Hour, 59, 59}
text = fmt.Sprintf("%02d", o.Hour)
if cfg.hour12 {
text = fmt.Sprintf("%d", timeHour12(o.Hour))
}
case timeMinutePart:
lo, hi = TimeOfDay{o.Hour, o.Minute, 0}, TimeOfDay{o.Hour, o.Minute, 59}
text = fmt.Sprintf("%02d", o.Minute)
case timeSecondPart:
lo, hi = o, o
text = fmt.Sprintf("%02d", o.Second)
case timePeriodPart:
lo, hi = TimeOfDay{o.Hour / 12 * 12, 0, 0}, TimeOfDay{o.Hour/12*12 + 11, 59, 59}
text = timePeriod(c, o.Hour)
}
sel := t
item := ui.RadioBase(c, &sel, o).Height(h).FillWidth().Center().Radius(theme.ControlRadius).
Disabled(!within(lo, hi)).Label(text)
fg := k.Text
switch {
case o == t:
item.Background(k.Accent)
fg = k.OnAccent
if fresh {
item.ScrollIntoView()
}
case item.Pressed():
item.Background(k.Selection)
case item.Hovered():
item.Background(k.SurfaceHover)
}
internal.Ring(item, k.Focus, theme.ControlRadius)
if o == t {
item.Draw(func(p *ui.Painter, r ui.Rect) { core.DatetimeSelectedMark(p, r, k.OnAccent) })
}
item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") })
if item.Clicked() {
*draft, picked, edited = o, part, true
}
}
})
})
}
})
return picked, edited
}
+155
View File
@@ -0,0 +1,155 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func timePickerTester(value *TimeOfDay, opts TimePickerOptions, changes *int) *ui.Tester {
opts.Label = "Alarm"
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Children(func() {
if TimePicker(c, value, opts).Changed() {
*changes++
}
})
}, 420, 420)
}
func TestTimePickerArrowsAndTyping(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{}, &n)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Alarm: Hours") {
t.Fatal("Tab should focus the hours")
}
tt.Key(0, ui.KeyUp)
if v != (TimeOfDay{10, 30, 0}) || n != 1 {
t.Fatalf("Up gave %v", v)
}
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key7)
if v != (TimeOfDay{17, 30, 0}) || !tt.Focused("Alarm: Minutes") {
t.Fatalf("typed hours %v, focus moved %v", v, tt.Focused("Alarm: Minutes"))
}
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{17, 5, 0}) {
t.Fatalf("typed minutes %v", v)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyDown)
if v != (TimeOfDay{16, 5, 0}) {
t.Fatalf("Left then Down gave %v", v)
}
tt.Key(0, ui.Key0)
tt.Key(0, ui.KeyDown)
if v != (TimeOfDay{23, 5, 0}) {
t.Fatalf("Down should wrap round the clock: %v", v)
}
}
func TestTimePickerHour12AndSeconds(t *testing.T) {
v, n := TimeOfDay{9, 0, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{Hour12: true, Seconds: true}, &n)
if !tt.HasText("AM") || !tt.HasText(" 9") {
t.Fatalf("12-hour display: %v", tt.Texts())
}
_ = tt.Click("Alarm: AM/PM")
tt.Key(0, ui.KeyP)
if v != (TimeOfDay{21, 0, 0}) {
t.Fatalf("P gave %v", v)
}
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key2)
if v != (TimeOfDay{12, 0, 0}) {
t.Fatalf("12 PM is noon, got %v", v)
}
_ = tt.Click("Alarm: Seconds")
tt.Key(0, ui.Key4)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{12, 0, 45}) {
t.Fatalf("seconds %v", v)
}
}
func TestTimePickerRangeAndStep(t *testing.T) {
v, n := TimeOfDay{9, 0, 0}, 0
opts := TimePickerOptions{Min: TimeOfDay{9, 0, 0}, Max: TimeOfDay{17, 0, 0}, MinuteStep: 15}
tt := timePickerTester(&v, opts, &n)
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.KeyDown)
if v != (TimeOfDay{9, 0, 0}) || n != 0 || !tt.HasText("Choose a time from 09:00 to 17:00.") {
t.Fatalf("out of range accepted: %v %v", v, tt.Texts())
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyUp)
if v != (TimeOfDay{10, 0, 0}) || tt.HasText("Choose a time") {
t.Fatalf("back in range: %v", v)
}
_ = tt.Click("Alarm: Minutes")
tt.Key(0, ui.KeyUp)
if v != (TimeOfDay{10, 15, 0}) {
t.Fatalf("step 15 gave %v", v)
}
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key7)
if v != (TimeOfDay{10, 0, 0}) || !tt.HasText("Minutes must be a multiple of 15.") {
t.Fatalf("off-step minute accepted: %v", v)
}
}
func TestTimePickerPopup(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
opts := TimePickerOptions{Min: TimeOfDay{9, 0, 0}, MinuteStep: 5}
tt := timePickerTester(&v, opts, &n)
if err := tt.Click("Choose time"); err != nil {
t.Fatal(err)
}
if err := tt.Click("25"); err != nil {
t.Fatal(err)
}
if v != (TimeOfDay{9, 25, 0}) || n != 1 {
t.Fatalf("minute pick %v", v)
}
if err := tt.Click("08"); err != nil {
t.Fatal(err)
}
if v.Hour != 9 || n != 1 {
t.Fatalf("hour before Min was chosen: %v", v)
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("05") {
t.Fatal("Escape should close the popup")
}
}
func TestTimePickerRejectsBadOptions(t *testing.T) {
for _, opts := range []TimePickerOptions{{MinuteStep: 7}, {Min: TimeOfDay{10, 0, 0}, Max: TimeOfDay{9, 0, 0}}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: expected panic", opts)
}
}()
v := TimeOfDay{}
timePickerTester(&v, opts, new(int))
}()
}
}
func TestTimePickerSnapshot(t *testing.T) {
a, b := TimeOfDay{9, 30, 0}, TimeOfDay{21, 5, 40}
snapshot(t, "time_picker", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
TimePicker(c, &a, TimePickerOptions{})
TimePicker(c, &b, TimePickerOptions{Hour12: true, Seconds: true})
TimePicker(c, &a, TimePickerOptions{Disabled: true})
})
})
}
+114
View File
@@ -0,0 +1,114 @@
package datetime
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TimeRange is a span of day; End before Start means next day.
type TimeRange struct {
Start, End TimeOfDay
}
// Overnight reports whether r crosses midnight.
func (r TimeRange) Overnight() bool { return r.End.Compare(r.Start) < 0 }
// TimeRangePickerOptions configure a TimeRangePicker.
type TimeRangePickerOptions struct {
Seconds bool
Hour12 bool
// MinuteStep and SecondStep must divide 60; zero means 1.
MinuteStep, SecondStep int
// Min and Max bound both ends; a zero Max means 23:59:59.
Min, Max TimeOfDay
// AllowOvernight accepts an end before the start, meaning the next day.
AllowOvernight bool
Disabled bool
// ReadOnly keeps both ends focusable and copyable but blocks edits.
ReadOnly bool
// Label names the pair for assistive technology.
Label string
}
// TimeRangePicker writes *value only when the range is valid.
func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOptions) DateTimeResult {
cfg := timePickerConfig("TimeRangePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12,
MinuteStep: opts.MinuteStep, SecondStep: opts.SecondStep, Min: opts.Min, Max: opts.Max, ReadOnly: opts.ReadOnly})
if !value.Start.Valid() || !value.End.Valid() {
panic(fmt.Sprintf("mujica: TimeRangePicker value %v is not a pair of times", *value))
}
if value.Overnight() && !opts.AllowOvernight {
panic(fmt.Sprintf("mujica: TimeRangePicker value %v crosses midnight without AllowOvernight", *value))
}
k := core.Tokens(c)
d := core.Density(c)
col := ui.Column(c).Gap(d.Unit()).AlignItems(ui.Start).Disabled(opts.Disabled).Role(ui.RoleGroup)
if opts.Label != "" {
col.Label(opts.Label)
}
draft := ui.Local(col, "time-range-draft", func() TimeRange { return *value })
seen := ui.Local(col, "time-range-seen", func() TimeRange { return *value })
problem := ui.Local(col, "time-range-error", func() string { return "" })
pending := ui.Local(col, "time-range-pending", func() bool { return false })
if *seen != *value {
*draft, *seen, *pending = *value, *value, false
}
changed := false
col.Children(func() {
edited, bad := false, ""
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
start, end := cfg, cfg
start.label, end.label = core.Msg(c, "time_range_picker.start"), core.Msg(c, "time_range_picker.end")
start.problem, end.problem = *problem, *problem
_, e1, b1 := timeEditor(c, &draft.Start, start)
ui.Text(c, "–").TextColor(k.TextMuted).Role(ui.RoleNone)
_, e2, b2 := timeEditor(c, &draft.End, end)
edited, bad = e1 || e2, b1
if bad == "" {
bad = b2
}
if draft.Overnight() && opts.AllowOvernight {
ui.Text(c, core.Msg(c, "time_range_picker.next_day")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
}
})
p := bad
if p == "" {
p = timeRangeProblem(c, *draft, cfg, opts.AllowOvernight)
}
if p != *problem {
*problem = p
c.Invalidate()
}
*pending = *pending || edited
if *pending && p == "" {
if *draft != *value {
slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft)
*value, *seen, changed = *draft, *draft, true
}
*pending = false
}
core.DatetimeError(c, *problem)
})
return DateTimeResult{Element: col, changed: changed}
}
// timeRangeProblem explains why r cannot be written, or returns "".
func timeRangeProblem(c *ui.Context, r TimeRange, cfg timeConfig, overnight bool) string {
for _, t := range []TimeOfDay{r.Start, r.End} {
if p := timePickerProblem(c, t, cfg); p != "" {
return p
}
}
switch {
case r.Start == r.End:
return core.Msg(c, "time_range_picker.same")
case r.Overnight() && !overnight:
return core.Msg(c, "time_range_picker.order")
}
return ""
}
+77
View File
@@ -0,0 +1,77 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func timeRangeTester(value *TimeRange, opts TimeRangePickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Children(func() {
if TimeRangePicker(c, value, opts).Changed() {
*changes++
}
})
}, 520, 420)
}
func TestTimeRangePickerOrder(t *testing.T) {
v, n := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}, 0
tt := timeRangeTester(&v, TimeRangePickerOptions{}, &n)
_ = tt.Click("End time: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key8)
if v.End != (TimeOfDay{17, 0, 0}) || n != 0 {
t.Fatalf("end before start accepted: %v", v)
}
if !tt.HasText("End must be after start; overnight ranges are off.") {
t.Fatalf("missing order error: %v", tt.Texts())
}
_ = tt.Click("Start time: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key7)
if v != (TimeRange{TimeOfDay{7, 0, 0}, TimeOfDay{8, 0, 0}}) || n != 2 || tt.HasText("End must be") {
t.Fatalf("fixing the start should commit both: %v", v)
}
_ = tt.Click("End time: Hours")
tt.Key(0, ui.KeyDown)
if v.End != (TimeOfDay{8, 0, 0}) || !tt.HasText("Start and end must differ.") {
t.Fatalf("equal ends accepted: %v", v)
}
}
func TestTimeRangePickerOvernight(t *testing.T) {
v, n := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{23, 0, 0}}, 0
tt := timeRangeTester(&v, TimeRangePickerOptions{AllowOvernight: true}, &n)
_ = tt.Click("End time: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key6)
if v.End != (TimeOfDay{6, 0, 0}) || n != 2 || !v.Overnight() || !tt.HasText("+1 day") {
t.Fatalf("overnight not accepted: %v %v", v, tt.Texts())
}
}
func TestTimeRangePickerRejectsOvernightValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
v := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{6, 0, 0}}
timeRangeTester(&v, TimeRangePickerOptions{}, new(int))
}
func TestTimeRangePickerSnapshot(t *testing.T) {
a := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 30, 0}}
b := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{6, 0, 0}}
snapshot(t, "time_range_picker", 420, 120, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Children(func() {
TimeRangePicker(c, &a, TimeRangePickerOptions{})
TimeRangePicker(c, &b, TimeRangePickerOptions{AllowOvernight: true})
})
})
}
+210
View File
@@ -0,0 +1,210 @@
package datetime
import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
_ "time/tzdata"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TimezoneSelectOptions configure a TimezoneSelect.
type TimezoneSelectOptions struct {
// Zones are the IANA names offered; empty uses built-ins.
Zones []string
// Common are pinned first; empty uses a built-in short list.
Common []string
// Now dates the offsets; zero uses the system clock.
Now time.Time
Disabled bool
Label string
}
var timezoneCommon = []string{"UTC", "Asia/Shanghai", "Asia/Tokyo", "Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles"}
var timezoneWorld = []string{"Pacific/Honolulu", "America/Anchorage", "America/Los_Angeles", "America/Denver", "America/Chicago",
"America/Mexico_City", "America/New_York", "America/Toronto", "America/Bogota", "America/Lima", "America/Caracas",
"America/Santiago", "America/Halifax", "America/St_Johns", "America/Sao_Paulo", "America/Argentina/Buenos_Aires",
"Atlantic/Azores", "UTC", "Europe/London", "Europe/Dublin", "Europe/Lisbon", "Africa/Lagos", "Europe/Paris",
"Europe/Berlin", "Europe/Madrid", "Europe/Rome", "Europe/Amsterdam", "Europe/Stockholm", "Europe/Warsaw",
"Africa/Cairo", "Africa/Johannesburg", "Europe/Athens", "Europe/Helsinki", "Europe/Kyiv", "Europe/Istanbul",
"Asia/Jerusalem", "Europe/Moscow", "Asia/Riyadh", "Africa/Nairobi", "Asia/Tehran", "Asia/Dubai", "Asia/Kabul",
"Asia/Karachi", "Asia/Tashkent", "Asia/Kolkata", "Asia/Kathmandu", "Asia/Dhaka", "Asia/Yangon", "Asia/Bangkok",
"Asia/Jakarta", "Asia/Ho_Chi_Minh", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Taipei", "Asia/Singapore",
"Asia/Manila", "Australia/Perth", "Asia/Seoul", "Asia/Tokyo", "Australia/Darwin", "Australia/Adelaide",
"Australia/Brisbane", "Australia/Sydney", "Pacific/Guam", "Pacific/Noumea", "Pacific/Auckland", "Pacific/Fiji",
"Pacific/Chatham", "Pacific/Tongatapu", "Pacific/Kiritimati"}
var timezoneCache sync.Map
// timezoneLoad resolves an IANA name once or panics.
func timezoneLoad(name string) *time.Location {
if v, ok := timezoneCache.Load(name); ok {
return v.(*time.Location)
}
loc, err := time.LoadLocation(name)
if err != nil {
panic(fmt.Sprintf("mujica: TimezoneSelect zone %q: %v", name, err))
}
timezoneCache.Store(name, loc)
return loc
}
// TimezoneOffset formats a zone's offset at t, as "UTC+09:00".
func TimezoneOffset(loc *time.Location, t time.Time) string {
_, off := t.In(loc).Zone()
sign := "+"
if off < 0 {
sign, off = "−", -off
}
return fmt.Sprintf("UTC%s%02d:%02d", sign, off/3600, off%3600/60)
}
// TimezoneSelect searches zones by name or offset into *zone.
func TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) core.ChoiceResult {
zones, common := opts.Zones, opts.Common
if len(zones) == 0 {
zones = timezoneWorld
}
if len(common) == 0 {
common = timezoneCommon
}
for _, z := range append(append([]string{}, zones...), common...) {
timezoneLoad(z)
}
if *zone != "" {
timezoneLoad(*zone)
}
now := opts.Now
if now.IsZero() {
now = c.Now()
}
k := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.Msg(c, "timezone_select.label")
}
old := *zone
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "timezone-open", func() bool { return false })
query := ui.Local(col, "timezone-query", func() string { return "" })
hi := ui.Local(col, "timezone-highlight", func() int { return 0 })
shown := ui.Local(col, "timezone-shown", func() int { return -1 })
text := ""
if *zone != "" {
text = TimezoneOffset(timezoneLoad(*zone), now) + " · " + timezoneName(*zone)
}
col.Children(func() {
field := core.DatetimeField(c, ui.Row(c), false, false)
field.Children(func() {
t := core.DatetimeTrigger(c, text, core.Msg(c, "timezone_select.placeholder"), "clock").Label(label).Description(text)
if !t.IsDisabled() && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open, *query, *hi = !*open, "", 0
}
})
core.DatetimePopup(c, field, open, false, func() {
list := timezoneFilter(zones, common, *query, now)
in := input.TextInput(c, query, input.TextInputOptions{Placeholder: core.Msg(c, "timezone_select.search"), Label: core.Msg(c, "timezone_select.search")})
in.Input.AutoFocus()
if in.Changed() {
*hi = 0
}
if !in.Input.IsDisabled() {
switch {
case in.Input.Shortcut(0, ui.KeyDown):
*hi = min(*hi+1, len(list)-1)
case in.Input.Shortcut(0, ui.KeyUp):
*hi = max(*hi-1, 0)
case in.Submitted() && len(list) > 0:
*zone, *open = list[*hi].name, false
}
}
defer func() { *shown = *hi }()
_, wh := c.Size()
ui.Scroll(c).MaxHeight(min(d.ControlHeight()*8, wh/2)).Children(func() {
if len(list) == 0 {
ui.Text(c, core.Msg(c, "timezone_select.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 2)
}
for i, z := range list {
if i == 0 || z.common != list[i-1].common {
key := "timezone_select.all"
if z.common {
key = "timezone_select.common"
}
ui.Text(c, core.Msg(c, key)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit(), d.Unit()*2)
}
if timezoneRow(c, z, i == *hi, i == *hi && *hi != *shown, z.name == *zone) {
*zone, *open = z.name, false
}
}
})
})
})
changed := *zone != old
if changed {
slog.Debug("mujica: timezone_select change", "from", old, "to", *zone)
}
return core.NewChoiceResult(col, changed)
}
type timezoneEntry struct {
name, offset string
common bool
}
// timezoneFilter matches names and offsets, common zones first.
func timezoneFilter(zones, common []string, query string, now time.Time) []timezoneEntry {
q := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(query, " ", "_")))
var out []timezoneEntry
add := func(names []string, isCommon bool) {
for _, z := range names {
off := TimezoneOffset(timezoneLoad(z), now)
if q == "" || strings.Contains(strings.ToLower(z), q) || strings.Contains(strings.ToLower(off), q) {
out = append(out, timezoneEntry{z, off, isCommon})
}
}
}
add(common, true)
add(zones, false)
return out
}
// timezoneName is a zone's city part, readable.
func timezoneName(z string) string {
return strings.ReplaceAll(z, "_", " ")
}
// timezoneRow draws one zone; it reports a click.
func timezoneRow(c *ui.Context, z timezoneEntry, highlighted, reveal, chosen bool) bool {
k := core.Tokens(c)
d := core.Density(c)
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(d.Unit() * 3).PaddingX(d.Unit() * 2).Radius(theme.ControlRadius).
Label(z.offset + " " + timezoneName(z.name))
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case chosen || highlighted:
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
if reveal {
b.ScrollIntoView()
}
b.Children(func() {
ui.Text(c, z.offset).Width(d.ControlHeight() * 2.4).Shrink(0).FontFeatures("tnum").TextColor(k.TextMuted)
ui.Text(c, timezoneName(z.name)).TextColor(k.Text).Grow(1).Shrink(1).SingleLine().Ellipsis("…")
if chosen {
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "check", k.AccentText) })
}
})
return b.Clicked() && !b.IsDisabled()
}
+100
View File
@@ -0,0 +1,100 @@
package datetime
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var tzNow = time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
func timezoneTester(zone *string, opts *TimezoneSelectOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
o := *opts
o.Now = tzNow
ui.Column(c).Padding(16).Children(func() {
if TimezoneSelect(c, zone, o).Changed() {
*n++
}
})
}, 520, 640)
}
func TestTimezoneSelectSearchByNameAndOffset(t *testing.T) {
zone, n := "", 0
tt := timezoneTester(&zone, &TimezoneSelectOptions{}, &n)
_ = tt.Click("Time zone")
if !tt.HasText("Common") || !tt.HasText("UTC+09:00 Asia/Tokyo") || !tt.HasText("UTC−04:00 America/New York") {
t.Fatalf("summer offsets and sections: %v", tt.Texts()[:12])
}
tt.Type("kolk")
if !tt.HasText("UTC+05:30 Asia/Kolkata") || tt.HasText("UTC+09:00 Asia/Tokyo") {
t.Fatalf("name search: %v", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if zone != "Asia/Kolkata" || n != 1 || !tt.HasText("UTC+05:30 · Asia/Kolkata") {
t.Fatalf("Enter picked %q", zone)
}
_ = tt.Click("Time zone")
tt.Type("+12:45")
tt.Key(0, ui.KeyEnter)
if zone != "Pacific/Chatham" {
t.Fatalf("offset search picked %q", zone)
}
}
func TestTimezoneSelectKeysCommonClickEmpty(t *testing.T) {
zone, n := "UTC", 0
tt := timezoneTester(&zone, &TimezoneSelectOptions{Common: []string{"Europe/Paris"}, Zones: []string{"Asia/Tokyo", "Europe/Paris"}}, &n)
_ = tt.Click("Time zone")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if zone != "Asia/Tokyo" || n != 1 {
t.Fatalf("Down then Enter picked %q", zone)
}
_ = tt.Click("Time zone")
_ = tt.Click("UTC+02:00 Europe/Paris")
if zone != "Europe/Paris" {
t.Fatalf("click picked %q", zone)
}
_ = tt.Click("Time zone")
tt.Type("atlantis")
if !tt.HasText("No matching time zone.") {
t.Fatal("no empty state")
}
}
func TestTimezoneSelectDisabledAndInvalid(t *testing.T) {
zone, n := "UTC", 0
tt := timezoneTester(&zone, &TimezoneSelectOptions{Disabled: true}, &n)
_ = tt.Click("Time zone")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if tt.HasText("Common") {
t.Fatal("disabled select opened")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for an unknown zone")
}
}()
bad := "Mars/Olympus"
timezoneTester(&bad, &TimezoneSelectOptions{}, &n)
}
func TestTimezoneOffsetHalfHours(t *testing.T) {
if got := TimezoneOffset(timezoneLoad("America/St_Johns"), tzNow); got != "UTC−02:30" {
t.Fatalf("offset %q", got)
}
}
func TestTimezoneSelectSnapshot(t *testing.T) {
zone := "Asia/Shanghai"
snapshot(t, "timezone_select", 420, 80, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { TimezoneSelect(c, &zone, TimezoneSelectOptions{Now: tzNow}) })
})
}
+97
View File
@@ -0,0 +1,97 @@
package datetime
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
// WeekPickerOptions configure a WeekPicker.
type WeekPickerOptions struct {
WeekStart WeekStart
// Min and Max bound the chosen week's days.
Min, Max core.Date
// Today is marked; zero uses the system clock.
Today core.Date
Clearable bool
Disabled bool
Label string
}
// WeekPicker chooses a week; *value is its first day.
func WeekPicker(c *ui.Context, value *core.Date, opts WeekPickerOptions) core.ChoiceResult {
core.DatetimeCheckDate("WeekPicker value", *value)
core.DatetimeCheckBounds("WeekPicker", opts.Min, opts.Max)
first := CalendarFirstWeekday(c, opts.WeekStart)
weekOf := func(d core.Date) core.Date { return d.AddDays(-((int(d.Weekday()) - int(first) + 7) % 7)) }
if !value.IsZero() && weekOf(*value) != *value {
panic(fmt.Sprintf("mujica: WeekPicker value %v does not start a week", *value))
}
d := core.Density(c)
today := core.DatetimeToday(c, opts.Today)
label := opts.Label
if label == "" {
label = core.Msg(c, "week_picker.label")
}
enabled := func(x core.Date) bool {
w := weekOf(x)
return w.AddDays(6).Within(opts.Min, core.Date{}) && w.Within(core.Date{}, opts.Max)
}
col := ui.Column(c).Width(d.ControlHeight() * 8).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "week-picker-open", func() bool { return false })
cursor := ui.Local(col, "week-picker-cursor", func() core.Date { return core.Date{} })
hover := ui.Local(col, "week-picker-hover", func() core.Date { return core.Date{} })
old := *value
text := ""
if !value.IsZero() {
text = WeekViewTitle(c, *value, value.AddDays(6))
}
col.Children(func() {
field := core.DatetimeField(c, ui.Row(c), false, false)
field.Children(func() {
t := core.DatetimeTrigger(c, text, core.Msg(c, "week_picker.placeholder"), "calendar").Label(label).Description(text)
if !t.IsDisabled() && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open, *hover = !*open, core.Date{}
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
if opts.Clearable && !value.IsZero() {
if b := core.IconAction(c, icons.Must("x"), core.Msg(c, "calendar.clear")); b.Clicked() && !b.IsDisabled() {
*value = core.Date{}
}
}
})
core.DatetimePopup(c, field, open, false, func() {
band := *hover
if band.IsZero() {
band = *cursor
}
picked, hovered := calendarMonth(c, cursor, calendarConfig{
chosen: func(x core.Date) bool { return !value.IsZero() && weekOf(x) == *value },
inRange: func(x core.Date) bool { return weekOf(x) == weekOf(band) },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if hovered != *hover {
*hover = hovered
c.Invalidate()
}
if !picked.IsZero() {
*value, *open = weekOf(picked), false
}
})
})
changed := *value != old
if changed {
slog.Debug("mujica: week_picker change", "from", old, "to", *value)
}
return core.NewChoiceResult(col, changed)
}
+78
View File
@@ -0,0 +1,78 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func weekPickerTester(v *core.Date, opts *WeekPickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
o := *opts
o.Today, o.Label = calendarToday, "Sprint"
ui.Column(c).Padding(16).Children(func() {
if WeekPicker(c, v, o).Changed() {
*n++
}
})
}, 480, 480)
}
func TestWeekPickerPicksWeekStart(t *testing.T) {
v, n := core.Date{}, 0
opts := &WeekPickerOptions{}
tt := weekPickerTester(&v, opts, &n)
_ = tt.Click("Sprint")
_ = tt.Click(datetimeTestLabel(oct(15)))
if v != oct(11) || n != 1 || !tt.HasText("October 11 – October 17, 2026") {
t.Fatalf("Sunday-start week of Oct 15: %v %v", v, tt.Texts())
}
v, opts.WeekStart = oct(12), WeekStartMonday
tt.Frame()
_ = tt.Click("Sprint")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if v != oct(19) || n != 2 {
t.Fatalf("Monday-start keyboard pick %v", v)
}
}
func TestWeekPickerBoundsClearDisabled(t *testing.T) {
v, n := oct(11), 0
opts := &WeekPickerOptions{Min: oct(4), Max: oct(24), Clearable: true}
tt := weekPickerTester(&v, opts, &n)
_ = tt.Click("Sprint")
_ = tt.Click(datetimeTestLabel(oct(26)))
_ = tt.Click(datetimeTestLabel(oct(2)))
if v != oct(11) || n != 0 {
t.Fatalf("out-of-range week chosen: %v", v)
}
tt.Key(0, ui.KeyEscape)
_ = tt.Click("Clear")
if !v.IsZero() || n != 1 {
t.Fatalf("clear left %v", v)
}
opts.Disabled = true
tt.Frame()
_ = tt.Click("Sprint")
if tt.HasText("October 2026") {
t.Fatal("disabled picker opened")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for a mid-week value")
}
}()
mid := oct(14)
weekPickerTester(&mid, &WeekPickerOptions{}, &n)
}
func TestWeekPickerSnapshot(t *testing.T) {
v := oct(11)
snapshot(t, "week_picker", 400, 80, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { WeekPicker(c, &v, WeekPickerOptions{Today: calendarToday, Clearable: true}) })
})
}
+80
View File
@@ -0,0 +1,80 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// YearPickerOptions configure a YearPicker.
type YearPickerOptions struct {
// Min and Max bound the years chosen; zero leaves a side open.
Min, Max int
DisableYear func(int) bool
Clearable bool
// Today marks the current year; zero uses the system clock.
Today core.Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup and clearing.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// YearPicker chooses a year into *value from a decade page; zero is none.
func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResult {
if *value < 0 || opts.Min < 0 || opts.Max < 0 || opts.Min != 0 && opts.Max != 0 && opts.Min > opts.Max {
panic(fmt.Sprintf("mujica: YearPicker value %d or range %d..%d is not valid", *value, opts.Min, opts.Max))
}
now := core.DatetimeToday(c, opts.Today).Year
home := *value
if home == 0 {
home = now
}
decade := func(o int) int { return o / 10 * 10 }
text := ""
if *value != 0 {
text = strconv.Itoa(*value)
}
label := opts.Label
if label == "" {
label = core.Msg(c, "year_picker.placeholder")
}
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
text: text, placeholder: core.Msg(c, "year_picker.placeholder"), label: label,
clearable: opts.Clearable, hasValue: *value != 0, disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home,
}, datetimeGridConfig{
pageOf: func(o int) (int, int) { return decade(o) - 1, 12 },
cols: 3,
page: 10,
title: func(start int) string { return fmt.Sprintf("%d – %d", start+1, start+10) },
prev: core.Msg(c, "year_picker.prev"),
next: core.Msg(c, "year_picker.next"),
text: strconv.Itoa,
label: strconv.Itoa,
chosen: func(o int) bool { return o == *value },
current: func(o int) bool { return o == now },
enabled: func(o int) bool {
return o > 0 && (opts.Min == 0 || o >= opts.Min) && (opts.Max == 0 || o <= opts.Max) &&
(opts.DisableYear == nil || !opts.DisableYear(o))
},
muted: func(o, start int) bool { return o == start || o == start+11 },
})
to := *value
switch {
case cleared:
to = 0
case ok:
to = picked
}
changed := to != *value
if changed {
slog.Debug("mujica: year_picker change", "from", *value, "to", to)
*value = to
}
return DateTimeResult{Element: col, changed: changed}
}
+89
View File
@@ -0,0 +1,89 @@
package datetime
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func yearPickerTester(value *int, opts YearPickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Vintage"
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
ui.Column(c).Padding(16).Children(func() {
if YearPicker(c, value, opts).Changed() {
*changes++
}
})
}, 420, 420)
}
func TestYearPickerClickAndDecade(t *testing.T) {
v, n := 0, 0
tt := yearPickerTester(&v, YearPickerOptions{}, &n)
_ = tt.Click("Vintage")
if !tt.HasText("2020 – 2029") || !tt.HasText("2019") || !tt.HasText("2030") {
t.Fatalf("decade page: %v", tt.Texts())
}
_ = tt.Click("Previous decade")
if err := tt.Click("2014"); err != nil {
t.Fatal(err)
}
if v != 2014 || n != 1 || tt.HasText("2015") {
t.Fatalf("picked %d", v)
}
}
func TestYearPickerKeyboard(t *testing.T) {
v, n := 2026, 0
tt := yearPickerTester(&v, YearPickerOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("2026") {
t.Fatal("popup should focus the chosen year")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if !tt.HasText("2030 – 2039") || !tt.Focused("2030") {
t.Fatalf("crossing the decade should turn the page: %v", tt.Texts())
}
tt.Key(0, ui.KeyPageUp)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeySpace)
if v != 2017 || n != 1 {
t.Fatalf("Space chose %d", v)
}
}
func TestYearPickerLimitsAndClear(t *testing.T) {
v, n := 2024, 0
opts := YearPickerOptions{Min: 2022, Max: 2027, Clearable: true, DisableYear: func(y int) bool { return y == 2025 }}
tt := yearPickerTester(&v, opts, &n)
_ = tt.Click("Vintage")
for _, y := range []string{"2021", "2025", "2028"} {
_ = tt.Click(y)
}
if v != 2024 || n != 0 {
t.Fatalf("disabled year chosen: %d", v)
}
_ = tt.Click("2026")
_ = tt.Click("Clear")
if v != 0 || n != 2 {
t.Fatalf("clear left %d (%d)", v, n)
}
}
func TestYearPickerSnapshot(t *testing.T) {
a, b := 2026, 0
snapshot(t, "year_picker", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
YearPicker(c, &b, YearPickerOptions{Today: calendarToday})
YearPicker(c, &a, YearPickerOptions{Today: calendarToday, Clearable: true})
})
})
}
+169
View File
@@ -0,0 +1,169 @@
package datetime
import (
"fmt"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// YearViewOptions configure a YearView.
type YearViewOptions struct {
// Location is the zone days are cut in; required.
Location *time.Location
// Version must change when events change in place.
Version int
WeekStart WeekStart
// Today is marked; zero uses the system clock.
Today core.Date
Disabled bool
Label string
}
// YearView shows twelve months with busy-day dots.
func YearView(c *ui.Context, year *int, events []CalendarEvent, opts YearViewOptions) CalendarViewResult {
loc := eventLocation("YearView", opts.Location)
if *year < 1 {
panic(fmt.Sprintf("mujica: YearView year %d is not valid", *year))
}
eventsCheck("YearView", events)
d := core.Density(c)
today := core.DatetimeToday(c, opts.Today)
first := core.Date{Year: *year, Month: time.January, Day: 1}
label := opts.Label
if label == "" {
label = strconv.Itoa(*year)
}
r := CalendarViewResult{}
root := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
counts := eventsDerived(root, "year-view-counts", events, opts.Version, eventDayStart(first, loc), eventDayStart(first.AddMonths(12), loc),
func(hits []int) map[core.Date]int {
m := map[core.Date]int{}
for _, h := range hits {
ev := events[h]
for day := core.DateOf(ev.Start.In(loc)); eventDayStart(day, loc).Before(ev.End); day = day.AddDays(1) {
m[day]++
}
}
return m
})
cursor := ui.Local(root, "year-view-cursor", func() core.Date { return core.Date{} })
if cursor.Year != *year {
*cursor = core.Date{Year: *year, Month: today.Month, Day: min(today.Day, 28)}
}
root.Children(func() {
calendarViewHeader(c, strconv.Itoa(*year), core.Msg(c, "calendar.prev_year"), core.Msg(c, "calendar.next_year"), func(dir int) {
*year += dir
})
grid := ui.Row(c).Wrap().AlignItems(ui.Start).Gap(d.Unit() * 4).FocusGroup(ui.Horizontal | ui.Vertical)
moved := false
if !grid.IsDisabled() {
for key, days := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1, ui.KeyUp: -7, ui.KeyDown: 7} {
if grid.Shortcut(0, key) {
*cursor, moved = cursor.AddDays(days), true
}
}
for key, months := range map[ui.Key]int{ui.KeyPageUp: -1, ui.KeyPageDown: 1} {
if grid.Shortcut(0, key) {
*cursor, moved = cursor.AddMonths(months), true
}
}
if moved && cursor.Year != *year {
*year = cursor.Year
}
}
grid.Children(func() {
for m := time.January; m <= time.December; m++ {
if day, ok := yearMonth(c, *year, m, counts, today, *cursor, moved, opts.WeekStart); ok && !grid.IsDisabled() {
r.day, *cursor = day, day
}
}
})
})
return r
}
// yearMonth draws one small month and reports a day clicked.
func yearMonth(c *ui.Context, year int, m time.Month, counts map[core.Date]int, today, cursor core.Date, focus bool, ws WeekStart) (core.Date, bool) {
k := core.Tokens(c)
cell := theme.MinHitSize
first := CalendarFirstWeekday(c, ws)
start := core.Date{Year: year, Month: m, Day: 1}
lead := (int(start.Weekday()) - int(first) + 7) % 7
var picked core.Date
ui.Column(c).Width(cell * 7).Gap(2).Children(func() {
ui.Text(c, core.DatetimeMonthName(c, m)).Font(theme.SerifFont).TextColor(k.Text).Bold()
ui.Row(c).Children(func() {
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, core.Msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center).
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
last := start.AddMonths(1).AddDays(-1).Day
for w := 0; w*7 < lead+last; w++ {
ui.Row(c).Children(func() {
for i := range 7 {
n := w*7 + i - lead + 1
if n < 1 || n > last {
ui.Box(c).Size(cell, cell)
continue
}
day := core.Date{Year: year, Month: m, Day: n}
busy := counts[day]
text := core.DatetimeDayLabel(c, day)
if busy > 0 {
text += ", " + core.CalendarCount(c, "year_view.events", busy)
}
b := ui.ButtonBase(c).Size(cell, cell).Radius(theme.ControlRadius).Label(text).Column()
internal.Ring(b, k.Focus, theme.ControlRadius)
if day == cursor && focus {
b.Focus()
}
switch {
case day == today:
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
dots := yearDots(busy)
b.Draw(func(p *ui.Painter, r ui.Rect) {
x0 := r.X + r.W/2 - float32(dots*5-2)/2
for j := range dots {
p.Fill(ui.Rect{X: x0 + float32(j)*5, Y: r.Y + r.H - 6, W: 3, H: 3}, k.AccentText, 1.5)
}
})
b.Children(func() {
t := ui.Text(c, strconv.Itoa(n)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).FontFeatures("tnum")
if day == today {
t.Bold().TextColor(k.AccentText)
}
})
if b.Clicked() {
picked = day
}
}
})
}
})
return picked, !picked.IsZero()
}
// yearDots maps an event count to one to three dots.
func yearDots(n int) int {
switch {
case n == 0:
return 0
case n <= 2:
return 1
case n <= 5:
return 2
}
return 3
}
+109
View File
@@ -0,0 +1,109 @@
package datetime
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func yearViewTester(year *int, events []CalendarEvent, opts YearViewOptions, days *[]core.Date) *ui.Tester {
opts.Location, opts.Today = calTokyo, calendarToday
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Mode: core.Light})
if d, ok := YearView(c, year, events, opts).DayChosen(); ok {
*days = append(*days, d)
}
}, 900, 1200)
}
func TestYearViewDensityMarks(t *testing.T) {
var evs []CalendarEvent
for i := range 4 {
evs = append(evs, calEvent(fmt.Sprint(i), 14, 9+i, 0, 30))
}
evs = append(evs, calEvent("x", 20, 9, 0, 30))
year := 2026
var days []core.Date
tt := yearViewTester(&year, evs, YearViewOptions{}, &days)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !tt.HasText("Wednesday, October 14, 2026, 4 events") || !tt.HasText("Tuesday, October 20, 2026, 1 event") {
t.Fatalf("labels lack counts: %v", tt.Texts()[:5])
}
img := tt.Image()
dotsAt := func(label string) int {
r, _ := tt.Find(label)
n, prev := 0, false
for x := int(r.X); x < int(r.X+r.W); x++ {
on := datetimeNear(img.RGBAAt(x, int(r.Y+r.H-5)), theme.Light().AccentText)
if on && !prev {
n++
}
prev = on
}
return n
}
if a, b, z := dotsAt("Wednesday, October 14, 2026, 4 events"), dotsAt("Tuesday, October 20, 2026, 1 event"), dotsAt(datetimeTestLabel(oct(21))); a != 2 || b != 1 || z != 0 {
t.Fatalf("dots %d %d %d", a, b, z)
}
if yearDots(6) != 3 {
t.Fatal("6 events should show three dots")
}
}
func TestYearViewClickKeysAndYearChange(t *testing.T) {
year := 2026
var days []core.Date
tt := yearViewTester(&year, nil, YearViewOptions{}, &days)
_ = tt.Click(datetimeTestLabel(core.Date{Year: 2026, Month: time.March, Day: 3}))
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(days) != 2 || days[0] != (core.Date{Year: 2026, Month: time.March, Day: 3}) || days[1] != (core.Date{Year: 2026, Month: time.March, Day: 11}) {
t.Fatalf("days %v", days)
}
_ = tt.Click(datetimeTestLabel(core.Date{Year: 2026, Month: time.December, Day: 31}))
tt.Key(0, ui.KeyRight)
if year != 2027 || !tt.Focused(datetimeTestLabel(core.Date{Year: 2027, Month: time.January, Day: 1})) {
t.Fatalf("crossing the year: %d", year)
}
_ = tt.Click("Previous year")
if year != 2026 {
t.Fatalf("year %d", year)
}
}
func TestYearViewDisabledAndInvalid(t *testing.T) {
year := 2026
var days []core.Date
tt := yearViewTester(&year, nil, YearViewOptions{Disabled: true}, &days)
_ = tt.Click(datetimeTestLabel(oct(3)))
_ = tt.Click("Next year")
if len(days) != 0 || year != 2026 {
t.Fatal("disabled year view reacted")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for year 0")
}
}()
zero := 0
yearViewTester(&zero, nil, YearViewOptions{}, &days)
}
func TestYearViewSnapshot(t *testing.T) {
var evs []CalendarEvent
for i := range 40 {
evs = append(evs, calEvent(fmt.Sprint(i), 1+i%28, 9+i%5, 0, 30))
}
year := 2026
snapshot(t, "year_view", 680, 960, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
YearView(c, &year, evs, YearViewOptions{Location: calTokyo, Today: calendarToday})
})
})
}