Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 }})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package datetime holds MujicaUI date and time pickers and calendar views.
|
||||
package datetime
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user