Calendar: components 421-440
No files matched your search
@@ -0,0 +1,149 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AgendaViewOptions configure an AgendaView.
|
||||
type AgendaViewOptions struct {
|
||||
// Location is the zone days are cut in; required.
|
||||
Location *time.Location
|
||||
// Version must change when events change in place.
|
||||
Version int
|
||||
// From is the first day listed; zero means today.
|
||||
From Date
|
||||
// Days is how many days are listed; zero means 30.
|
||||
Days int
|
||||
// Today is marked; zero uses the system clock.
|
||||
Today Date
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// agendaRow is a day heading (ev < 0) or event.
|
||||
type agendaRow struct {
|
||||
day Date
|
||||
ev int
|
||||
}
|
||||
|
||||
// AgendaView lists events by day, building only visible rows.
|
||||
func AgendaView(c *ui.Context, events []CalendarEvent, opts AgendaViewOptions) CalendarViewResult {
|
||||
loc := eventLocation("AgendaView", opts.Location)
|
||||
if opts.Days < 0 {
|
||||
panic("mujica: AgendaView Days is negative")
|
||||
}
|
||||
days := opts.Days
|
||||
if days == 0 {
|
||||
days = 30
|
||||
}
|
||||
eventsCheck("AgendaView", events)
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
today := datetimeToday(c, opts.Today)
|
||||
from := opts.From
|
||||
if from.IsZero() {
|
||||
from = today
|
||||
}
|
||||
datetimeCheckDate("AgendaView From", from)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "agenda_view.label")
|
||||
}
|
||||
r := CalendarViewResult{}
|
||||
root := ui.Column(c).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = root
|
||||
rows := eventsDerived(root, "agenda-rows", events, opts.Version, eventDayStart(from, loc), eventDayStart(from.AddDays(days), loc),
|
||||
func(hits []int) []agendaRow { return agendaRows(events, hits, from, days, loc) })
|
||||
state := ui.Local(root, "agenda-list", func() *ui.ListState { return &ui.ListState{} })
|
||||
st := *state
|
||||
st.Key = func(i int) any { return rows[i] }
|
||||
st.Header = func(i int) bool { return rows[i].ev < 0 }
|
||||
st.Label = func(i int) string {
|
||||
if rows[i].ev < 0 {
|
||||
return datetimeDayLabel(c, rows[i].day)
|
||||
}
|
||||
return events[rows[i].ev].Title
|
||||
}
|
||||
root.Children(func() {
|
||||
list := ui.List(c, st, len(rows), func(i int) {
|
||||
row := rows[i]
|
||||
if row.ev < 0 {
|
||||
h := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit() * 2).PaddingX(d.Unit() * 3).Background(k.Background).Role(ui.RoleColumnHeader)
|
||||
h.Children(func() {
|
||||
t := ui.Text(c, datetimeDayLabel(c, row.day)).Font(theme.SerifFont).TextColor(k.Text)
|
||||
if row.day == today {
|
||||
t.Bold().TextColor(k.AccentText)
|
||||
ui.Text(c, msg(c, "agenda_view.today")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
h.DrawOver(func(p *ui.Painter, rr ui.Rect) { internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+rr.H-1, k.Ornament) })
|
||||
return
|
||||
}
|
||||
if agendaItem(c, events[row.ev], loc, opts.Hour12) && !root.IsDisabled() {
|
||||
r.opened = events[row.ev].ID
|
||||
}
|
||||
}).Grow(1).MinHeight(d.ControlHeight() * 4).FocusGroup(ui.Vertical)
|
||||
list.Children(func() {
|
||||
if len(rows) == 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "agenda_view.empty"), days)).TextColor(k.TextMuted).Padding(d.Unit() * 3)
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// agendaRows flattens the span into headed days.
|
||||
func agendaRows(events []CalendarEvent, hits []int, from Date, days int, loc *time.Location) []agendaRow {
|
||||
buckets := make([][]int, days)
|
||||
for _, h := range hits {
|
||||
ev := events[h]
|
||||
first := max(0, monthDays(ev.Start.In(loc), from)*-1)
|
||||
last := min(days-1, -monthDays(ev.End.Add(-time.Nanosecond).In(loc), from))
|
||||
for i := first; i <= last; i++ {
|
||||
buckets[i] = append(buckets[i], h)
|
||||
}
|
||||
}
|
||||
var out []agendaRow
|
||||
for i, b := range buckets {
|
||||
if len(b) > 0 {
|
||||
out = append(out, agendaRow{from.AddDays(i), -1})
|
||||
for _, h := range b {
|
||||
out = append(out, agendaRow{from.AddDays(i), h})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// agendaItem is one event row; it reports a click.
|
||||
func agendaItem(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
when := eventTimeText(c, ev, loc, hour12)
|
||||
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(d.Unit() * 3).PaddingX(d.Unit() * 3).Label(ev.Title + ", " + when)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
col := eventColor(k, ev.Color)
|
||||
b.Children(func() {
|
||||
ui.Text(c, when).Width(d.ControlHeight() * 3.5).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), col)
|
||||
})
|
||||
ui.Column(c).Grow(1).Shrink(1).Children(func() {
|
||||
ui.Text(c, ev.Title).TextColor(k.Text).SingleLine().Ellipsis("…")
|
||||
if ev.Location != "" {
|
||||
ui.Text(c, ev.Location).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…")
|
||||
}
|
||||
})
|
||||
})
|
||||
return b.Clicked()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func agendaTester(events []CalendarEvent, opts AgendaViewOptions, opened *[]string) *ui.Tester {
|
||||
opts.Location, opts.Today = calTokyo, calendarToday
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Height(500).Children(func() {
|
||||
if id, ok := AgendaView(c, events, opts).Opened(); ok {
|
||||
*opened = append(*opened, id)
|
||||
}
|
||||
})
|
||||
}, 600, 520)
|
||||
}
|
||||
|
||||
func TestAgendaViewGroupsByDay(t *testing.T) {
|
||||
evs := []CalendarEvent{calEvent("b", 7, 14, 0, 60), calEvent("a", 7, 9, 0, 30), calEvent("c", 9, 10, 0, 60), calEvent("old", 1, 9, 0, 60)}
|
||||
evs[0].Location = "Rehearsal hall"
|
||||
var opened []string
|
||||
tt := agendaTester(evs, AgendaViewOptions{}, &opened)
|
||||
texts := tt.Texts()
|
||||
idx := func(s string) int {
|
||||
for i, x := range texts {
|
||||
if x == s {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
d7, a, b, d9, c := idx("Wednesday, October 7, 2026"), idx("Event a, 09:00 – 09:30"), idx("Event b, 14:00 – 15:00"), idx("Friday, October 9, 2026"), idx("Event c, 10:00 – 11:00")
|
||||
if d7 < 0 || !(d7 < a && a < b && b < d9 && d9 < c) || tt.HasText("Event old") || !tt.HasText("Rehearsal hall") {
|
||||
t.Fatalf("order %d %d %d %d %d in %v", d7, a, b, d9, c, texts)
|
||||
}
|
||||
_ = tt.Click("Event b, 14:00 – 15:00")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(opened) != 2 || opened[0] != "b" || opened[1] != "c" {
|
||||
t.Fatalf("opened %v", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgendaViewMultiDayEmptyAndDisabled(t *testing.T) {
|
||||
trip := calEvent("t", 6, 20, 0, 26*60)
|
||||
var opened []string
|
||||
tt := agendaTester([]CalendarEvent{trip}, AgendaViewOptions{Days: 3}, &opened)
|
||||
if !tt.HasText("Tuesday, October 6, 2026") || !tt.HasText("Wednesday, October 7, 2026") {
|
||||
t.Fatalf("a multi-day event lists on each day: %v", tt.Texts())
|
||||
}
|
||||
tt = agendaTester(nil, AgendaViewOptions{Days: 7}, &opened)
|
||||
if !tt.HasText("No events in the next 7 days.") {
|
||||
t.Fatal("no empty state")
|
||||
}
|
||||
tt = agendaTester([]CalendarEvent{calEvent("a", 6, 9, 0, 30)}, AgendaViewOptions{Disabled: true}, &opened)
|
||||
_ = tt.Click("Event a, 09:00 – 09:30")
|
||||
if len(opened) != 0 {
|
||||
t.Fatal("disabled agenda opened")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
agendaTester(nil, AgendaViewOptions{Days: -1}, &opened)
|
||||
}
|
||||
|
||||
func TestAgendaViewLargeData(t *testing.T) {
|
||||
var evs []CalendarEvent
|
||||
base := time.Date(2026, time.October, 5, 0, 0, 0, 0, calTokyo)
|
||||
for i := range 100000 {
|
||||
s := base.Add(time.Duration(i%(365*24)) * time.Hour)
|
||||
evs = append(evs, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(30 * time.Minute)})
|
||||
}
|
||||
var opened []string
|
||||
tt := agendaTester(evs, AgendaViewOptions{Days: 365}, &opened)
|
||||
if n := len(tt.Texts()); n > 200 {
|
||||
t.Fatalf("built %d labels", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgendaViewSnapshot(t *testing.T) {
|
||||
evs := []CalendarEvent{calEvent("a", 5, 9, 0, 30), calEvent("b", 5, 14, 0, 60), calEvent("c", 7, 10, 0, 60), calEvent("d", 8, 18, 0, 120)}
|
||||
evs[1].Location, evs[1].Color, evs[3].Color = "Rehearsal hall", EventInfo, EventSuccess
|
||||
snapshot(t, "agenda_view", 520, 420, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Height(420).Children(func() {
|
||||
AgendaView(c, evs, AgendaViewOptions{Location: calTokyo, Today: calendarToday})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -46,7 +46,7 @@ type AttendeeListResult struct {
|
||||
// Removed returns the index of the attendee the user removed.
|
||||
func (r AttendeeListResult) Removed() (int, bool) { return r.removed, r.removed >= 0 }
|
||||
|
||||
// AttendeeList shows invitees with their reply, in words and an icon, and a reply summary.
|
||||
// AttendeeList shows invitees, their replies and a summary.
|
||||
func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
@@ -71,7 +71,7 @@ func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions)
|
||||
ui.Text(c, msg(c, "attendee_list.empty")).TextColor(k.TextMuted)
|
||||
}
|
||||
for i, a := range attendees {
|
||||
ui.Row(c).Key(i).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
|
||||
ui.Row(c).Key(i).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
|
||||
Avatar(c, a.Name, AvatarOptions{Size: 28})
|
||||
ui.Column(c).Grow(1).Shrink(1).Children(func() {
|
||||
name := a.Name
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var calAttendees = []Attendee{
|
||||
{Name: "Sakiko Togawa", Email: "sakiko@court.example", Status: AttendeeAccepted, Organizer: true},
|
||||
{Name: "Mutsumi Wakaba", Status: AttendeeTentative},
|
||||
{Name: "Uika Misumi", Email: "uika@court.example", Status: AttendeeDeclined, Optional: true},
|
||||
{Name: "Nyamu Yuutenji", Status: AttendeePending},
|
||||
}
|
||||
|
||||
func attendeeTester(list *[]Attendee, opts AttendeeListOptions) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if i, ok := AttendeeList(c, *list, opts).Removed(); ok {
|
||||
*list = append((*list)[:i:i], (*list)[i+1:]...)
|
||||
}
|
||||
}, 480, 400)
|
||||
}
|
||||
|
||||
func TestAttendeeListStatusesAndSummary(t *testing.T) {
|
||||
list := append([]Attendee(nil), calAttendees...)
|
||||
tt := attendeeTester(&list, AttendeeListOptions{})
|
||||
for _, s := range []string{"1 accepted · 1 maybe · 1 declined · 1 awaiting", "Sakiko Togawa · Organizer",
|
||||
"Uika Misumi · Optional", "Accepted", "Maybe", "Declined", "Awaiting reply", "uika@court.example"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
if tt.HasText("Remove Mutsumi Wakaba") {
|
||||
t.Fatal("remove buttons without Removable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendeeListRemoveKeyboardAndOrganizer(t *testing.T) {
|
||||
list := append([]Attendee(nil), calAttendees...)
|
||||
tt := attendeeTester(&list, AttendeeListOptions{Removable: true})
|
||||
if tt.HasText("Remove Sakiko Togawa") {
|
||||
t.Fatal("the organizer cannot be removed")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(list) != 3 || list[0].Name != "Sakiko Togawa" || list[1].Name != "Uika Misumi" {
|
||||
t.Fatalf("keyboard remove left %v", list)
|
||||
}
|
||||
_ = tt.Click("Remove Uika Misumi")
|
||||
if len(list) != 2 || !tt.HasText("1 accepted · 0 maybe · 0 declined · 1 awaiting") {
|
||||
t.Fatalf("click remove left %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendeeListDisabledEmptyInvalid(t *testing.T) {
|
||||
list := append([]Attendee(nil), calAttendees...)
|
||||
tt := attendeeTester(&list, AttendeeListOptions{Removable: true, Disabled: true})
|
||||
_ = tt.Click("Remove Mutsumi Wakaba")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(list) != 4 {
|
||||
t.Fatal("disabled list removed someone")
|
||||
}
|
||||
empty := []Attendee{}
|
||||
if tt := attendeeTester(&empty, AttendeeListOptions{}); !tt.HasText("No attendees yet.") {
|
||||
t.Fatal("no empty state")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic on bad status")
|
||||
}
|
||||
}()
|
||||
bad := []Attendee{{Name: "X", Status: 7}}
|
||||
attendeeTester(&bad, AttendeeListOptions{})
|
||||
}
|
||||
|
||||
func TestAttendeeListSnapshot(t *testing.T) {
|
||||
snapshot(t, "attendee_list", 420, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { AttendeeList(c, calAttendees, AttendeeListOptions{Removable: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AvailabilityPerson is another person's free slots, [day][slot].
|
||||
type AvailabilityPerson struct {
|
||||
Name string
|
||||
Free [][]bool
|
||||
}
|
||||
|
||||
// AvailabilityPickerOptions configure an AvailabilityPicker.
|
||||
type AvailabilityPickerOptions struct {
|
||||
// Days are the grid's columns; required.
|
||||
Days []Date
|
||||
// From and To are hours; zero means 9 to 18.
|
||||
From, To int
|
||||
// SlotMinutes must divide an hour; zero means 30.
|
||||
SlotMinutes int
|
||||
// Others are counted as free per slot.
|
||||
Others []AvailabilityPerson
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// availabilityDrag is a paint stroke: the value being painted.
|
||||
type availabilityDrag struct {
|
||||
active bool
|
||||
paint bool
|
||||
}
|
||||
|
||||
// AvailabilityPicker paints free slots and counts others' overlap.
|
||||
func AvailabilityPicker(c *ui.Context, mine *[][]bool, opts AvailabilityPickerOptions) ChoiceResult {
|
||||
from, to := opts.From, opts.To
|
||||
if from == 0 && to == 0 {
|
||||
from, to = 9, 18
|
||||
}
|
||||
slot := timeGridSlot("AvailabilityPicker", opts.SlotMinutes)
|
||||
if len(opts.Days) == 0 || from < 0 || to > 24 || to <= from || slot > 60 {
|
||||
panic(fmt.Sprintf("mujica: AvailabilityPicker needs days and hours 0 <= %d < %d <= 24", from, to))
|
||||
}
|
||||
slots := (to - from) * 60 / slot
|
||||
availabilityCheck("value", *mine, len(opts.Days), slots)
|
||||
for _, p := range opts.Others {
|
||||
availabilityCheck(p.Name, p.Free, len(opts.Days), slots)
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "availability_picker.label")
|
||||
}
|
||||
gutter := d.ControlHeight() * 1.8
|
||||
rowH := float32(theme.MinHitSize)
|
||||
headH := d.ControlHeight()
|
||||
changed := false
|
||||
set := func(day, s int, v bool) {
|
||||
if (*mine)[day][s] != v {
|
||||
(*mine)[day][s], changed = v, true
|
||||
}
|
||||
}
|
||||
grid := ui.Column(c).Focusable().Role(ui.RoleTable).Label(label).Disabled(opts.Disabled)
|
||||
internal.Ring(grid, k.Focus, theme.ControlRadius)
|
||||
drag := ui.Local(grid, "availability-drag", func() availabilityDrag { return availabilityDrag{} })
|
||||
cursor := ui.Local(grid, "availability-cursor", func() [2]int { return [2]int{} })
|
||||
if !grid.IsDisabled() {
|
||||
b := grid.Bounds()
|
||||
px, py, _ := grid.PointerPosition()
|
||||
cw := (b.W - gutter) / float32(len(opts.Days))
|
||||
day, s := int((px-gutter)/max(cw, 1)), int((py-headH)/rowH)
|
||||
inside := px >= gutter && py >= headH && day < len(opts.Days) && s < slots
|
||||
if _, _, pressing := grid.Dragged(); pressing && inside {
|
||||
if !drag.active {
|
||||
*drag = availabilityDrag{true, !(*mine)[day][s]}
|
||||
*cursor = [2]int{day, s}
|
||||
}
|
||||
set(day, s, drag.paint)
|
||||
} else if !pressing {
|
||||
drag.active = false
|
||||
}
|
||||
for key, dd := range map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, -1}, ui.KeyDown: {0, 1}} {
|
||||
if grid.Shortcut(0, key) {
|
||||
*cursor = [2]int{max(0, min(len(opts.Days)-1, cursor[0]+dd[0])), max(0, min(slots-1, cursor[1]+dd[1]))}
|
||||
}
|
||||
}
|
||||
if grid.Shortcut(0, ui.KeySpace) || grid.Shortcut(0, ui.KeyEnter) {
|
||||
set(cursor[0], cursor[1], !(*mine)[cursor[0]][cursor[1]])
|
||||
}
|
||||
}
|
||||
focused := grid.FocusVisible() || grid.Focused() && !drag.active
|
||||
grid.Children(func() {
|
||||
ui.Row(c).Height(headH).Children(func() {
|
||||
ui.Box(c).Width(gutter).Shrink(0)
|
||||
for _, day := range opts.Days {
|
||||
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))+" "+strconv.Itoa(day.Day)).Grow(1).Basis(0).
|
||||
TextAlign(ui.Center).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleColumnHeader)
|
||||
}
|
||||
})
|
||||
for s := range slots {
|
||||
m := from*60 + s*slot
|
||||
ui.Row(c).Height(rowH).Role(ui.RoleRow).Children(func() {
|
||||
hour := ""
|
||||
if m%60 == 0 {
|
||||
hour = timeText(c, TimeOfDay{m / 60, 0, 0}, timeConfig{hour12: opts.Hour12})
|
||||
}
|
||||
ui.Text(c, hour).Width(gutter).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
for di, day := range opts.Days {
|
||||
free := 0
|
||||
for _, p := range opts.Others {
|
||||
if p.Free[di][s] {
|
||||
free++
|
||||
}
|
||||
}
|
||||
availabilityCell(c, day, m, (*mine)[di][s], free, len(opts.Others), focused && *cursor == [2]int{di, s}, opts.Hour12)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if changed {
|
||||
slog.Debug("mujica: availability_picker change")
|
||||
}
|
||||
return ChoiceResult{Element: grid, changed: changed}
|
||||
}
|
||||
|
||||
func availabilityCheck(name string, v [][]bool, days, slots int) {
|
||||
if len(v) != days {
|
||||
panic(fmt.Sprintf("mujica: AvailabilityPicker %s has %d days, want %d", name, len(v), days))
|
||||
}
|
||||
for _, row := range v {
|
||||
if len(row) != slots {
|
||||
panic(fmt.Sprintf("mujica: AvailabilityPicker %s has %d slots, want %d", name, len(row), slots))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// availabilityCell shows one slot's choice and free count.
|
||||
func availabilityCell(c *ui.Context, day Date, m int, mine bool, free, others int, cursor, hour12 bool) {
|
||||
k := Tokens(c)
|
||||
text := datetimeDayLabel(c, day) + " " + timeText(c, TimeOfDay{m / 60, m % 60, 0}, timeConfig{hour12: hour12})
|
||||
if mine {
|
||||
text += ", " + msg(c, "availability_picker.mine")
|
||||
}
|
||||
if others > 0 {
|
||||
text += ", " + fmt.Sprintf(msg(c, "availability_picker.others"), free, others)
|
||||
}
|
||||
everyone := mine && others > 0 && free == others
|
||||
cell := ui.Box(c).Grow(1).Basis(0).MinWidth(0).FillHeight().Center().Role(ui.RoleCell).Label(text).BorderWidth(0, 0, theme.BorderWidth, theme.BorderWidth).BorderColor(k.Border)
|
||||
if mine {
|
||||
cell.Background(k.Selection)
|
||||
}
|
||||
cell.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if others > 0 && free > 0 {
|
||||
h := (r.H - 4) * float32(free) / float32(others)
|
||||
p.Fill(ui.Rect{X: r.X + 2, Y: r.Y + r.H - 2 - h, W: 3, H: h}, k.Info, 1)
|
||||
}
|
||||
if mine {
|
||||
internal.Mark(p, ui.Rect{X: r.X + r.W - 16, Y: r.Y + 2, W: 12, H: 12}, "check", k.AccentText)
|
||||
}
|
||||
if cursor {
|
||||
datetimeSelectedMark(p, r, k.Focus)
|
||||
}
|
||||
})
|
||||
cell.Children(func() {
|
||||
if others > 0 && free > 0 {
|
||||
t := ui.Text(c, strconv.Itoa(free)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
if everyone {
|
||||
t.Bold().TextColor(k.AccentText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func availabilityGrid(days, slots int) [][]bool {
|
||||
v := make([][]bool, days)
|
||||
for i := range v {
|
||||
v[i] = make([]bool, slots)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
var availabilityDays = []Date{oct(12), oct(13), oct(14)}
|
||||
|
||||
func availabilityTester(v *[][]bool, opts *AvailabilityPickerOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
o := *opts
|
||||
o.Days, o.From, o.To = availabilityDays, 9, 12
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if AvailabilityPicker(c, v, o).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 560, 360)
|
||||
}
|
||||
|
||||
func TestAvailabilityPickerPaintDrag(t *testing.T) {
|
||||
v, n := availabilityGrid(3, 6), 0
|
||||
tt := availabilityTester(&v, &AvailabilityPickerOptions{}, &n)
|
||||
a, _ := tt.Find("Monday, October 12, 2026 09:00")
|
||||
b, _ := tt.Find("Monday, October 12, 2026 10:30")
|
||||
calDrag(tt, a.X+a.W/2, a.Y+a.H/2, b.X+b.W/2, b.Y+b.H/2)
|
||||
for s := range 6 {
|
||||
if v[0][s] != (s <= 3) {
|
||||
t.Fatalf("painted %v", v[0])
|
||||
}
|
||||
}
|
||||
if n == 0 || !tt.HasText("Monday, October 12, 2026 10:00, you are free") {
|
||||
t.Fatal("no change or label")
|
||||
}
|
||||
// Starting on a free slot erases.
|
||||
calDrag(tt, a.X+a.W/2, a.Y+a.H/2, a.X+a.W/2, a.Y+a.H*1.5)
|
||||
if v[0][0] || v[0][1] || !v[0][2] {
|
||||
t.Fatalf("erase stroke %v", v[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityPickerOthersAndKeyboard(t *testing.T) {
|
||||
v, n := availabilityGrid(3, 6), 0
|
||||
free := availabilityGrid(3, 6)
|
||||
free[1][0] = true
|
||||
other := availabilityGrid(3, 6)
|
||||
other[1][0], other[1][1] = true, true
|
||||
opts := &AvailabilityPickerOptions{Others: []AvailabilityPerson{{Name: "Mutsumi", Free: free}, {Name: "Uika", Free: other}}}
|
||||
tt := availabilityTester(&v, opts, &n)
|
||||
if !tt.HasText("Tuesday, October 13, 2026 09:00, 2 of 2 others free") || !tt.HasText("Tuesday, October 13, 2026 09:30, 1 of 2 others free") {
|
||||
t.Fatalf("overlap counts: %v", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !v[1][0] || !v[1][1] || n != 2 {
|
||||
t.Fatalf("keyboard toggles %v", v[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityPickerDisabledAndInvalid(t *testing.T) {
|
||||
v, n := availabilityGrid(3, 6), 0
|
||||
tt := availabilityTester(&v, &AvailabilityPickerOptions{Disabled: true}, &n)
|
||||
a, _ := tt.Find("Monday, October 12, 2026 09:00")
|
||||
calDrag(tt, a.X+a.W/2, a.Y+a.H/2, a.X+a.W/2, a.Y+a.H*2.5)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if v[0][0] || n != 0 {
|
||||
t.Fatal("disabled picker painted")
|
||||
}
|
||||
for _, bad := range []*AvailabilityPickerOptions{{SlotMinutes: 7}, {Others: []AvailabilityPerson{{Name: "X", Free: availabilityGrid(2, 6)}}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: expected panic", bad)
|
||||
}
|
||||
}()
|
||||
w := availabilityGrid(3, 6)
|
||||
availabilityTester(&w, bad, &n)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityPickerSnapshot(t *testing.T) {
|
||||
v := availabilityGrid(3, 6)
|
||||
v[0][1], v[0][2], v[1][2] = true, true, true
|
||||
free := availabilityGrid(3, 6)
|
||||
free[0][2], free[1][2], free[2][0] = true, true, true
|
||||
snapshot(t, "availability_picker", 520, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
AvailabilityPicker(c, &v, AvailabilityPickerOptions{Days: availabilityDays, From: 9, To: 12,
|
||||
Others: []AvailabilityPerson{{Name: "Mutsumi", Free: free}}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// CalendarDayViewOptions configure a CalendarDayView.
|
||||
type CalendarDayViewOptions struct {
|
||||
// Location is the zone the day is cut in; required.
|
||||
Location *time.Location
|
||||
// Version must change when events change in place.
|
||||
Version int
|
||||
// SlotMinutes is the grid step; zero means 30.
|
||||
SlotMinutes int
|
||||
// Today is marked; zero uses the system clock.
|
||||
Today Date
|
||||
// Now places the current-time line; zero uses the system clock.
|
||||
Now time.Time
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// CalendarDayView shows one day's time grid.
|
||||
func CalendarDayView(c *ui.Context, day *Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult {
|
||||
datetimeCheckDate("CalendarDayView day", *day)
|
||||
if day.IsZero() {
|
||||
panic("mujica: CalendarDayView needs a day")
|
||||
}
|
||||
cfg := timeGridConfig{name: "calendar_day_view", loc: eventLocation("CalendarDayView", opts.Location), version: opts.Version,
|
||||
slot: timeGridSlot("CalendarDayView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12}
|
||||
eventsCheck("CalendarDayView", events)
|
||||
title := datetimeDayLabel(c, *day)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = title
|
||||
}
|
||||
r := CalendarViewResult{}
|
||||
root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = root
|
||||
root.Children(func() {
|
||||
calendarViewHeader(c, title, msg(c, "calendar_day_view.prev"), msg(c, "calendar_day_view.next"), func(dir int) {
|
||||
*day = day.AddDays(dir)
|
||||
})
|
||||
timeGrid(c, root, []Date{*day}, events, cfg, &r)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func dayViewTester(day *Date, events []CalendarEvent, opts CalendarDayViewOptions, changes *[]CalendarChange) *ui.Tester {
|
||||
opts.Location, opts.Today = calTokyo, calendarToday
|
||||
opts.Now = time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ch, ok := CalendarDayView(c, day, events, opts).Changed(); ok {
|
||||
*changes = append(*changes, ch)
|
||||
}
|
||||
}, 600, 900)
|
||||
}
|
||||
|
||||
func TestCalendarDayViewOverlapSideBySide(t *testing.T) {
|
||||
day := oct(14)
|
||||
evs := []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 9, 30, 60), calEvent("c", 14, 10, 0, 60), calEvent("d", 14, 12, 0, 30)}
|
||||
var ch []CalendarChange
|
||||
tt := dayViewTester(&day, evs, CalendarDayViewOptions{}, &ch)
|
||||
a, _ := tt.Find("Event a, 09:00 – 11:00")
|
||||
b, _ := tt.Find("Event b, 09:30 – 10:30")
|
||||
c, _ := tt.Find("Event c, 10:00 – 11:00")
|
||||
d, _ := tt.Find("Event d, 12:00 – 12:30")
|
||||
if !(a.X < b.X && b.X < c.X) || a.W > d.W/2 || d.X != a.X {
|
||||
t.Fatalf("three overlapping events need three columns, the lone one the full width: %v %v %v %v", a, b, c, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarDayViewNavigationSlotAndDisabled(t *testing.T) {
|
||||
day := oct(14)
|
||||
var ch []CalendarChange
|
||||
tt := dayViewTester(&day, nil, CalendarDayViewOptions{SlotMinutes: 15}, &ch)
|
||||
if !tt.HasText("Wednesday, October 14, 2026") {
|
||||
t.Fatalf("title %v", tt.Texts()[:4])
|
||||
}
|
||||
_ = tt.Click("Schedule, Wednesday, October 14, 2026")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(ch) != 1 || ch[0].End.Sub(ch[0].Start) != 15*time.Minute {
|
||||
t.Fatalf("15-minute slot create %+v", ch)
|
||||
}
|
||||
_ = tt.Click("Next day")
|
||||
if day != oct(15) {
|
||||
t.Fatalf("day %v", day)
|
||||
}
|
||||
d := oct(14)
|
||||
ch = nil
|
||||
tt = dayViewTester(&d, nil, CalendarDayViewOptions{Disabled: true}, &ch)
|
||||
_ = tt.Click("Schedule, Wednesday, October 14, 2026")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("Previous day")
|
||||
if len(ch) != 0 || d != oct(14) {
|
||||
t.Fatal("disabled day view reacted")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without a day")
|
||||
}
|
||||
}()
|
||||
zero := Date{}
|
||||
dayViewTester(&zero, nil, CalendarDayViewOptions{}, &ch)
|
||||
}
|
||||
|
||||
func TestCalendarDayViewSnapshot(t *testing.T) {
|
||||
day := oct(14)
|
||||
evs := []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 9, 30, 60), calEvent("c", 14, 10, 0, 60), calEvent("d", 14, 12, 0, 30)}
|
||||
evs[1].Color, evs[2].Color = EventInfo, EventSuccess
|
||||
snapshot(t, "calendar_day_view", 560, 860, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CalendarDayView(c, &day, evs, CalendarDayViewOptions{Location: calTokyo, Today: calendarToday,
|
||||
Now: time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
EventNeutral
|
||||
)
|
||||
|
||||
// CalendarEvent is one entry of the calendar views. End is exclusive.
|
||||
// CalendarEvent is one entry of the views; End is exclusive.
|
||||
type CalendarEvent struct {
|
||||
ID string
|
||||
Title string
|
||||
@@ -46,7 +46,7 @@ const (
|
||||
CalendarCreate
|
||||
)
|
||||
|
||||
// CalendarChange is a move, resize or new event a view asks the caller to apply.
|
||||
// CalendarChange is a move, resize or creation to apply.
|
||||
type CalendarChange struct {
|
||||
Kind CalendarChangeKind
|
||||
ID string
|
||||
@@ -54,7 +54,7 @@ type CalendarChange struct {
|
||||
AllDay bool
|
||||
}
|
||||
|
||||
// CalendarViewResult is a calendar view and what the user asked of it.
|
||||
// CalendarViewResult is a view and what the user asked.
|
||||
type CalendarViewResult struct {
|
||||
Element *ui.Element
|
||||
opened string
|
||||
@@ -76,7 +76,7 @@ func (r CalendarViewResult) Changed() (CalendarChange, bool) {
|
||||
// DayChosen returns the day the user activated.
|
||||
func (r CalendarViewResult) DayChosen() (Date, bool) { return r.day, !r.day.IsZero() }
|
||||
|
||||
// eventPalette maps EventColor to tokens that keep 3:1 on Surface in both themes.
|
||||
// eventPalette maps EventColor to tokens at 3:1 on Surface.
|
||||
func eventPalette(k theme.Tokens) [6]ui.Color {
|
||||
return [6]ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Danger, k.TextMuted}
|
||||
}
|
||||
@@ -100,24 +100,25 @@ func eventsCheck(name string, events []CalendarEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// eventsIndex caches the events overlapping a span, rebuilt only when data or span change.
|
||||
type eventsIndex struct {
|
||||
// eventsCache holds data derived from events overlapping a span.
|
||||
type eventsCache[T any] struct {
|
||||
first *CalendarEvent
|
||||
n int
|
||||
version int
|
||||
from, to time.Time
|
||||
hits []int
|
||||
value T
|
||||
valid bool
|
||||
}
|
||||
|
||||
// eventsIn returns the indexes of events overlapping [from, to), by start time.
|
||||
func eventsIn(e *ui.Element, events []CalendarEvent, version int, from, to time.Time) []int {
|
||||
ix := ui.Local(e, "mujica.events.index", func() *eventsIndex { return &eventsIndex{} })
|
||||
// eventsDerived rebuilds build(hits) only when events or span change.
|
||||
func eventsDerived[T any](e *ui.Element, key string, events []CalendarEvent, version int, from, to time.Time, build func(hits []int) T) T {
|
||||
cache := ui.Local(e, key, func() *eventsCache[T] { return &eventsCache[T]{} })
|
||||
var first *CalendarEvent
|
||||
if len(events) > 0 {
|
||||
first = &events[0]
|
||||
}
|
||||
if c := *ix; c.first == first && c.n == len(events) && c.version == version && c.from.Equal(from) && c.to.Equal(to) && c.hits != nil {
|
||||
return c.hits
|
||||
if x := *cache; x.valid && x.first == first && x.n == len(events) && x.version == version && x.from.Equal(from) && x.to.Equal(to) {
|
||||
return x.value
|
||||
}
|
||||
hits := []int{}
|
||||
for i, ev := range events {
|
||||
@@ -132,14 +133,15 @@ func eventsIn(e *ui.Element, events []CalendarEvent, version int, from, to time.
|
||||
}
|
||||
return x.End.After(y.End)
|
||||
})
|
||||
**ix = eventsIndex{first, len(events), version, from, to, hits}
|
||||
return hits
|
||||
v := build(hits)
|
||||
**cache = eventsCache[T]{first, len(events), version, from, to, v, true}
|
||||
return v
|
||||
}
|
||||
|
||||
// eventSpan is a placed interval, in any unit.
|
||||
type eventSpan struct{ from, to float64 }
|
||||
|
||||
// eventColumns lays out overlapping spans side by side: each gets a column and its cluster's width.
|
||||
// eventColumns gives overlapping spans side-by-side columns and cluster widths.
|
||||
func eventColumns(spans []eventSpan) (col, cols []int) {
|
||||
col, cols = make([]int, len(spans)), make([]int, len(spans))
|
||||
order := make([]int, len(spans))
|
||||
@@ -179,7 +181,7 @@ func eventColumns(spans []eventSpan) (col, cols []int) {
|
||||
return col, cols
|
||||
}
|
||||
|
||||
// eventDayStart is midnight of d in loc; a missing midnight resolves to the day's first instant.
|
||||
// eventDayStart is d's first instant in loc.
|
||||
func eventDayStart(d Date, loc *time.Location) time.Time {
|
||||
return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
@@ -195,6 +197,38 @@ func eventTimeText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 b
|
||||
return text
|
||||
}
|
||||
|
||||
// eventShift moves ev by days, keeping wall time.
|
||||
func eventShift(c *ui.Context, ev CalendarEvent, days int, loc *time.Location) (CalendarChange, string) {
|
||||
s, e := ev.Start.In(loc), ev.End.In(loc)
|
||||
ch := CalendarChange{Kind: CalendarMove, ID: ev.ID, AllDay: ev.AllDay}
|
||||
if ev.AllDay {
|
||||
ch.Start, ch.End = eventDayStart(DateOf(s).AddDays(days), loc), eventDayStart(DateOf(e).AddDays(days), loc)
|
||||
return ch, ""
|
||||
}
|
||||
day := DateOf(s).AddDays(days)
|
||||
wall := TimeOfDay{s.Hour(), s.Minute(), s.Second()}
|
||||
at, n := dateTimeResolve(day, wall, loc)
|
||||
if p := eventWallProblem(c, day, wall, loc, n); p != "" {
|
||||
return ch, p
|
||||
}
|
||||
ch.Start, ch.End = at, at.Add(ev.End.Sub(ev.Start))
|
||||
return ch, ""
|
||||
}
|
||||
|
||||
// eventWallProblem reports a wall time DST skips or repeats.
|
||||
func eventWallProblem(c *ui.Context, day Date, wall TimeOfDay, loc *time.Location, n int) string {
|
||||
key := ""
|
||||
switch {
|
||||
case n == 0:
|
||||
key = "date_time_picker.gap"
|
||||
case n > 1:
|
||||
key = "date_time_picker.ambiguous"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(msg(c, key), day.String()+" "+fmt.Sprintf("%02d:%02d", wall.Hour, wall.Minute), loc.String())
|
||||
}
|
||||
|
||||
// eventLocation panics without a caller zone; views never assume UTC.
|
||||
func eventLocation(name string, loc *time.Location) *time.Location {
|
||||
if loc == nil {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CalendarMonthViewOptions configure a CalendarMonthView.
|
||||
type CalendarMonthViewOptions struct {
|
||||
// Location is the zone days are cut in; required.
|
||||
Location *time.Location
|
||||
// Version must change when events change in place.
|
||||
Version int
|
||||
WeekStart WeekStart
|
||||
// MaxPerDay is rows shown before "+N"; zero means 3.
|
||||
MaxPerDay int
|
||||
// Today is marked; zero uses the system clock.
|
||||
Today Date
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// monthDrag is the event id a month view chip drags.
|
||||
type monthDrag string
|
||||
|
||||
// monthDay is one cell's rows of side-by-side events.
|
||||
type monthDay struct {
|
||||
rows [][]int
|
||||
n int
|
||||
}
|
||||
|
||||
// CalendarMonthView shows a month of events; drags reschedule them.
|
||||
func CalendarMonthView(c *ui.Context, month *YearMonth, events []CalendarEvent, opts CalendarMonthViewOptions) CalendarViewResult {
|
||||
loc := eventLocation("CalendarMonthView", opts.Location)
|
||||
if month.IsZero() || month.Month < time.January || month.Month > time.December {
|
||||
panic(fmt.Sprintf("mujica: CalendarMonthView month %v is not valid", *month))
|
||||
}
|
||||
if opts.MaxPerDay < 0 {
|
||||
panic("mujica: CalendarMonthView MaxPerDay is negative")
|
||||
}
|
||||
maxRows := opts.MaxPerDay
|
||||
if maxRows == 0 {
|
||||
maxRows = 3
|
||||
}
|
||||
eventsCheck("CalendarMonthView", events)
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
today := datetimeToday(c, opts.Today)
|
||||
first := calendarFirstWeekday(c, opts.WeekStart)
|
||||
start := Date{month.Year, month.Month, 1}
|
||||
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = datetimeMonthTitle(c, month.Year, month.Month)
|
||||
}
|
||||
r := CalendarViewResult{}
|
||||
root := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = root
|
||||
problem := ui.Local(root, "month-view-problem", func() string { return "" })
|
||||
more := ui.Local(root, "month-view-more", func() Date { return Date{} })
|
||||
days := eventsDerived(root, "month-view-days", events, opts.Version, eventDayStart(start, loc), eventDayStart(start.AddDays(42), loc),
|
||||
func(hits []int) []monthDay { return monthLayout(events, hits, start, loc) })
|
||||
disabled := func() bool { return root.IsDisabled() }
|
||||
apply := func(ch CalendarChange, p string) {
|
||||
*problem = p
|
||||
if p == "" && !disabled() {
|
||||
slog.Debug("mujica: calendar_month_view move", "id", ch.ID, "start", ch.Start)
|
||||
r.change = &ch
|
||||
}
|
||||
}
|
||||
root.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).Children(func() {
|
||||
if iconAction(c, icons.Must("chevron-left"), msg(c, "calendar.prev_month")).Clicked() && !disabled() {
|
||||
*month = YearMonth{start.AddDays(7).AddMonths(-1).Year, start.AddDays(7).AddMonths(-1).Month}
|
||||
}
|
||||
ui.Text(c, datetimeMonthTitle(c, month.Year, month.Month)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).
|
||||
TextColor(k.Text).Grow(1).TextAlign(ui.Center)
|
||||
if iconAction(c, icons.Must("chevron-right"), msg(c, "calendar.next_month")).Clicked() && !disabled() {
|
||||
*month = YearMonth{start.AddDays(7).AddMonths(1).Year, start.AddDays(7).AddMonths(1).Month}
|
||||
}
|
||||
})
|
||||
ui.Row(c).Children(func() {
|
||||
for i := range 7 {
|
||||
wd := (int(first) + i) % 7
|
||||
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Grow(1).Basis(0).TextAlign(ui.Center).
|
||||
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
|
||||
}
|
||||
})
|
||||
ui.Column(c).Border(theme.BorderWidth, k.Border).Dividers(theme.BorderWidth, k.Border).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() {
|
||||
for w := range 6 {
|
||||
ui.Row(c).Dividers(theme.BorderWidth, k.Border).Children(func() {
|
||||
for i := range 7 {
|
||||
at := w*7 + i
|
||||
day := start.AddDays(at)
|
||||
monthCell(c, day, *month, today, days[at], events, maxRows, loc, opts, &r, more, apply)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
datetimeError(c, *problem)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// monthLayout buckets events per day, concurrent ones side by side.
|
||||
func monthLayout(events []CalendarEvent, hits []int, start Date, loc *time.Location) []monthDay {
|
||||
out := make([]monthDay, 42)
|
||||
for i := range out {
|
||||
from, to := eventDayStart(start.AddDays(i), loc), eventDayStart(start.AddDays(i+1), loc)
|
||||
var idx []int
|
||||
var spans []eventSpan
|
||||
for _, h := range hits {
|
||||
ev := events[h]
|
||||
if ev.Start.Before(to) && ev.End.After(from) {
|
||||
idx = append(idx, h)
|
||||
spans = append(spans, eventSpan{float64(max(ev.Start.Unix(), from.Unix())), float64(min(ev.End.Unix(), to.Unix()))})
|
||||
}
|
||||
}
|
||||
col, _ := eventColumns(spans)
|
||||
for j, h := range idx {
|
||||
if col[j] == 0 || len(out[i].rows) == 0 {
|
||||
out[i].rows = append(out[i].rows, []int{h})
|
||||
} else {
|
||||
last := len(out[i].rows) - 1
|
||||
out[i].rows[last] = append(out[i].rows[last], h)
|
||||
}
|
||||
}
|
||||
out[i].n = len(idx)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// monthCell draws one day: number, chips and "+N".
|
||||
func monthCell(c *ui.Context, day Date, month YearMonth, today Date, lay monthDay, events []CalendarEvent, maxRows int,
|
||||
loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, more *Date, apply func(CalendarChange, string)) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
cell := ui.Column(c).Grow(1).Basis(0).MinWidth(0).MinHeight(d.ControlHeight() * 3.5).Padding(d.Unit()).Gap(d.Unit()).Clip()
|
||||
if id, ok := ui.Drop[monthDrag](cell); ok && !cell.IsDisabled() {
|
||||
for _, ev := range events {
|
||||
if ev.ID == string(id) {
|
||||
apply(eventShift(c, ev, monthDays(ev.Start.In(loc), day), loc))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, over := ui.DragOver[monthDrag](cell); over {
|
||||
cell.Background(k.Selection)
|
||||
cell.DrawOver(func(p *ui.Painter, rr ui.Rect) { datetimeSelectedMark(p, rr, k.Focus) })
|
||||
}
|
||||
cell.Children(func() {
|
||||
fg := k.Text
|
||||
if day.Month != month.Month {
|
||||
fg = k.TextMuted
|
||||
}
|
||||
num := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.MinHitSize / 2).Label(datetimeDayLabel(c, day))
|
||||
internal.Ring(num, k.Focus, theme.MinHitSize/2)
|
||||
if day == today {
|
||||
num.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
|
||||
} else if num.Hovered() {
|
||||
num.Background(k.SurfaceHover)
|
||||
}
|
||||
num.Children(func() {
|
||||
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(fg).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
|
||||
if day == today {
|
||||
t.Bold()
|
||||
}
|
||||
})
|
||||
if num.Clicked() && !num.IsDisabled() {
|
||||
r.day = day
|
||||
}
|
||||
shown := 0
|
||||
for i, row := range lay.rows {
|
||||
if i >= maxRows {
|
||||
break
|
||||
}
|
||||
ui.Row(c).Gap(d.Unit() / 2).Children(func() {
|
||||
for _, h := range row {
|
||||
monthChip(c, events[h], loc, opts, r, apply)
|
||||
shown++
|
||||
}
|
||||
})
|
||||
}
|
||||
if hidden := lay.n - shown; hidden > 0 {
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Justify(ui.Start).PaddingX(d.Unit()).Radius(theme.ControlRadius).
|
||||
Label(calendarCount(c, "calendar_month_view.more_label", hidden))
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "calendar_month_view.more"), hidden)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
|
||||
})
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
*more = day
|
||||
}
|
||||
open := *more == day
|
||||
ui.PopoverBase(c, b, &open, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayEnter(c, panel))
|
||||
_, wh := c.Size()
|
||||
panel.Padding(d.Unit() * 2).Gap(d.Unit()).MinWidth(d.ControlHeight() * 6).MaxHeight(wh / 2)
|
||||
ui.Text(c, datetimeDayLabel(c, day)).Font(theme.SerifFont).TextColor(k.Text)
|
||||
ui.Scroll(c).Shrink(1).Children(func() {
|
||||
ui.Column(c).Gap(d.Unit()).Children(func() {
|
||||
for _, row := range lay.rows {
|
||||
for _, h := range row {
|
||||
monthChip(c, events[h], loc, opts, r, apply)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
if !open && *more == day {
|
||||
*more = Date{}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// monthChip is a draggable chip; Alt+arrows move it.
|
||||
func monthChip(c *ui.Context, ev CalendarEvent, loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, apply func(CalendarChange, string)) {
|
||||
chip := EventChip(c, ev, EventChipOptions{Location: loc, Hour12: opts.Hour12, Inline: true}).Grow(1).Basis(0).MinWidth(theme.MinHitSize)
|
||||
if chip.IsDisabled() {
|
||||
return
|
||||
}
|
||||
chip.Drag(monthDrag(ev.ID))
|
||||
if chip.Clicked() {
|
||||
r.opened = ev.ID
|
||||
}
|
||||
for key, days := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1, ui.KeyUp: -7, ui.KeyDown: 7} {
|
||||
if chip.Shortcut(ui.Alt, key) {
|
||||
apply(eventShift(c, ev, days, loc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// monthDays counts calendar days from t's day to day.
|
||||
func monthDays(t time.Time, day Date) int {
|
||||
return int(day.civil().Sub(DateOf(t).civil()).Hours() / 24)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type monthProbe struct {
|
||||
month YearMonth
|
||||
events []CalendarEvent
|
||||
opts CalendarMonthViewOptions
|
||||
opened []string
|
||||
changes []CalendarChange
|
||||
days []Date
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func (p *monthProbe) tester() *ui.Tester {
|
||||
if p.month.IsZero() {
|
||||
p.month = YearMonth{2026, time.October}
|
||||
}
|
||||
p.opts.Location, p.opts.Today = calTokyo, calendarToday
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
o := p.opts
|
||||
o.Disabled = p.disabled
|
||||
r := CalendarMonthView(c, &p.month, p.events, o)
|
||||
if id, ok := r.Opened(); ok {
|
||||
p.opened = append(p.opened, id)
|
||||
}
|
||||
if ch, ok := r.Changed(); ok {
|
||||
p.changes = append(p.changes, ch)
|
||||
}
|
||||
if d, ok := r.DayChosen(); ok {
|
||||
p.days = append(p.days, d)
|
||||
}
|
||||
}, 1000, 900)
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewOverflowAndPopover(t *testing.T) {
|
||||
p := &monthProbe{}
|
||||
for i := range 5 {
|
||||
p.events = append(p.events, calEvent(fmt.Sprint(i), 14, 8+2*i, 0, 60))
|
||||
}
|
||||
tt := p.tester()
|
||||
if !tt.HasText("+2") || tt.HasText("Event 3") {
|
||||
t.Fatalf("3 rows then +2 expected: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("2 more events")
|
||||
if !tt.HasText("Event 4") {
|
||||
t.Fatal("+N should list the hidden events")
|
||||
}
|
||||
_ = tt.Click("Event 4, 16:00 – 17:00")
|
||||
if len(p.opened) != 1 || p.opened[0] != "4" {
|
||||
t.Fatalf("opened %v", p.opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewOverlapSideBySide(t *testing.T) {
|
||||
p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 10, 0, 60), calEvent("c", 14, 15, 0, 60)}}
|
||||
tt := p.tester()
|
||||
a, _ := tt.Find("Event a, 09:00 – 11:00")
|
||||
b, _ := tt.Find("Event b, 10:00 – 11:00")
|
||||
cc, _ := tt.Find("Event c, 15:00 – 16:00")
|
||||
if a.Y != b.Y || b.X <= a.X || cc.Y <= a.Y {
|
||||
t.Fatalf("overlapping events should share a row: %v %v %v", a, b, cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewDragAndKeys(t *testing.T) {
|
||||
p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 30, 60)}}
|
||||
tt := p.tester()
|
||||
chip, _ := tt.Find("Event a, 09:30 – 10:30")
|
||||
target, _ := tt.Find(datetimeTestLabel(oct(16)))
|
||||
tt.Press(chip.X+10, chip.Y+5)
|
||||
for i := 1; i <= 4; i++ {
|
||||
f := float32(i) / 4
|
||||
tt.Move(chip.X+10+(target.X+20-chip.X-10)*f, chip.Y+5+(target.Y+40-chip.Y-5)*f)
|
||||
}
|
||||
tt.Release(target.X+20, target.Y+40)
|
||||
want := time.Date(2026, time.October, 16, 9, 30, 0, 0, calTokyo)
|
||||
if len(p.changes) != 1 || !p.changes[0].Start.Equal(want) || p.changes[0].End.Sub(p.changes[0].Start) != time.Hour {
|
||||
t.Fatalf("drop gave %+v", p.changes)
|
||||
}
|
||||
_ = tt.Click("Event a, 09:30 – 10:30")
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if len(p.changes) != 2 || !p.changes[1].Start.Equal(time.Date(2026, time.October, 21, 9, 30, 0, 0, calTokyo)) {
|
||||
t.Fatalf("Alt+Down gave %+v", p.changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewDSTGapReported(t *testing.T) {
|
||||
ny, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := CalendarEvent{ID: "g", Title: "Early", Start: time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)}
|
||||
ev.End = ev.Start.Add(time.Hour)
|
||||
p := &monthProbe{month: YearMonth{2026, time.March}, events: []CalendarEvent{ev}}
|
||||
p.opts.Location = ny
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ch, ok := CalendarMonthView(c, &p.month, p.events, CalendarMonthViewOptions{Location: ny, Today: Date{2026, time.March, 1}}).Changed(); ok {
|
||||
p.changes = append(p.changes, ch)
|
||||
}
|
||||
}, 1000, 900)
|
||||
_ = tt.Click("Early, 02:30 – 03:30")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if len(p.changes) != 0 || !tt.HasText("2026-03-08 02:30 does not exist in America/New_York") {
|
||||
t.Fatalf("gap not reported: %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewNavigationDayAndDisabled(t *testing.T) {
|
||||
p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60)}}
|
||||
tt := p.tester()
|
||||
_ = tt.Click(datetimeTestLabel(oct(20)))
|
||||
_ = tt.Click("Next month")
|
||||
if len(p.days) != 1 || p.days[0] != oct(20) || p.month != (YearMonth{2026, time.November}) {
|
||||
t.Fatalf("day %v month %v", p.days, p.month)
|
||||
}
|
||||
p.month, p.disabled = YearMonth{2026, time.October}, true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Next month")
|
||||
_ = tt.Click("Event a, 09:00 – 10:00")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if p.month != (YearMonth{2026, time.October}) || len(p.opened) != 0 || len(p.changes) != 0 {
|
||||
t.Fatal("disabled view reacted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewLargeDataAndInvalid(t *testing.T) {
|
||||
p := &monthProbe{}
|
||||
base := time.Date(2000, 1, 1, 9, 0, 0, 0, calTokyo)
|
||||
for i := range 100000 {
|
||||
s := base.Add(time.Duration(i) * 3 * time.Hour)
|
||||
p.events = append(p.events, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(time.Hour)})
|
||||
}
|
||||
tt := p.tester()
|
||||
if n := len(tt.Texts()); n > 800 {
|
||||
t.Fatalf("built %d labels for one month", n)
|
||||
}
|
||||
for _, bad := range []func(){
|
||||
func() { q := &monthProbe{events: []CalendarEvent{{ID: "x", Start: base, End: base}}}; q.tester() },
|
||||
func() { q := &monthProbe{opts: CalendarMonthViewOptions{MaxPerDay: -1}}; q.tester() },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected panic")
|
||||
}
|
||||
}()
|
||||
bad()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarMonthViewSnapshot(t *testing.T) {
|
||||
m := YearMonth{2026, time.October}
|
||||
evs := []CalendarEvent{calEvent("1", 6, 9, 0, 60), calEvent("2", 14, 9, 0, 120), calEvent("3", 14, 10, 0, 30),
|
||||
calEvent("4", 14, 13, 0, 30), calEvent("5", 14, 15, 0, 30), calEvent("6", 14, 17, 0, 30), calEvent("7", 22, 18, 0, 60)}
|
||||
evs[1].Color, evs[2].Color, evs[6].Color = EventInfo, EventSuccess, EventWarning
|
||||
snapshot(t, "calendar_month_view", 900, 860, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CalendarMonthView(c, &m, evs, CalendarMonthViewOptions{Location: calTokyo, Today: calendarToday})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// timeGridConfig is what the week and day views share.
|
||||
type timeGridConfig struct {
|
||||
name string
|
||||
loc *time.Location
|
||||
version int
|
||||
slot int
|
||||
today Date
|
||||
now time.Time
|
||||
hour12 bool
|
||||
}
|
||||
|
||||
// timeGesture is a drag in progress on the time grid.
|
||||
type timeGesture struct {
|
||||
active bool
|
||||
kind CalendarChangeKind
|
||||
id string
|
||||
day int
|
||||
anchor int
|
||||
dx, dy float32
|
||||
cur int
|
||||
}
|
||||
|
||||
// timeGridDay is one column's timed and all-day events.
|
||||
type timeGridDay struct {
|
||||
timed []timePlaced
|
||||
allDay []int
|
||||
}
|
||||
|
||||
type timePlaced struct {
|
||||
ev int
|
||||
from, to int
|
||||
col, cols int
|
||||
}
|
||||
|
||||
// timeGridSlot checks and defaults the slot length.
|
||||
func timeGridSlot(name string, slot int) int {
|
||||
if slot == 0 {
|
||||
return 30
|
||||
}
|
||||
if slot < 0 || 60%slot != 0 && slot%60 != 0 {
|
||||
panic(fmt.Sprintf("mujica: %s SlotMinutes %d must divide an hour", name, slot))
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
// timeGrid draws day columns and turns gestures into changes.
|
||||
func timeGrid(c *ui.Context, root *ui.Element, days []Date, events []CalendarEvent, cfg timeGridConfig, r *CalendarViewResult) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
hourH := d.ControlHeight() * 2
|
||||
slotH := hourH * float32(cfg.slot) / 60
|
||||
g := *ui.Local(root, "time-grid-gesture", func() *timeGesture { return &timeGesture{} })
|
||||
morning := func() []int {
|
||||
v := make([]int, len(days))
|
||||
for i := range v {
|
||||
v[i] = 8 * 60 / cfg.slot
|
||||
}
|
||||
return v
|
||||
}
|
||||
cursor := ui.Local(root, "time-grid-cursor", morning)
|
||||
if len(*cursor) != len(days) {
|
||||
*cursor = morning()
|
||||
}
|
||||
problem := ui.Local(root, "time-grid-problem", func() string { return "" })
|
||||
scroll := ui.Local(root, "time-grid-scroll", func() *ui.ScrollState { return &ui.ScrollState{Y: hourH * 8} })
|
||||
lay := eventsDerived(root, "time-grid-layout", events, cfg.version, eventDayStart(days[0], cfg.loc),
|
||||
eventDayStart(days[len(days)-1].AddDays(1), cfg.loc), func(hits []int) []timeGridDay { return timeGridLayout(events, hits, days, cfg.loc) })
|
||||
apply := func(ch CalendarChange, p string) {
|
||||
*problem = p
|
||||
if p == "" && !root.IsDisabled() {
|
||||
slog.Debug("mujica: "+cfg.name+" change", "kind", ch.Kind, "id", ch.ID, "start", ch.Start, "end", ch.End)
|
||||
r.change = &ch
|
||||
}
|
||||
}
|
||||
gutter := d.ControlHeight() * 1.6
|
||||
ui.Row(c).Children(func() {
|
||||
ui.Box(c).Width(gutter).Shrink(0)
|
||||
for _, day := range days {
|
||||
wd := msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))
|
||||
b := ui.ButtonBase(c).Grow(1).Basis(0).MinHeight(theme.MinHitSize).Gap(d.Unit()).Radius(theme.ControlRadius).Label(datetimeDayLabel(c, day))
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if day == cfg.today {
|
||||
b.Background(k.Selection)
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Text(c, wd).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(k.Text).FontFeatures("tnum")
|
||||
if day == cfg.today {
|
||||
t.Bold().TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
r.day = day
|
||||
}
|
||||
}
|
||||
})
|
||||
ui.Row(c).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
||||
ui.Text(c, msg(c, "calendar_week_view.all_day")).Width(gutter).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).
|
||||
Padding(d.Unit())
|
||||
for i, day := range days {
|
||||
cell := ui.Column(c).Grow(1).Basis(0).MinWidth(0).MinHeight(theme.MinHitSize).Padding(d.Unit() / 2).Gap(d.Unit() / 2)
|
||||
if id, ok := ui.Drop[monthDrag](cell); ok && !cell.IsDisabled() {
|
||||
for _, ev := range events {
|
||||
if ev.ID == string(id) {
|
||||
apply(eventShift(c, ev, monthDays(ev.Start.In(cfg.loc), day), cfg.loc))
|
||||
}
|
||||
}
|
||||
}
|
||||
cell.Children(func() {
|
||||
for _, h := range lay[i].allDay {
|
||||
monthChip(c, events[h], cfg.loc, CalendarMonthViewOptions{Hour12: cfg.hour12}, r, apply)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
ui.Scroll(c).TrackScroll(*scroll).Height(hourH * 9).Children(func() {
|
||||
ui.Row(c).Children(func() {
|
||||
ui.Column(c).Width(gutter).Shrink(0).Children(func() {
|
||||
for h := range 24 {
|
||||
ui.Text(c, timeText(c, TimeOfDay{h, 0, 0}, timeConfig{hour12: cfg.hour12})).Height(hourH).
|
||||
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum").PaddingX(d.Unit())
|
||||
}
|
||||
})
|
||||
for i, day := range days {
|
||||
timeColumn(c, i, day, lay[i], events, cfg, hourH, slotH, g, &(*cursor)[i], r, apply)
|
||||
}
|
||||
})
|
||||
})
|
||||
datetimeError(c, *problem)
|
||||
}
|
||||
|
||||
// timeGridLayout places each day's events in minutes with side-by-side columns.
|
||||
func timeGridLayout(events []CalendarEvent, hits []int, days []Date, loc *time.Location) []timeGridDay {
|
||||
out := make([]timeGridDay, len(days))
|
||||
for i, day := range days {
|
||||
from, to := eventDayStart(day, loc), eventDayStart(day.AddDays(1), loc)
|
||||
var spans []eventSpan
|
||||
for _, h := range hits {
|
||||
ev := events[h]
|
||||
if !ev.Start.Before(to) || !ev.End.After(from) {
|
||||
continue
|
||||
}
|
||||
if ev.AllDay || ev.End.Sub(ev.Start) >= 24*time.Hour {
|
||||
out[i].allDay = append(out[i].allDay, h)
|
||||
continue
|
||||
}
|
||||
a, b := 0, 24*60
|
||||
if !ev.Start.Before(from) {
|
||||
a = timeWallMinutes(ev.Start.In(loc))
|
||||
}
|
||||
if ev.End.Before(to) {
|
||||
b = timeWallMinutes(ev.End.In(loc))
|
||||
}
|
||||
b = max(b, a+1)
|
||||
out[i].timed = append(out[i].timed, timePlaced{ev: h, from: a, to: b})
|
||||
spans = append(spans, eventSpan{float64(a), float64(b)})
|
||||
}
|
||||
col, cols := eventColumns(spans)
|
||||
for j := range out[i].timed {
|
||||
out[i].timed[j].col, out[i].timed[j].cols = col[j], cols[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func timeWallMinutes(t time.Time) int { return t.Hour()*60 + t.Minute() }
|
||||
|
||||
// timeColumn draws one day: lines, events, preview and cursor.
|
||||
func timeColumn(c *ui.Context, i int, day Date, lay timeGridDay, events []CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
|
||||
g *timeGesture, cursor *int, r *CalendarViewResult, apply func(CalendarChange, string)) {
|
||||
k := Tokens(c)
|
||||
slots := 24 * 60 / cfg.slot
|
||||
col := ui.Box(c).Grow(1).Basis(0).MinWidth(0).Height(hourH*24).Focusable().
|
||||
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "calendar_week_view.column"), datetimeDayLabel(c, day))).BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.Border)
|
||||
disabled := col.IsDisabled()
|
||||
slotOf := func(y float32) int { return max(0, min(slots-1, int(y/slotH))) }
|
||||
if !disabled {
|
||||
switch {
|
||||
case col.Shortcut(0, ui.KeyUp):
|
||||
*cursor = max(0, *cursor-1)
|
||||
case col.Shortcut(0, ui.KeyDown):
|
||||
*cursor = min(slots-1, *cursor+1)
|
||||
case col.Shortcut(0, ui.KeyEnter):
|
||||
apply(timeCreate(c, day, *cursor, *cursor+1, cfg))
|
||||
}
|
||||
_, py, _ := col.PointerPosition()
|
||||
dx, dy, pressing := col.Dragged()
|
||||
if pressing && !g.active {
|
||||
*g = timeGesture{active: true, kind: CalendarCreate, day: i, anchor: slotOf(py - dy), cur: slotOf(py)}
|
||||
}
|
||||
if g.active && g.kind == CalendarCreate && g.day == i {
|
||||
if pressing {
|
||||
g.dx, g.dy = g.dx+dx, g.dy+dy
|
||||
g.cur = slotOf(py)
|
||||
} else {
|
||||
a, b := min(g.anchor, g.cur), max(g.anchor, g.cur)+1
|
||||
if math.Abs(float64(g.dy)) >= 4 {
|
||||
apply(timeCreate(c, day, a, b, cfg))
|
||||
}
|
||||
*cursor = g.anchor
|
||||
*g = timeGesture{}
|
||||
}
|
||||
}
|
||||
}
|
||||
focused := col.Focused()
|
||||
cur := *cursor
|
||||
creating := g.active && g.kind == CalendarCreate && g.day == i
|
||||
ga, gb := min(g.anchor, g.cur), max(g.anchor, g.cur)+1
|
||||
col.Draw(func(p *ui.Painter, rr ui.Rect) {
|
||||
for h := 1; h < 24; h++ {
|
||||
internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+float32(h)*hourH, k.Border)
|
||||
}
|
||||
if focused {
|
||||
box := ui.Rect{X: rr.X + 2, Y: rr.Y + float32(cur)*slotH + 1, W: rr.W - 4, H: slotH - 2}
|
||||
p.Fill(box, k.Selection, theme.ControlRadius)
|
||||
datetimeSelectedMark(p, box, k.Focus)
|
||||
}
|
||||
if creating {
|
||||
box := ui.Rect{X: rr.X + 2, Y: rr.Y + float32(ga)*slotH, W: rr.W - 4, H: float32(gb-ga) * slotH}
|
||||
p.Fill(box, k.Selection, theme.ControlRadius)
|
||||
p.StrokeDashed(box, k.AccentText, theme.ControlRadius, theme.BorderWidth)
|
||||
}
|
||||
})
|
||||
col.Children(func() {
|
||||
now := cfg.now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
}
|
||||
for _, pl := range lay.timed {
|
||||
timeChip(c, i, pl, events[pl.ev], cfg, hourH, slotH, g, r, apply)
|
||||
}
|
||||
if day == DateOf(now.In(cfg.loc)) {
|
||||
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: cfg.loc, Day: day, Now: cfg.now, Hour12: cfg.hour12})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// timeChip places an event; drag moves, bottom edge resizes.
|
||||
func timeChip(c *ui.Context, i int, pl timePlaced, ev CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
|
||||
g *timeGesture, r *CalendarViewResult, apply func(CalendarChange, string)) {
|
||||
from, to := float32(pl.from)*hourH/60, float32(pl.to)*hourH/60
|
||||
mine := g.active && g.id == ev.ID && g.day == i
|
||||
snap := func(v float32) int { return int(math.Round(float64(v/slotH))) * cfg.slot }
|
||||
var shiftY, growY float32
|
||||
if mine && g.kind == CalendarMove {
|
||||
shiftY = float32(snap(g.dy)) * hourH / 60
|
||||
}
|
||||
if mine && g.kind == CalendarResize {
|
||||
growY = float32(snap(g.dy)) * hourH / 60
|
||||
}
|
||||
box := ui.Box(c).Key(ev.ID).Absolute().Top(from + shiftY).Height(max(to-from+growY, theme.MinHitSize)).
|
||||
LeftPercent(100 * float32(pl.col) / float32(pl.cols)).WidthPercent(100 / float32(pl.cols)).Padding(1)
|
||||
box.Children(func() {
|
||||
chip := EventChip(c, ev, EventChipOptions{Location: cfg.loc, Hour12: cfg.hour12, Selected: mine, Inline: to-from < hourH*0.75}).Fill()
|
||||
handle := ui.Box(c).Absolute().Left(0).Right(0).Bottom(0).Height(6).Cursor(ui.CursorResizeNS).Label(fmt.Sprintf(msg(c, "calendar_week_view.resize"), ev.Title))
|
||||
if chip.IsDisabled() {
|
||||
return
|
||||
}
|
||||
if chip.Clicked() && !mine {
|
||||
r.opened = ev.ID
|
||||
}
|
||||
for key, dm := range map[ui.Key]int{ui.KeyUp: -cfg.slot, ui.KeyDown: cfg.slot} {
|
||||
if chip.Shortcut(ui.Alt, key) {
|
||||
apply(timeMove(c, ev, 0, dm, cfg))
|
||||
}
|
||||
if chip.Shortcut(ui.Alt|ui.Shift, key) {
|
||||
apply(timeResize(c, ev, dm, cfg))
|
||||
}
|
||||
}
|
||||
for key, dd := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1} {
|
||||
if chip.Shortcut(ui.Alt, key) {
|
||||
apply(timeMove(c, ev, dd, 0, cfg))
|
||||
}
|
||||
}
|
||||
for _, part := range []struct {
|
||||
e *ui.Element
|
||||
kind CalendarChangeKind
|
||||
}{{chip, CalendarMove}, {handle, CalendarResize}} {
|
||||
dx, dy, ok := part.e.Dragged()
|
||||
switch {
|
||||
case ok && (!g.active && (dx != 0 || dy != 0) || mine && g.kind == part.kind):
|
||||
if !g.active {
|
||||
*g = timeGesture{active: true, kind: part.kind, id: ev.ID, day: i}
|
||||
}
|
||||
g.dx, g.dy = g.dx+dx, g.dy+dy
|
||||
case !ok && mine && g.kind == part.kind:
|
||||
dm := snap(g.dy)
|
||||
if part.kind == CalendarResize {
|
||||
if dm != 0 {
|
||||
apply(timeResize(c, ev, dm, cfg))
|
||||
}
|
||||
} else if dd := int(math.Round(float64(g.dx / max(chip.Bounds().W*float32(pl.cols), 1)))); dm != 0 || dd != 0 {
|
||||
apply(timeMove(c, ev, dd, dm, cfg))
|
||||
}
|
||||
*g = timeGesture{}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// timeCreate makes an event over slots [a, b).
|
||||
func timeCreate(c *ui.Context, day Date, a, b int, cfg timeGridConfig) (CalendarChange, string) {
|
||||
s, sn := timeAt(day, a*cfg.slot, cfg.loc)
|
||||
e, en := timeAt(day, b*cfg.slot, cfg.loc)
|
||||
if p := eventWallProblem(c, timeDay(day, a*cfg.slot), timeWall(a*cfg.slot), cfg.loc, sn); p != "" {
|
||||
return CalendarChange{}, p
|
||||
}
|
||||
if p := eventWallProblem(c, timeDay(day, b*cfg.slot), timeWall(b*cfg.slot), cfg.loc, en); p != "" {
|
||||
return CalendarChange{}, p
|
||||
}
|
||||
return CalendarChange{Kind: CalendarCreate, Start: s, End: e}, ""
|
||||
}
|
||||
|
||||
// timeMove shifts ev by days and minutes of wall time.
|
||||
func timeMove(c *ui.Context, ev CalendarEvent, days, minutes int, cfg timeGridConfig) (CalendarChange, string) {
|
||||
s := ev.Start.In(cfg.loc)
|
||||
day := DateOf(s).AddDays(days)
|
||||
m := timeWallMinutes(s) + minutes
|
||||
at, n := timeAt(day, m, cfg.loc)
|
||||
if p := eventWallProblem(c, timeDay(day, m), timeWall(m), cfg.loc, n); p != "" {
|
||||
return CalendarChange{}, p
|
||||
}
|
||||
return CalendarChange{Kind: CalendarMove, ID: ev.ID, Start: at, End: at.Add(ev.End.Sub(ev.Start))}, ""
|
||||
}
|
||||
|
||||
// timeResize moves ev's end by minutes of wall time.
|
||||
func timeResize(c *ui.Context, ev CalendarEvent, minutes int, cfg timeGridConfig) (CalendarChange, string) {
|
||||
e := ev.End.In(cfg.loc)
|
||||
day, m := DateOf(e), timeWallMinutes(e)+minutes
|
||||
at, n := timeAt(day, m, cfg.loc)
|
||||
if p := eventWallProblem(c, timeDay(day, m), timeWall(m), cfg.loc, n); p != "" {
|
||||
return CalendarChange{}, p
|
||||
}
|
||||
if !at.After(ev.Start) {
|
||||
return CalendarChange{}, msg(c, "calendar_week_view.too_short")
|
||||
}
|
||||
return CalendarChange{Kind: CalendarResize, ID: ev.ID, Start: ev.Start, End: at}, ""
|
||||
}
|
||||
|
||||
// timeAt resolves minute m of day as wall time.
|
||||
func timeAt(day Date, m int, loc *time.Location) (time.Time, int) {
|
||||
return dateTimeResolve(timeDay(day, m), timeWall(m), loc)
|
||||
}
|
||||
|
||||
func timeDay(day Date, m int) Date {
|
||||
return day.AddDays(int(math.Floor(float64(m) / (24 * 60))))
|
||||
}
|
||||
|
||||
func timeWall(m int) TimeOfDay {
|
||||
m = ((m % (24 * 60)) + 24*60) % (24 * 60)
|
||||
return TimeOfDay{m / 60, m % 60, 0}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CalendarWeekViewOptions configure a CalendarWeekView.
|
||||
type CalendarWeekViewOptions struct {
|
||||
// Location is the zone days are cut in; required.
|
||||
Location *time.Location
|
||||
// Version must change when events change in place.
|
||||
Version int
|
||||
WeekStart WeekStart
|
||||
// SlotMinutes is the grid step; zero means 30.
|
||||
SlotMinutes int
|
||||
// Today is marked; zero uses the system clock.
|
||||
Today Date
|
||||
// Now places the current-time line; zero uses the system clock.
|
||||
Now time.Time
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// CalendarWeekView shows *week as a time grid; drags edit events.
|
||||
func CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult {
|
||||
datetimeCheckDate("CalendarWeekView week", *week)
|
||||
if week.IsZero() {
|
||||
panic("mujica: CalendarWeekView needs a week")
|
||||
}
|
||||
cfg := timeGridConfig{name: "calendar_week_view", loc: eventLocation("CalendarWeekView", opts.Location), version: opts.Version,
|
||||
slot: timeGridSlot("CalendarWeekView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12}
|
||||
eventsCheck("CalendarWeekView", events)
|
||||
first := calendarFirstWeekday(c, opts.WeekStart)
|
||||
start := week.AddDays(-((int(week.Weekday()) - int(first) + 7) % 7))
|
||||
days := make([]Date, 7)
|
||||
for i := range days {
|
||||
days[i] = start.AddDays(i)
|
||||
}
|
||||
title := weekViewTitle(c, start, start.AddDays(6))
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = title
|
||||
}
|
||||
r := CalendarViewResult{}
|
||||
root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = root
|
||||
root.Children(func() {
|
||||
calendarViewHeader(c, title, msg(c, "calendar_week_view.prev"), msg(c, "calendar_week_view.next"), func(dir int) {
|
||||
*week = week.AddDays(7 * dir)
|
||||
})
|
||||
timeGrid(c, root, days, events, cfg, &r)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// calendarViewHeader is the title row with previous and next buttons.
|
||||
func calendarViewHeader(c *ui.Context, title, prev, next string, step func(dir int)) {
|
||||
k := Tokens(c)
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() {
|
||||
if b := iconAction(c, icons.Must("chevron-left"), prev); b.Clicked() && !b.IsDisabled() {
|
||||
step(-1)
|
||||
}
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).TextAlign(ui.Center)
|
||||
if b := iconAction(c, icons.Must("chevron-right"), next); b.Clicked() && !b.IsDisabled() {
|
||||
step(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// weekViewTitle names a span of days.
|
||||
func weekViewTitle(c *ui.Context, a, b Date) string {
|
||||
return fmt.Sprintf(msg(c, "calendar_week_view.title"), datetimeMonthName(c, a.Month), a.Day, datetimeMonthName(c, b.Month), b.Day, b.Year)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type weekProbe struct {
|
||||
week Date
|
||||
events []CalendarEvent
|
||||
opts CalendarWeekViewOptions
|
||||
changes []CalendarChange
|
||||
opened []string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func (p *weekProbe) tester() *ui.Tester {
|
||||
if p.week.IsZero() {
|
||||
p.week = oct(14)
|
||||
}
|
||||
p.opts.Location, p.opts.Today = calTokyo, calendarToday
|
||||
p.opts.Now = time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
o := p.opts
|
||||
o.Disabled = p.disabled
|
||||
r := CalendarWeekView(c, &p.week, p.events, o)
|
||||
if ch, ok := r.Changed(); ok {
|
||||
p.changes = append(p.changes, ch)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
p.opened = append(p.opened, id)
|
||||
}
|
||||
}, 1100, 900)
|
||||
}
|
||||
|
||||
func calDrag(tt *ui.Tester, x0, y0, x1, y1 float32) {
|
||||
tt.Press(x0, y0)
|
||||
for i := 1; i <= 4; i++ {
|
||||
f := float32(i) / 4
|
||||
tt.Move(x0+(x1-x0)*f, y0+(y1-y0)*f)
|
||||
}
|
||||
tt.Release(x1, y1)
|
||||
}
|
||||
|
||||
func calAt(t *testing.T, h, m int, day int) time.Time {
|
||||
t.Helper()
|
||||
return time.Date(2026, time.October, day, h, m, 0, 0, calTokyo)
|
||||
}
|
||||
|
||||
func TestCalendarWeekViewDragCreateAndKeyboardCreate(t *testing.T) {
|
||||
p := &weekProbe{}
|
||||
tt := p.tester()
|
||||
col, ok := tt.Find("Schedule, " + datetimeTestLabel(oct(15)))
|
||||
if !ok {
|
||||
t.Fatal("no column")
|
||||
}
|
||||
hourH := float32(72)
|
||||
// Drag 9:00 to 10:30; the visible top is 8:00.
|
||||
y9 := col.Y + hourH + 5
|
||||
calDrag(tt, col.X+20, y9, col.X+20, y9+hourH*1.5)
|
||||
if len(p.changes) != 1 || p.changes[0].Kind != CalendarCreate || !p.changes[0].Start.Equal(calAt(t, 9, 0, 15)) || !p.changes[0].End.Equal(calAt(t, 11, 0, 15)) {
|
||||
t.Fatalf("drag create %+v", p.changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(p.changes) != 2 || !p.changes[1].Start.Equal(calAt(t, 9, 30, 15)) || !p.changes[1].End.Equal(calAt(t, 10, 0, 15)) {
|
||||
t.Fatalf("keyboard create %+v", p.changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarWeekViewMoveResizeAndOverlap(t *testing.T) {
|
||||
p := &weekProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60), calEvent("b", 14, 9, 30, 60)}}
|
||||
tt := p.tester()
|
||||
a, _ := tt.Find("Event a, 09:00 – 10:00")
|
||||
b, _ := tt.Find("Event b, 09:30 – 10:30")
|
||||
if b.X <= a.X || b.X-a.X < a.W/2 || b.Y <= a.Y {
|
||||
t.Fatalf("overlapping events should sit side by side: %v %v", a, b)
|
||||
}
|
||||
calDrag(tt, a.X+10, a.Y+20, a.X+10, a.Y+20+72)
|
||||
if len(p.changes) != 1 || p.changes[0].Kind != CalendarMove || !p.changes[0].Start.Equal(calAt(t, 10, 0, 14)) || !p.changes[0].End.Equal(calAt(t, 11, 0, 14)) {
|
||||
t.Fatalf("move %+v", p.changes)
|
||||
}
|
||||
h, _ := tt.Find("Resize Event b")
|
||||
calDrag(tt, h.X+10, h.Y+3, h.X+10, h.Y+3+36)
|
||||
if len(p.changes) != 2 || p.changes[1].Kind != CalendarResize || !p.changes[1].End.Equal(calAt(t, 11, 0, 14)) {
|
||||
t.Fatalf("resize %+v", p.changes)
|
||||
}
|
||||
_ = tt.Click("Event a, 09:00 – 10:00")
|
||||
if len(p.opened) != 1 {
|
||||
t.Fatalf("click should open: %v", p.opened)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
tt.Key(ui.Alt|ui.Shift, ui.KeyUp)
|
||||
if len(p.changes) != 5 || !p.changes[2].Start.Equal(calAt(t, 9, 0, 15)) || !p.changes[3].Start.Equal(calAt(t, 9, 30, 14)) ||
|
||||
!p.changes[4].End.Equal(calAt(t, 9, 30, 14)) {
|
||||
t.Fatalf("keyboard changes %+v", p.changes[2:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarWeekViewAllDayWeekStartAndNav(t *testing.T) {
|
||||
ev := calEvent("h", 16, 0, 0, 24*60)
|
||||
ev.AllDay, ev.Title = true, "Holiday"
|
||||
p := &weekProbe{events: []CalendarEvent{ev}, opts: CalendarWeekViewOptions{WeekStart: WeekStartMonday}}
|
||||
tt := p.tester()
|
||||
if !tt.HasText("October 12 – October 18, 2026") || !tt.HasText("All day") || !tt.HasText("Holiday") {
|
||||
t.Fatalf("texts %v", tt.Texts()[:12])
|
||||
}
|
||||
_ = tt.Click("Holiday, All day")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if len(p.changes) != 1 || !p.changes[0].Start.Equal(calAt(t, 0, 0, 17)) || !p.changes[0].AllDay {
|
||||
t.Fatalf("all-day move %+v", p.changes)
|
||||
}
|
||||
_ = tt.Click("Next week")
|
||||
if p.week != oct(21) {
|
||||
t.Fatalf("week %v", p.week)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarWeekViewDisabledAndInvalid(t *testing.T) {
|
||||
p := &weekProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60)}, disabled: true}
|
||||
tt := p.tester()
|
||||
a, _ := tt.Find("Event a, 09:00 – 10:00")
|
||||
calDrag(tt, a.X+10, a.Y+20, a.X+10, a.Y+92)
|
||||
_ = tt.Click("Event a, 09:00 – 10:00")
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
_ = tt.Click("Next week")
|
||||
if len(p.changes) != 0 || len(p.opened) != 0 || p.week != oct(14) {
|
||||
t.Fatal("disabled week view reacted")
|
||||
}
|
||||
for _, opts := range []CalendarWeekViewOptions{{SlotMinutes: 7}, {SlotMinutes: -15}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: expected panic", opts)
|
||||
}
|
||||
}()
|
||||
q := &weekProbe{opts: opts}
|
||||
q.tester()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarWeekViewLargeData(t *testing.T) {
|
||||
p := &weekProbe{}
|
||||
base := time.Date(2000, 1, 1, 9, 0, 0, 0, calTokyo)
|
||||
for i := range 100000 {
|
||||
s := base.Add(time.Duration(i) * 3 * time.Hour)
|
||||
p.events = append(p.events, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(time.Hour)})
|
||||
}
|
||||
tt := p.tester()
|
||||
if n := len(tt.Texts()); n > 400 {
|
||||
t.Fatalf("built %d labels for one week", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarWeekViewSnapshot(t *testing.T) {
|
||||
w := oct(14)
|
||||
evs := []CalendarEvent{calEvent("1", 12, 9, 0, 60), calEvent("2", 14, 9, 0, 120), calEvent("3", 14, 9, 30, 60),
|
||||
calEvent("4", 15, 13, 0, 90), calEvent("5", 16, 10, 0, 45)}
|
||||
evs[1].Color, evs[2].Color, evs[3].Color = EventInfo, EventSuccess, EventWarning
|
||||
h := calEvent("h", 13, 0, 0, 24*60)
|
||||
h.AllDay, h.Title = true, "Holiday"
|
||||
evs = append(evs, h)
|
||||
snapshot(t, "calendar_week_view", 1000, 860, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CalendarWeekView(c, &w, evs, CalendarWeekViewOptions{Location: calTokyo, Today: calendarToday,
|
||||
Now: time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CountdownFormat chooses how the time left reads.
|
||||
type CountdownFormat int
|
||||
|
||||
const (
|
||||
// CountdownClock reads 01:02:03, with days as "2d" first.
|
||||
CountdownClock CountdownFormat = iota
|
||||
// CountdownWords reads "1 h 2 min 3 s".
|
||||
CountdownWords
|
||||
)
|
||||
|
||||
// CountdownOptions configure a Countdown.
|
||||
type CountdownOptions struct {
|
||||
Format CountdownFormat
|
||||
// Now fixes the clock and stops updates.
|
||||
Now time.Time
|
||||
Label string
|
||||
}
|
||||
|
||||
// CountdownResult is the countdown and its finish.
|
||||
type CountdownResult struct {
|
||||
Element *ui.Element
|
||||
finished bool
|
||||
}
|
||||
|
||||
// Finished reports reaching zero, once per deadline.
|
||||
func (r CountdownResult) Finished() bool { return r.finished }
|
||||
|
||||
// Countdown shows the time left, ticking each second while shown.
|
||||
func Countdown(c *ui.Context, deadline time.Time, opts CountdownOptions) CountdownResult {
|
||||
if opts.Format < CountdownClock || opts.Format > CountdownWords {
|
||||
panic(fmt.Sprintf("mujica: Countdown Format %d out of range", opts.Format))
|
||||
}
|
||||
k := Tokens(c)
|
||||
now := opts.Now
|
||||
live := now.IsZero()
|
||||
if live {
|
||||
now = c.Now()
|
||||
}
|
||||
left := max(deadline.Sub(now), 0)
|
||||
shown := left.Truncate(time.Second)
|
||||
if left%time.Second != 0 {
|
||||
shown += time.Second
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "countdown.label")
|
||||
}
|
||||
text := CountdownText(c, shown, opts.Format)
|
||||
e := ui.Text(c, text).Font(theme.MonoFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text).FontFeatures("tnum").
|
||||
Label(label + ", " + CountdownText(c, shown, CountdownWords))
|
||||
r := CountdownResult{Element: e}
|
||||
fired := ui.Local(e, "countdown-fired", func() time.Time { return time.Time{} })
|
||||
switch {
|
||||
case left == 0 && !fired.Equal(deadline):
|
||||
*fired, r.finished = deadline, true
|
||||
slog.Debug("mujica: countdown finished", "deadline", deadline)
|
||||
c.Announce(fmt.Sprintf(msg(c, "countdown.done"), label))
|
||||
case left > 0 && live:
|
||||
c.After(left % time.Second)
|
||||
if left%time.Second == 0 {
|
||||
c.After(time.Second)
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
e.TextColor(k.AccentText)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// CountdownText formats a non-negative duration in whole seconds.
|
||||
func CountdownText(c *ui.Context, d time.Duration, f CountdownFormat) string {
|
||||
s := int(d / time.Second)
|
||||
days, h, m, sec := s/86400, s%86400/3600, s%3600/60, s%60
|
||||
if f == CountdownClock {
|
||||
t := fmt.Sprintf("%02d:%02d:%02d", h, m, sec)
|
||||
if days > 0 {
|
||||
t = fmt.Sprintf(msg(c, "countdown.days_clock"), days) + " " + t
|
||||
}
|
||||
return t
|
||||
}
|
||||
out := ""
|
||||
for i, v := range []int{days, h, m, sec} {
|
||||
if v > 0 || i == 3 && out == "" {
|
||||
if out != "" {
|
||||
out += " "
|
||||
}
|
||||
out += fmt.Sprintf(msg(c, []string{"countdown.d", "countdown.h", "countdown.m", "countdown.s"}[i]), v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestCountdownFormatsAndFinishesOnce(t *testing.T) {
|
||||
deadline := time.Date(2026, time.October, 5, 12, 0, 0, 0, calTokyo)
|
||||
now, n := deadline.Add(-(26*time.Hour + 3*time.Minute + 4*time.Second + 500*time.Millisecond)), 0
|
||||
format := CountdownClock
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Countdown(c, deadline, CountdownOptions{Now: now, Format: format}).Finished() {
|
||||
n++
|
||||
}
|
||||
}, 400, 120)
|
||||
if !tt.HasText("Time left, 1 d 2 h 3 min 5 s") {
|
||||
t.Fatalf("label %v", tt.Texts())
|
||||
}
|
||||
var clock, words string
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
clock, words = CountdownText(c, 26*time.Hour+3*time.Minute+5*time.Second, CountdownClock), CountdownText(c, 0, CountdownWords)
|
||||
}, 10, 10)
|
||||
if clock != "1d 02:03:05" || words != "0 s" {
|
||||
t.Fatalf("formats %q %q", clock, words)
|
||||
}
|
||||
format = CountdownWords
|
||||
now = deadline.Add(time.Second)
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if n != 1 || !tt.HasText("Time left, 0 s") {
|
||||
t.Fatalf("finished %d times, %v", n, tt.Texts())
|
||||
}
|
||||
if a := tt.Announcements(); len(a) != 1 || a[0] != "Time left: time is up." {
|
||||
t.Fatalf("announcements %q", a)
|
||||
}
|
||||
deadline = deadline.Add(time.Minute)
|
||||
now = deadline
|
||||
tt.Frame()
|
||||
if n != 2 {
|
||||
t.Fatal("a new deadline should finish again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountdownInvalidFormat(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Countdown(c, time.Now(), CountdownOptions{Format: 7})
|
||||
}, 10, 10)
|
||||
}
|
||||
|
||||
func TestCountdownSnapshot(t *testing.T) {
|
||||
deadline := time.Date(2026, time.October, 5, 12, 0, 0, 0, calTokyo)
|
||||
snapshot(t, "countdown", 320, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
Countdown(c, deadline, CountdownOptions{Now: deadline.Add(-(3*time.Hour + 25*time.Second))})
|
||||
Countdown(c, deadline, CountdownOptions{Now: deadline.Add(-90 * time.Second), Format: CountdownWords})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CronSchedule is a parsed five-field cron expression.
|
||||
type CronSchedule struct {
|
||||
fields [5]uint64
|
||||
tokens [5]string
|
||||
// domAny and dowAny note a "*" day field.
|
||||
domAny, dowAny bool
|
||||
}
|
||||
|
||||
// CronProblem is what is wrong with a cron field.
|
||||
type CronProblem int
|
||||
|
||||
const (
|
||||
CronFieldCount CronProblem = iota
|
||||
CronEmptyItem
|
||||
CronBadStep
|
||||
CronNotNumber
|
||||
CronOutOfRange
|
||||
CronBackwards
|
||||
)
|
||||
|
||||
// CronError locates a problem in a cron expression.
|
||||
type CronError struct {
|
||||
// Field is 0 to 4, or -1 for the count.
|
||||
Field int
|
||||
Problem CronProblem
|
||||
// Text is the offending part.
|
||||
Text string
|
||||
}
|
||||
|
||||
var cronProblemKeys = [...]string{"cron_editor.err_count", "cron_editor.err_empty", "cron_editor.err_step", "cron_editor.err_number",
|
||||
"cron_editor.err_range", "cron_editor.err_backwards"}
|
||||
|
||||
func (e *CronError) Error() string {
|
||||
return fmt.Sprintf("cron field %d: problem %d at %q", e.Field+1, e.Problem, e.Text)
|
||||
}
|
||||
|
||||
var cronBounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
|
||||
|
||||
var cronNames = [5][]string{3: {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"},
|
||||
4: {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}}
|
||||
|
||||
// ParseCron parses standard five-field cron with names.
|
||||
func ParseCron(expr string) (CronSchedule, error) {
|
||||
parts := strings.Fields(expr)
|
||||
var s CronSchedule
|
||||
if len(parts) != 5 {
|
||||
return s, &CronError{-1, CronFieldCount, strconv.Itoa(len(parts))}
|
||||
}
|
||||
for f, part := range parts {
|
||||
s.tokens[f] = part
|
||||
for _, item := range strings.Split(part, ",") {
|
||||
bits, err := cronItem(f, strings.ToUpper(item))
|
||||
if err != nil {
|
||||
err.Field = f
|
||||
return CronSchedule{}, err
|
||||
}
|
||||
s.fields[f] |= bits
|
||||
}
|
||||
}
|
||||
if s.fields[4]&(1<<7) != 0 {
|
||||
s.fields[4] |= 1
|
||||
}
|
||||
s.domAny, s.dowAny = parts[2] == "*", parts[4] == "*"
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cronItem parses one list item into a bit set.
|
||||
func cronItem(f int, item string) (uint64, *CronError) {
|
||||
lo, hi := cronBounds[f][0], cronBounds[f][1]
|
||||
if item == "" {
|
||||
return 0, &CronError{Problem: CronEmptyItem}
|
||||
}
|
||||
rng, step := item, 1
|
||||
if i := strings.IndexByte(item, '/'); i >= 0 {
|
||||
n, err := strconv.Atoi(item[i+1:])
|
||||
if err != nil || n < 1 {
|
||||
return 0, &CronError{Problem: CronBadStep, Text: item[i+1:]}
|
||||
}
|
||||
rng, step = item[:i], n
|
||||
}
|
||||
a, b := lo, hi
|
||||
switch {
|
||||
case rng == "*":
|
||||
case strings.Contains(rng, "-"):
|
||||
x, y, _ := strings.Cut(rng, "-")
|
||||
var e *CronError
|
||||
if a, e = cronValue(f, x); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if b, e = cronValue(f, y); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if a > b {
|
||||
return 0, &CronError{Problem: CronBackwards, Text: rng}
|
||||
}
|
||||
default:
|
||||
var e *CronError
|
||||
if a, e = cronValue(f, rng); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if step == 1 {
|
||||
b = a
|
||||
}
|
||||
}
|
||||
var bits uint64
|
||||
for v := a; v <= b; v += step {
|
||||
bits |= 1 << v
|
||||
}
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
func cronValue(f int, s string) (int, *CronError) {
|
||||
for i, n := range cronNames[f] {
|
||||
if s == n {
|
||||
return i + cronBounds[f][0], nil
|
||||
}
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, &CronError{Problem: CronNotNumber, Text: s}
|
||||
}
|
||||
if v < cronBounds[f][0] || v > cronBounds[f][1] {
|
||||
return 0, &CronError{Problem: CronOutOfRange, Text: s}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// CronRun is one upcoming run; Repeated marks a DST repeat.
|
||||
type CronRun struct {
|
||||
At time.Time
|
||||
Repeated bool
|
||||
}
|
||||
|
||||
// Next returns up to n runs within five years.
|
||||
func (s CronSchedule) Next(t time.Time, loc *time.Location, n int) []CronRun {
|
||||
var out []CronRun
|
||||
w := t.In(loc)
|
||||
day := DateOf(w)
|
||||
for i := 0; i < 5*366 && len(out) < n; i, day = i+1, day.AddDays(1) {
|
||||
if !s.dayMatches(day) {
|
||||
continue
|
||||
}
|
||||
for h := 0; h < 24 && len(out) < n; h++ {
|
||||
if s.fields[1]&(1<<h) == 0 {
|
||||
continue
|
||||
}
|
||||
for m := 0; m < 60 && len(out) < n; m++ {
|
||||
if s.fields[0]&(1<<m) == 0 {
|
||||
continue
|
||||
}
|
||||
at, k := dateTimeResolve(day, TimeOfDay{h, m, 0}, loc)
|
||||
if k == 0 || !at.After(t) {
|
||||
continue
|
||||
}
|
||||
out = append(out, CronRun{at, k > 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dayMatches applies cron's either-day-field rule.
|
||||
func (s CronSchedule) dayMatches(d Date) bool {
|
||||
if s.fields[3]&(1<<int(d.Month)) == 0 {
|
||||
return false
|
||||
}
|
||||
dom := s.fields[2]&(1<<d.Day) != 0
|
||||
dow := s.fields[4]&(1<<int(d.Weekday())) != 0
|
||||
if !s.domAny && !s.dowAny {
|
||||
return dom || dow
|
||||
}
|
||||
return dom && dow
|
||||
}
|
||||
|
||||
// CronEditorOptions configure a CronEditor.
|
||||
type CronEditorOptions struct {
|
||||
// Location is the zone runs are computed in; required.
|
||||
Location *time.Location
|
||||
// Now starts the preview; zero uses the system clock.
|
||||
Now time.Time
|
||||
// Runs is how many next runs show; zero means 5.
|
||||
Runs int
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// CronEditor edits, explains and previews a cron expression.
|
||||
func CronEditor(c *ui.Context, expr *string, opts CronEditorOptions) ChoiceResult {
|
||||
loc := eventLocation("CronEditor", opts.Location)
|
||||
if opts.Runs < 0 {
|
||||
panic("mujica: CronEditor Runs is negative")
|
||||
}
|
||||
runs := opts.Runs
|
||||
if runs == 0 {
|
||||
runs = 5
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "cron_editor.label")
|
||||
}
|
||||
old := *expr
|
||||
col := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
cache := ui.Local(col, "cron-parse", func() *cronParsed { return &cronParsed{} })
|
||||
col.Children(func() {
|
||||
cp := *cache
|
||||
if !cp.ok || cp.expr != *expr || !cp.from.Equal(now.Truncate(time.Minute)) {
|
||||
cp.s, cp.err = ParseCron(*expr)
|
||||
cp.expr, cp.ok, cp.from = *expr, true, now.Truncate(time.Minute)
|
||||
if cp.err == nil {
|
||||
cp.runs = cp.s.Next(now, loc, runs)
|
||||
}
|
||||
}
|
||||
problem := ""
|
||||
if cp.err != nil {
|
||||
problem = cronProblem(c, cp.err.(*CronError))
|
||||
}
|
||||
in := TextInput(c, expr, TextInputOptions{Label: msg(c, "cron_editor.expression"), Placeholder: "*/15 9-17 * * MON-FRI", Disabled: opts.Disabled, Error: problem})
|
||||
in.Input.Font(theme.MonoFont)
|
||||
bad := -2
|
||||
if cp.err != nil {
|
||||
bad = cp.err.(*CronError).Field
|
||||
}
|
||||
fields := strings.Fields(*expr)
|
||||
ui.Row(c).Gap(d.Unit() * 2).Wrap().Children(func() {
|
||||
for f := range 5 {
|
||||
tok := ""
|
||||
if f < len(fields) {
|
||||
tok = fields[f]
|
||||
}
|
||||
cronFieldCard(c, f, tok, f == bad || bad == -1)
|
||||
}
|
||||
})
|
||||
if cp.err != nil {
|
||||
return
|
||||
}
|
||||
ui.Text(c, msg(c, "cron_editor.next")).Font(theme.SerifFont).TextColor(k.Text)
|
||||
ui.Column(c).Gap(d.Unit()).Role(ui.RoleList).Children(func() {
|
||||
list := cp.runs
|
||||
if len(list) == 0 {
|
||||
ui.Text(c, msg(c, "cron_editor.never")).TextColor(k.TextMuted)
|
||||
}
|
||||
for _, run := range list {
|
||||
w := run.At.In(loc)
|
||||
text := msg(c, "calendar.weekday_"+strconv.Itoa(int(w.Weekday()))) + " " + DateOf(w).String() + " " +
|
||||
timeText(c, TimeOfDay{w.Hour(), w.Minute(), 0}, timeConfig{hour12: opts.Hour12}) + " " + w.Format("MST")
|
||||
if run.Repeated {
|
||||
text += " · " + msg(c, "cron_editor.repeated")
|
||||
}
|
||||
ui.Text(c, text).FontFeatures("tnum").TextColor(k.Text).Role(ui.RoleListItem)
|
||||
}
|
||||
})
|
||||
})
|
||||
changed := *expr != old && !col.IsDisabled()
|
||||
if changed {
|
||||
slog.Debug("mujica: cron_editor change", "valid", (*cache).err == nil)
|
||||
}
|
||||
return ChoiceResult{Element: col, changed: changed}
|
||||
}
|
||||
|
||||
// cronParsed caches the parse and the runs from one minute.
|
||||
type cronParsed struct {
|
||||
expr string
|
||||
from time.Time
|
||||
s CronSchedule
|
||||
err error
|
||||
runs []CronRun
|
||||
ok bool
|
||||
}
|
||||
|
||||
var cronFieldKeys = [5]string{"cron_editor.minute", "cron_editor.hour", "cron_editor.dom", "cron_editor.month", "cron_editor.dow"}
|
||||
|
||||
func cronProblem(c *ui.Context, e *CronError) string {
|
||||
reason := msg(c, cronProblemKeys[e.Problem])
|
||||
switch e.Problem {
|
||||
case CronFieldCount, CronBadStep, CronNotNumber, CronBackwards:
|
||||
reason = fmt.Sprintf(reason, e.Text)
|
||||
case CronOutOfRange:
|
||||
reason = fmt.Sprintf(reason, e.Text, cronBounds[e.Field][0], cronBounds[e.Field][1])
|
||||
}
|
||||
if e.Field < 0 {
|
||||
return reason
|
||||
}
|
||||
return fmt.Sprintf(msg(c, "cron_editor.field_error"), msg(c, cronFieldKeys[e.Field]), reason)
|
||||
}
|
||||
|
||||
// cronFieldCard shows one field's token and meaning.
|
||||
func cronFieldCard(c *ui.Context, f int, tok string, bad bool) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
border := k.Border
|
||||
if bad {
|
||||
border = k.Danger
|
||||
}
|
||||
ui.Column(c).MinWidth(d.ControlHeight()*3).Padding(d.Unit()*2).Gap(d.Unit()).Radius(theme.CardRadius).
|
||||
Border(theme.BorderWidth, border).Background(k.Surface).Children(func() {
|
||||
ui.Text(c, msg(c, cronFieldKeys[f])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Text(c, tok).Font(theme.MonoFont).TextColor(k.Text)
|
||||
desc := cronDescribe(c, f, tok)
|
||||
if bad {
|
||||
desc = msg(c, "cron_editor.invalid")
|
||||
}
|
||||
ui.Text(c, desc).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
|
||||
// cronDescribe words a field token, as "every 15" or "9–17".
|
||||
func cronDescribe(c *ui.Context, f int, tok string) string {
|
||||
if tok == "*" {
|
||||
return msg(c, "cron_editor.any")
|
||||
}
|
||||
var parts []string
|
||||
for _, item := range strings.Split(tok, ",") {
|
||||
rng, step, hasStep := strings.Cut(item, "/")
|
||||
rng = strings.ReplaceAll(rng, "-", "–")
|
||||
switch {
|
||||
case hasStep && rng == "*":
|
||||
parts = append(parts, fmt.Sprintf(msg(c, "cron_editor.every"), step))
|
||||
case hasStep:
|
||||
parts = append(parts, fmt.Sprintf(msg(c, "cron_editor.every_in"), step, rng))
|
||||
default:
|
||||
parts = append(parts, rng)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func cronRuns(t *testing.T, expr string, from time.Time, loc *time.Location, n int) []CronRun {
|
||||
t.Helper()
|
||||
s, err := ParseCron(expr)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", expr, err)
|
||||
}
|
||||
return s.Next(from, loc, n)
|
||||
}
|
||||
|
||||
func TestParseCronErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
expr string
|
||||
field int
|
||||
p CronProblem
|
||||
}{{"* * * *", -1, CronFieldCount}, {"60 * * * *", 0, CronOutOfRange}, {"* 9-5 * * *", 1, CronBackwards},
|
||||
{"*/0 * * * *", 0, CronBadStep}, {"* * x * *", 2, CronNotNumber}, {"1,,2 * * * *", 0, CronEmptyItem}, {"* * * 13 *", 3, CronOutOfRange}} {
|
||||
_, err := ParseCron(c.expr)
|
||||
var ce *CronError
|
||||
if !errors.As(err, &ce) || ce.Field != c.field || ce.Problem != c.p {
|
||||
t.Errorf("%q: %v", c.expr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronNextRuns(t *testing.T) {
|
||||
from := time.Date(2026, time.October, 5, 8, 50, 0, 0, calTokyo) // Monday
|
||||
got := cronRuns(t, "*/15 9-10 * * MON-FRI", from, calTokyo, 3)
|
||||
if len(got) != 3 || !got[0].At.Equal(calAt(t, 9, 0, 5)) || !got[2].At.Equal(calAt(t, 9, 30, 5)) {
|
||||
t.Fatalf("weekday runs %v", got)
|
||||
}
|
||||
got = cronRuns(t, "0 12 1 * SUN", from, calTokyo, 2)
|
||||
if !got[0].At.Equal(calAt(t, 12, 0, 11)) || !got[1].At.Equal(calAt(t, 12, 0, 18)) {
|
||||
t.Fatalf("day-of-month or day-of-week: %v", got)
|
||||
}
|
||||
got = cronRuns(t, "0 0 29 2 *", from, calTokyo, 1)
|
||||
if len(got) != 1 || got[0].At.Year() != 2028 {
|
||||
t.Fatalf("leap day %v", got)
|
||||
}
|
||||
if got = cronRuns(t, "0 0 31 2 *", from, calTokyo, 1); len(got) != 0 {
|
||||
t.Fatalf("Feb 31 never runs: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronNextRunsAcrossDST(t *testing.T) {
|
||||
ny, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := cronRuns(t, "30 2 * * *", time.Date(2026, time.March, 7, 3, 0, 0, 0, ny), ny, 2)
|
||||
if got[0].At.Day() != 9 {
|
||||
t.Fatalf("02:30 on the gap day must not run: %v", got)
|
||||
}
|
||||
got = cronRuns(t, "30 1 * * *", time.Date(2026, time.October, 31, 3, 0, 0, 0, ny), ny, 1)
|
||||
if !got[0].Repeated || got[0].At.Day() != 1 {
|
||||
t.Fatalf("01:30 on fall-back is repeated: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func cronTester(expr *string, opts CronEditorOptions, n *int) *ui.Tester {
|
||||
opts.Location, opts.Now = calTokyo, time.Date(2026, time.October, 5, 8, 50, 0, 0, calTokyo)
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if CronEditor(c, expr, opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 760, 520)
|
||||
}
|
||||
|
||||
func TestCronEditorEditAndPreview(t *testing.T) {
|
||||
expr, n := "0 9 * * MON-FRI", 0
|
||||
tt := cronTester(&expr, CronEditorOptions{Runs: 3}, &n)
|
||||
for _, s := range []string{"Mo 2026-10-05 09:00 JST", "Tu 2026-10-06 09:00 JST", "MON–FRI", "Every value"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Expression")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("0 25 * * *")
|
||||
if n == 0 || !tt.HasText("Hour: 25 is outside 0–23.") || tt.HasText("Next runs") {
|
||||
t.Fatalf("error not shown: %v", tt.Texts())
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("*/30 * * * *")
|
||||
if !tt.HasText("Every 30") || !tt.HasText("Mo 2026-10-05 09:00 JST") {
|
||||
t.Fatalf("fixed expression: %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronEditorDisabledAndLocale(t *testing.T) {
|
||||
expr, n := "0 9 * * *", 0
|
||||
tt := cronTester(&expr, CronEditorOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Expression")
|
||||
tt.Type("x")
|
||||
if expr != "0 9 * * *" || n != 0 {
|
||||
t.Fatal("disabled editor changed")
|
||||
}
|
||||
bad := "* *"
|
||||
tt = ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
CronEditor(c, &bad, CronEditorOptions{Location: calTokyo})
|
||||
}, 760, 400)
|
||||
if !tt.HasText("需要 5 个字段,而不是 2 个。") {
|
||||
t.Fatalf("zh error: %v", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without Location")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CronEditor(c, &expr, CronEditorOptions{})
|
||||
}, 10, 10)
|
||||
}
|
||||
|
||||
func TestCronEditorSnapshot(t *testing.T) {
|
||||
expr := "*/15 9-17 * * MON-FRI"
|
||||
snapshot(t, "cron_editor", 720, 400, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CronEditor(c, &expr, CronEditorOptions{Location: calTokyo, Now: time.Date(2026, time.October, 5, 8, 50, 0, 0, calTokyo)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// CurrentTimeIndicatorOptions configure a CurrentTimeIndicator.
|
||||
type CurrentTimeIndicatorOptions struct {
|
||||
// Location is the zone of the day shown; required.
|
||||
Location *time.Location
|
||||
// Day is the day the parent spans; zero means today.
|
||||
Day Date
|
||||
// Now overrides the clock; zero uses the system clock.
|
||||
Now time.Time
|
||||
Hour12 bool
|
||||
}
|
||||
|
||||
// CurrentTimeIndicator rules the current time across its parent.
|
||||
func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element {
|
||||
loc := eventLocation("CurrentTimeIndicator", opts.Location)
|
||||
k := Tokens(c)
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
c.After(now.Truncate(time.Minute).Add(time.Minute).Sub(now))
|
||||
}
|
||||
wall := now.In(loc)
|
||||
day := opts.Day
|
||||
if day.IsZero() {
|
||||
day = DateOf(wall)
|
||||
}
|
||||
datetimeCheckDate("CurrentTimeIndicator Day", day)
|
||||
e := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).PassThrough()
|
||||
if DateOf(wall) != day {
|
||||
return e.Role(ui.RoleNone)
|
||||
}
|
||||
frac := float32(wall.Hour()*60+wall.Minute()) / (24 * 60)
|
||||
when := timeText(c, TimeOfDay{wall.Hour(), wall.Minute(), 0}, timeConfig{hour12: opts.Hour12})
|
||||
e.Role(ui.RoleStatus).Label(fmt.Sprintf(msg(c, "current_time_indicator.label"), when))
|
||||
e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
y := internal.Snap(p, r.Y+r.H*frac)
|
||||
p.Line(r.X, y, r.X+r.W, y, 2, k.Danger)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, r.X+4), y, 8), k.Danger)
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestCurrentTimeIndicatorPositionAndZone(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 14, 3, 0, 0, 0, time.UTC) // 12:00 in Tokyo
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Box(c).Size(200, 240).Children(func() {
|
||||
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Day: oct(14), Now: now})
|
||||
})
|
||||
}, 200, 240)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if !tt.HasText("Now, 12:00") {
|
||||
t.Fatalf("label %v", tt.Texts())
|
||||
}
|
||||
img := tt.Image()
|
||||
if !datetimeNear(img.RGBAAt(100, 120), theme.Light().Danger) || datetimeNear(img.RGBAAt(100, 60), theme.Light().Danger) {
|
||||
t.Fatal("the line should sit halfway down at noon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTimeIndicatorOtherDayHidden(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 14, 12, 0, 0, 0, calTokyo)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Box(c).Size(200, 240).Children(func() {
|
||||
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Day: oct(15), Now: now})
|
||||
})
|
||||
}, 200, 240)
|
||||
if tt.HasText("Now,") {
|
||||
t.Fatal("no line on another day")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without Location")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{})
|
||||
}, 10, 10)
|
||||
}
|
||||
|
||||
func TestCurrentTimeIndicatorUsesClockWhileShown(t *testing.T) {
|
||||
shown := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Box(c).Size(200, 240).Children(func() {
|
||||
if shown {
|
||||
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: time.Local})
|
||||
}
|
||||
})
|
||||
}, 200, 240)
|
||||
if !tt.HasText("Now, ") {
|
||||
t.Fatalf("today's line missing: %v", tt.Texts())
|
||||
}
|
||||
shown = false
|
||||
tt.Frame()
|
||||
if tt.HasText("Now, ") {
|
||||
t.Fatal("hidden indicator still shows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentTimeIndicatorSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 14, 15, 30, 0, 0, calTokyo)
|
||||
snapshot(t, "current_time_indicator", 240, 160, func(c *ui.Context) {
|
||||
ui.Box(c).Margin(16).Size(208, 128).Border(1, Tokens(c).Border).Children(func() {
|
||||
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: calTokyo, Now: now})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# 421 CalendarMonthView · 月视图
|
||||
|
||||
## 用途
|
||||
|
||||
按月显示事件格。每天最多显示若干行事件,其余折叠为“+N”,点击后在浮层中列出;事件可拖到其他日期改期。同一天时间重叠的事件并排放在同一行。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
month := mujica.YearMonth{Year: 2026, Month: time.October}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc})
|
||||
if ch, ok := r.Changed(); ok {
|
||||
fmt.Println("move", ch.ID, "to", ch.Start)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func CalendarMonthView(c *ui.Context, month *YearMonth, events []CalendarEvent, opts CalendarMonthViewOptions) CalendarViewResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
|
||||
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
|
||||
| `MaxPerDay` | `int` | 0(即 3) | 每天显示的事件行数,超出部分折叠为“+N”。 |
|
||||
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦(焦点环)、今日(圆形底色 + 加粗)、非本月(弱化)、拖动经过的日期(选中底色 + 内嵌框)、禁用;改期失败时下方显示错误。
|
||||
|
||||
## 事件
|
||||
|
||||
`Opened()` 返回被激活的事件 ID;`DayChosen()` 返回点击的日期;`Changed()` 返回 `CalendarMove` 变更(保持墙上时间与时长)。上一月/下一月直接修改 `*month`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到达日期数字与事件块,方向键在其间移动。
|
||||
- Enter 或 Space 打开事件或选择日期。
|
||||
- 事件块上 Alt+←/→ 移动一天,Alt+↑/↓ 移动一周。
|
||||
- Escape 关闭“+N”浮层。
|
||||
|
||||
## 限制
|
||||
|
||||
- 拖动改期使用 MyGo 的 `Drag`/`Drop`;拖动预览是 MyGo 绘制的元素副本。
|
||||
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
|
||||
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 422 CalendarWeekView · 周视图
|
||||
|
||||
## 用途
|
||||
|
||||
一周的时间网格,顶部为全天行。在空白处拖动新建事件,拖动事件块移动,拖动底边调整时长;重叠事件按列并排。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
week := mujica.Date{Year: 2026, Month: time.October, Day: 14}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15})
|
||||
if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate {
|
||||
fmt.Println("new event", ch.Start, ch.End)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
|
||||
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
|
||||
| `SlotMinutes` | `int` | 0(即 30) | 拖动与键盘的时间格长度,须整除一小时。 |
|
||||
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 当前时间线的位置。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦列(时间格光标:选中底色 + 内嵌框)、拖动中(新建区域虚线框、被拖事件内嵌框)、今日表头、当前时间线、禁用;无法表示的时间显示错误。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 返回 `CalendarCreate`、`CalendarMove` 或 `CalendarResize`;`Opened()` 返回点击的事件;`DayChosen()` 返回点击的表头日期。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 聚焦某一天的列,↑/↓ 移动时间格光标,Enter 新建一格长的事件。
|
||||
- 事件块上 Alt+↑/↓ 按格移动,Alt+←/→ 移动一天,Alt+Shift+↑/↓ 调整结束时间。
|
||||
|
||||
## 限制
|
||||
|
||||
- 调整时长的底边手柄只有 6 DIP 高,低于 28 DIP 命中区域要求;同样的操作可用事件块上的 Alt+Shift+↑/↓ 完成。
|
||||
- 网格高 24 小时,默认滚动到 8:00;跨越 24 小时以上的事件放在全天行。
|
||||
- 时间按墙上时间排布,夏令时当天的重复或缺失小时不改变格高。
|
||||
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
|
||||
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 423 CalendarDayView · 日视图
|
||||
|
||||
## 用途
|
||||
|
||||
单日时间网格;重叠事件按列并排,交互与周视图相同。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
day := mujica.Date{Year: 2026, Month: time.October, Day: 14}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok {
|
||||
fmt.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func CalendarDayView(c *ui.Context, day *Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
|
||||
| `SlotMinutes` | `int` | 0(即 30) | 拖动与键盘的时间格长度,须整除一小时。 |
|
||||
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 当前时间线的位置。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
同周视图。
|
||||
|
||||
## 事件
|
||||
|
||||
同周视图:`Changed()`、`Opened()`、`DayChosen()`;前一天/后一天修改 `*day`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 同周视图。
|
||||
|
||||
## 限制
|
||||
|
||||
- 与周视图共用网格实现。
|
||||
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
|
||||
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 424 AgendaView · 日程列表
|
||||
|
||||
## 用途
|
||||
|
||||
按日分组的日程列表,日期标题在滚动时固定在顶部;只构建可见行,适合大量事件。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14}
|
||||
if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok {
|
||||
fmt.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func AgendaView(c *ui.Context, events []CalendarEvent, opts AgendaViewOptions) CalendarViewResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
|
||||
| `From` | `Date` | 零值(今天) | 列表的第一天。 |
|
||||
| `Days` | `int` | 0(即 30) | 列出的天数。 |
|
||||
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦、今日标题(强调色 + “今天”)、空状态、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Opened()` 返回被激活事件的 ID。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入列表,↑/↓ 在事件间移动,Enter 打开。
|
||||
|
||||
## 限制
|
||||
|
||||
- 跨越多天的事件在每一天下各列一次。
|
||||
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
|
||||
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 425 YearView · 年视图
|
||||
|
||||
## 用途
|
||||
|
||||
十二个月的小日历,每天下方用 1~3 个点表示事件密度,读屏会读出事件数量。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
year := 2026
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok {
|
||||
fmt.Println("go to", day)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func YearView(c *ui.Context, year *int, events []CalendarEvent, opts YearViewOptions) CalendarViewResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 |
|
||||
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
|
||||
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦、今日(底色 + 加粗)、密度点(1~2 项一点、3~5 项两点、6 项以上三点)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`DayChosen()` 返回被激活的日期;上一年/下一年及跨年移动修改 `*year`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入日期,←/→ 移动一天,↑/↓ 移动一周,PageUp/PageDown 移动一月,可跨年。
|
||||
- Enter 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
- 密度不只靠颜色:用点数区分,并写入标签。
|
||||
- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。
|
||||
- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 426 EventChip · 事件块
|
||||
|
||||
## 用途
|
||||
|
||||
显示单个事件:左侧色条、标题与时间,超出时截断为省略号。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() {
|
||||
fmt.Println("open", ev.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Inline` | `bool` | false | 开始时间与标题放在一行。 |
|
||||
| `Selected` | `bool` | false | 选中:选中底色 + 内嵌框。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停与按下(色调加深)、聚焦、选中、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
返回的元素可用 `.Clicked()` 判断激活。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 聚焦,Enter 或 Space 激活。
|
||||
|
||||
## 限制
|
||||
|
||||
- 事件颜色来自主题:AccentText、Info、Success、Warning、Danger、TextMuted,均在两套主题下对 Surface 达到 3:1;Ornament 在浅色主题下仅 2.8:1,不纳入。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 427 EventEditor · 事件编辑器
|
||||
|
||||
## 用途
|
||||
|
||||
编辑事件的标题、时间、地点、参与者、提醒与重复规则。编辑的是副本,点击保存并校验通过后才写回。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc})
|
||||
if r.Saved() {
|
||||
fmt.Println("saved", ev.Title, ev.Start)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) EventEditorResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、字段错误(保存后显示:缺少标题、结束早于开始、重复规则问题)、全天模式(只选日期)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Saved()`:校验通过并写回 `*ev`;`Canceled()`:丢弃草稿。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 在字段间移动,各字段保留自身按键;保存与取消为按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 全天事件保存时规整为当天零点到次日零点。
|
||||
- 参与者按姓名添加,新参与者状态为“未回复”。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 428 EventPopover · 事件浮层
|
||||
|
||||
## 用途
|
||||
|
||||
在锚点旁显示事件摘要:时间、地点、重复、参与者与提醒,并提供编辑与删除入口。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc})
|
||||
if chip.Clicked() {
|
||||
open = true
|
||||
}
|
||||
r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc})
|
||||
if r.Delete() {
|
||||
fmt.Println("delete", ev.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `ReadOnly` | `bool` | false | 隐藏编辑与删除。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
|
||||
## 状态
|
||||
|
||||
打开(160 ms 淡入,减少动画时直接显示)、只读、禁用(自动关闭)。
|
||||
|
||||
## 事件
|
||||
|
||||
`Edit()`、`Delete()` 报告点击并关闭浮层;删除只报告,确认由调用方决定。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到达关闭、删除与编辑;Escape 或点击外部关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 浮层尺寸受窗口限制,内容超出时滚动。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 429 CurrentTimeIndicator · 当前时间线
|
||||
|
||||
## 用途
|
||||
|
||||
填满父元素,按当前时间在当天中的比例画一条横线,用于时间网格。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Box(c).Size(200, 240).Children(func() {
|
||||
mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Day` | `Date` | 零值(今天) | 父元素自上而下代表的日期;不是今天时不显示。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 当前时间;固定后不再定时刷新,便于测试。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
|
||||
## 状态
|
||||
|
||||
显示(Danger 色线与菱形)、不在当天时隐藏。
|
||||
|
||||
## 事件
|
||||
|
||||
无事件;读屏通过标签读出当前时间。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 不可聚焦。
|
||||
|
||||
## 限制
|
||||
|
||||
- 仅在被构建(可见)时用 `c.After` 预约下一分钟的重绘;隐藏后不再预约,自动停止。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 430 RecurrenceEditor · 重复规则编辑器
|
||||
|
||||
## 用途
|
||||
|
||||
选择每天、每周、每月、每年或自定义间隔,每周可选星期,并设置结束条件(永不、次数、日期)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}})
|
||||
if r.Changed() && r.Problem() == "" {
|
||||
fmt.Println(mujica.RecurrenceSummary(c, rule))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func RecurrenceEditor(c *ui.Context, rule *Recurrence, opts RecurrenceEditorOptions) RecurrenceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Start` | `Date` | 零值 | 首次发生日期;结束日期不能早于它,每周规则默认取其星期。 |
|
||||
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、自定义(显示间隔与单位)、每周(星期按钮)、结束条件、问题提示(无星期、次数小于 1、结束日期过早)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告规则变化;`Problem()` 返回当前不可保存的原因;`RecurrenceSummary` 生成本地化描述。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 在重复选择、间隔、星期与结束条件之间移动,各控件保留自身按键。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只描述规则,不展开具体日期。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 431 AvailabilityPicker · 空闲时段选择
|
||||
|
||||
## 用途
|
||||
|
||||
按天与时间的网格,拖动涂抹自己的空闲时段,并显示其他人在每个时段的空闲人数。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}}
|
||||
mine := [][]bool{make([]bool, 18), make([]bool, 18)}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() {
|
||||
fmt.Println("availability changed")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func AvailabilityPicker(c *ui.Context, mine *[][]bool, opts AvailabilityPickerOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Days` | `[]Date` | 必填 | 网格的列。 |
|
||||
| `From / To` | `int` | 9 / 18 | 起止小时,0 ≤ From < To ≤ 24。 |
|
||||
| `SlotMinutes` | `int` | 0(即 30) | 时段长度。 |
|
||||
| `Others` | `[]AvailabilityPerson` | nil | 其他人的空闲表,维度须与值一致。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、自己空闲(选中底色 + 勾)、他人空闲(数量 + 条形)、全员可行(加粗强调)、键盘光标(内嵌框)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告自己的空闲表变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 聚焦网格,方向键移动光标,Space 或 Enter 切换。
|
||||
|
||||
## 限制
|
||||
|
||||
- 值的维度为 [天][时段],与选项不符时 panic。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 432 TimezoneSelect · 时区选择
|
||||
|
||||
## 用途
|
||||
|
||||
按城市名或偏移搜索 IANA 时区,常用时区置顶,显示当前偏移。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
zone := "Asia/Shanghai"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() {
|
||||
fmt.Println("zone", zone)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Zones` | `[]string` | 内置世界列表 | 可选时区。 |
|
||||
| `Common` | `[]string` | 内置常用列表 | 置顶的常用时区。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 计算偏移所用的时刻(夏令时会改变偏移)。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
关闭、打开(搜索框 + 分组列表)、高亮、已选(勾)、无结果、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告选中的时区变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Enter、Space 或 ↓ 打开;输入搜索;↑/↓ 移动高亮;Enter 选择;Escape 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 内嵌 `time/tzdata`,离线可用;未知时区名 panic。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 433 ReminderPicker · 提醒选择
|
||||
|
||||
## 用途
|
||||
|
||||
多选事件前的提醒时间,按选项顺序保存。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
reminders := []time.Duration{15 * time.Minute}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() {
|
||||
fmt.Println("reminders", reminders)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func ReminderPicker(c *ui.Context, value *[]time.Duration, opts ReminderPickerOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Choices` | `[]time.Duration` | 0、5 分、15 分、30 分、1 时、1 天、1 周 | 可选的提前量,须为整分钟且不重复。 |
|
||||
| `Max` | `int` | 0(不限) | 最多选择的数量,达到后其余选项禁用。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、选中(选中底色 + 下划线标记)、达到上限的禁用项、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入列表,↑/↓ 移动,Space 切换。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只支持事件开始前的提醒;提前量须为整分钟,否则 panic。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 434 AttendeeList · 参与者列表
|
||||
|
||||
## 用途
|
||||
|
||||
列出参与者、头像、邮箱与回复状态(文字 + 图标),顶部汇总各状态人数。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok {
|
||||
people = append(people[:i], people[i+1:]...)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Removable` | `bool` | false | 除组织者外显示移除按钮。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
接受、待定、拒绝、未回复(各有图标与文字)、组织者、可选、空状态、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Removed()` 返回被移除者的下标,由调用方从切片中删除。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到达各移除按钮,Enter 移除。
|
||||
|
||||
## 限制
|
||||
|
||||
- 回复状态不由组件修改。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 435 WeekPicker · 周选择器
|
||||
|
||||
## 用途
|
||||
|
||||
选择整周:悬停时预览整周,值为该周第一天。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var week mujica.Date
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() {
|
||||
fmt.Println("week of", week)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func WeekPicker(c *ui.Context, value *Date, opts WeekPickerOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 |
|
||||
| `Min / Max` | `Date` | 零值 | 所选周必须落在范围内。 |
|
||||
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 |
|
||||
| `Clearable` | `bool` | false | 显示清空按钮。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
关闭、打开(整周预览带、已选周)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告选中周变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Enter、Space 或 ↓ 打开;方向键移动;Enter 选择所在周;Escape 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 值不是周起始日时 panic;更换周起始日后由调用方重设值。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 436 DurationPicker · 时长选择器
|
||||
|
||||
## 用途
|
||||
|
||||
以时、分、秒三个数字框编辑时长,并检查范围。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
length := 90 * time.Minute
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour}
|
||||
if mujica.DurationPicker(c, &length, opts).Changed() {
|
||||
fmt.Println("length", length)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func DurationPicker(c *ui.Context, value *time.Duration, opts DurationPickerOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Seconds` | `bool` | false | 显示秒字段;关闭时值须为整分钟。 |
|
||||
| `Min / Max` | `time.Duration` | 0 / 99 时 59 分 59 秒 | 允许的范围。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
默认、聚焦、超出范围(错误说明,值不写入)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告合法的新时长。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 在三个字段间移动;↑/↓ 步进;键入数字后 Enter。
|
||||
|
||||
## 限制
|
||||
|
||||
- 超出范围的输入保留并报告,不会被夹取。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 437 CronEditor · Cron 编辑器
|
||||
|
||||
## 用途
|
||||
|
||||
编辑标准 5 字段 cron 表达式,逐字段解释、就地报告错误,并预览接下来的运行时间。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
expr := "0 9 * * MON-FRI"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() {
|
||||
if _, err := mujica.ParseCron(expr); err == nil {
|
||||
fmt.Println("schedule", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func CronEditor(c *ui.Context, expr *string, opts CronEditorOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 预览的起点。 |
|
||||
| `Runs` | `int` | 0(即 5) | 预览的次数。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
有效(字段卡片 + 运行时间)、错误(出错字段红框 + 说明,不显示预览)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告表达式文本变化;`ParseCron` 可单独校验并计算 `Next`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 在表达式框中输入;解释与预览随输入更新。
|
||||
|
||||
## 限制
|
||||
|
||||
- 支持列表、范围、步长、`*` 与月份/星期英文缩写;日与星期同时受限时任一匹配即运行(cron 惯例)。
|
||||
- 夏令时跳过的时间不运行;重复的时间取第一次并标注。
|
||||
- 只搜索未来五年。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 438 RelativeTime · 相对时间
|
||||
|
||||
## 用途
|
||||
|
||||
把时间显示为“3 分钟前”“2 天后”,并在提示与标签中给出精确时间。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 |
|
||||
| `Hour12` | `bool` | false | 12 小时制显示时间。 |
|
||||
|
||||
## 状态
|
||||
|
||||
刚刚、分钟、小时、天、月、年。
|
||||
|
||||
## 事件
|
||||
|
||||
无事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 不可聚焦;精确时间写在标签与提示中。
|
||||
|
||||
## 限制
|
||||
|
||||
- 刷新频率随距离变化:2 分钟内每秒、2 小时内每分钟、更远每小时;仅在可见时用 `c.After` 预约,隐藏即停止。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 439 Countdown · 倒计时
|
||||
|
||||
## 用途
|
||||
|
||||
显示距截止时间的剩余时间,归零时报告一次并朗读提示。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() {
|
||||
fmt.Println("happy new year")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Countdown(c *ui.Context, deadline time.Time, opts CountdownOptions) CountdownResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Format` | `CountdownFormat` | `CountdownClock` | 时钟格式 01:02:03 或文字格式“1 小时 2 分 3 秒”。 |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
计时中、归零(强调色)。
|
||||
|
||||
## 事件
|
||||
|
||||
`Finished()` 在每个截止时间归零的那一帧报告一次。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 不可聚焦;归零时通过 `Announce` 通知读屏。
|
||||
|
||||
## 限制
|
||||
|
||||
- 仅在可见时每秒用 `c.After` 预约重绘,隐藏或归零后停止。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 440 Stopwatch · 秒表
|
||||
|
||||
## 用途
|
||||
|
||||
秒表:开始、暂停、继续、计次与复位,计次列表显示单圈与累计时间。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var sw mujica.StopwatchState
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() {
|
||||
fmt.Println("laps", len(sw.Laps))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Stopwatch(c *ui.Context, s *StopwatchState, opts StopwatchOptions) ChoiceResult`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 |
|
||||
| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 |
|
||||
| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
未开始、运行中、暂停(显示“继续”)、有计次、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 报告开始、暂停、计次或复位;`StopwatchState.Elapsed` 与 `Laps` 读取结果。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到达开始/暂停、计次与复位,Enter 或 Space 按下。
|
||||
|
||||
## 限制
|
||||
|
||||
- 运行且可见时每 100 ms 用 `c.After` 预约重绘;暂停或隐藏后停止。
|
||||
@@ -0,0 +1,100 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// DurationPickerOptions configure a DurationPicker.
|
||||
type DurationPickerOptions struct {
|
||||
// Seconds adds a seconds field.
|
||||
Seconds bool
|
||||
// Min and Max bound the duration; zero Max means 99:59:59.
|
||||
Min, Max time.Duration
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// DurationPicker edits hours, minutes and seconds within a range.
|
||||
func DurationPicker(c *ui.Context, value *time.Duration, opts DurationPickerOptions) ChoiceResult {
|
||||
hi := opts.Max
|
||||
if hi == 0 {
|
||||
hi = 99*time.Hour + 59*time.Minute + 59*time.Second
|
||||
}
|
||||
unit := time.Minute
|
||||
if opts.Seconds {
|
||||
unit = time.Second
|
||||
}
|
||||
switch {
|
||||
case opts.Min < 0 || hi < opts.Min:
|
||||
panic(fmt.Sprintf("mujica: DurationPicker range %v..%v is not valid", opts.Min, hi))
|
||||
case *value < 0 || *value%unit != 0:
|
||||
panic(fmt.Sprintf("mujica: DurationPicker value %v is negative or finer than the fields", *value))
|
||||
}
|
||||
d := Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "duration_picker.label")
|
||||
}
|
||||
col := ui.Column(c).Gap(d.Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
draft := ui.Local(col, "duration-draft", func() [3]float64 { return durationParts(*value) })
|
||||
seen := ui.Local(col, "duration-seen", func() time.Duration { return *value })
|
||||
if *seen != *value {
|
||||
*draft, *seen = durationParts(*value), *value
|
||||
}
|
||||
changed, edited := false, false
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
fields := []struct {
|
||||
key string
|
||||
max float64
|
||||
}{{"duration_picker.hours", math.Floor(hi.Hours())}, {"duration_picker.minutes", 59}, {"duration_picker.seconds", 59}}
|
||||
for i, f := range fields {
|
||||
if i == 2 && !opts.Seconds {
|
||||
break
|
||||
}
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Width(d.ControlHeight() * 3.2).Children(func() {
|
||||
r := NumberInput(c, &draft[i], NumberInputOptions{Min: 0, Max: f.max, Label: msg(c, f.key), Disabled: opts.Disabled})
|
||||
edited = edited || r.Changed()
|
||||
})
|
||||
ui.Text(c, msg(c, f.key+"_unit")).TextColor(Tokens(c).TextMuted)
|
||||
})
|
||||
}
|
||||
})
|
||||
total := time.Duration(draft[0])*time.Hour + time.Duration(draft[1])*time.Minute + time.Duration(draft[2])*time.Second
|
||||
problem := ""
|
||||
if total < opts.Min || total > hi {
|
||||
problem = fmt.Sprintf(msg(c, "duration_picker.range"), DurationText(c, opts.Min), DurationText(c, hi))
|
||||
}
|
||||
if edited && problem == "" && total != *value && !col.IsDisabled() {
|
||||
slog.Debug("mujica: duration_picker change", "from", *value, "to", total)
|
||||
*value, *seen, changed = total, total, true
|
||||
}
|
||||
datetimeError(c, problem)
|
||||
})
|
||||
return ChoiceResult{Element: col, changed: changed}
|
||||
}
|
||||
|
||||
func durationParts(v time.Duration) [3]float64 {
|
||||
return [3]float64{float64(v / time.Hour), float64(v % time.Hour / time.Minute), float64(v % time.Minute / time.Second)}
|
||||
}
|
||||
|
||||
// DurationText words a duration, as "1 h 30 min".
|
||||
func DurationText(c *ui.Context, v time.Duration) string {
|
||||
p := durationParts(v)
|
||||
s := ""
|
||||
for i, key := range []string{"duration_picker.hours_unit", "duration_picker.minutes_unit", "duration_picker.seconds_unit"} {
|
||||
if p[i] > 0 || i == 1 && s == "" && p[2] == 0 {
|
||||
if s != "" {
|
||||
s += " "
|
||||
}
|
||||
s += fmt.Sprintf("%d %s", int(p[i]), msg(c, key))
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func durationTester(v *time.Duration, opts *DurationPickerOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if DurationPicker(c, v, *opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
}, 640, 200)
|
||||
}
|
||||
|
||||
func TestDurationPickerEditsFields(t *testing.T) {
|
||||
v, n := 90*time.Minute, 0
|
||||
opts := &DurationPickerOptions{Seconds: true}
|
||||
tt := durationTester(&v, opts, &n)
|
||||
_ = tt.Click("Hours")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
_ = tt.Click("Seconds")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("45")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != 2*time.Hour+30*time.Minute+45*time.Second || n != 2 {
|
||||
t.Fatalf("value %v (%d)", v, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationPickerRangeReported(t *testing.T) {
|
||||
v, n := 30*time.Minute, 0
|
||||
opts := &DurationPickerOptions{Min: 15 * time.Minute, Max: 2 * time.Hour}
|
||||
tt := durationTester(&v, opts, &n)
|
||||
_ = tt.Click("Hours")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 90*time.Minute || n != 1 {
|
||||
t.Fatalf("1 h 30 min should fit the range: %v", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 90*time.Minute || !tt.HasText("Choose a duration from 15 min to 2 h.") {
|
||||
t.Fatalf("over Max accepted: %v %v", v, tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Minutes")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("0")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != 2*time.Hour || n != 2 || tt.HasText("Choose a duration") {
|
||||
t.Fatalf("fixing the minutes: %v", v)
|
||||
}
|
||||
if tt.HasText("Seconds") {
|
||||
t.Fatal("seconds field without Seconds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationPickerDisabledAndInvalid(t *testing.T) {
|
||||
v, n := time.Hour, 0
|
||||
tt := durationTester(&v, &DurationPickerOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Hours")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != time.Hour || n != 0 {
|
||||
t.Fatal("disabled picker changed")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
v time.Duration
|
||||
opts DurationPickerOptions
|
||||
}{{30 * time.Second, DurationPickerOptions{}}, {0, DurationPickerOptions{Min: time.Hour, Max: time.Minute}}, {-time.Minute, DurationPickerOptions{}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%v %+v: expected panic", c.v, c.opts)
|
||||
}
|
||||
}()
|
||||
durationTester(&c.v, &c.opts, &n)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationPickerSnapshot(t *testing.T) {
|
||||
v := 2*time.Hour + 15*time.Minute + 30*time.Second
|
||||
snapshot(t, "duration_picker", 640, 80, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { DurationPicker(c, &v, DurationPickerOptions{Seconds: true}) })
|
||||
})
|
||||
}
|
||||
@@ -14,13 +14,13 @@ type EventChipOptions struct {
|
||||
// Location is the zone the time shows in; required.
|
||||
Location *time.Location
|
||||
Hour12 bool
|
||||
// Inline puts time and title on one line.
|
||||
// Inline puts the start time and title on one line.
|
||||
Inline bool
|
||||
Selected bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// EventChip is a clickable event block: color stripe, time and title, truncated to fit.
|
||||
// EventChip is a clickable, truncating event block.
|
||||
func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element {
|
||||
loc := eventLocation("EventChip", opts.Location)
|
||||
k := Tokens(c)
|
||||
@@ -32,7 +32,7 @@ func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Eleme
|
||||
title = msg(c, "event_chip.untitled")
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Justify(ui.Start).AlignItems(ui.Start).
|
||||
Radius(theme.ControlRadius).Padding(d.Unit(), d.Unit()*2).PaddingX(d.Unit()*2).Gap(d.Unit()).
|
||||
Radius(theme.ControlRadius).Padding(d.Unit(), d.Unit()*2).PaddingX(d.Unit() * 2).Gap(d.Unit()).
|
||||
Label(title + ", " + when).Disabled(opts.Disabled).Clip()
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
bg := col.Alpha(0.14).Over(k.Surface)
|
||||
@@ -60,7 +60,7 @@ func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Eleme
|
||||
b.Children(func() {
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
if opts.Inline {
|
||||
ui.Text(c, when).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(0).FontFeatures("tnum")
|
||||
ui.Text(c, eventStartText(c, ev, loc, opts.Hour12)).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(0).FontFeatures("tnum")
|
||||
}
|
||||
ui.Text(c, title).FontSize(small).Bold().TextColor(k.Text).SingleLine().Ellipsis("…").Shrink(1)
|
||||
if !opts.Inline {
|
||||
@@ -69,3 +69,12 @@ func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Eleme
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// eventStartText is the start time alone, or "All day".
|
||||
func eventStartText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) string {
|
||||
if ev.AllDay {
|
||||
return msg(c, "event_chip.all_day")
|
||||
}
|
||||
s := ev.Start.In(loc)
|
||||
return timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, timeConfig{hour12: hour12})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
var calTokyo = func() *time.Location {
|
||||
loc, err := time.LoadLocation("Asia/Tokyo")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return loc
|
||||
}()
|
||||
|
||||
// calEvent is an event on calendarToday's month in Tokyo.
|
||||
func calEvent(id string, day, h, m, mins int) CalendarEvent {
|
||||
s := time.Date(2026, time.October, day, h, m, 0, 0, calTokyo)
|
||||
return CalendarEvent{ID: id, Title: "Event " + id, Start: s, End: s.Add(time.Duration(mins) * time.Minute)}
|
||||
}
|
||||
|
||||
func TestEventChipShowsZoneTimeAndClicks(t *testing.T) {
|
||||
ev := calEvent("a", 6, 9, 30, 90)
|
||||
ev.Start, ev.End = ev.Start.UTC(), ev.End.UTC()
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if EventChip(c, ev, EventChipOptions{Location: calTokyo}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 300, 200)
|
||||
if !tt.HasText("09:30 – 11:00") || !tt.HasText("Event a") {
|
||||
t.Fatalf("chip should show Tokyo wall time: %v", tt.Texts())
|
||||
}
|
||||
label := "Event a, 09:30 – 11:00"
|
||||
_ = tt.Click(label)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 2 {
|
||||
t.Fatalf("clicks %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChipTruncatesAndAllDay(t *testing.T) {
|
||||
ev := calEvent("b", 6, 0, 0, 24*60)
|
||||
ev.AllDay, ev.Title = true, strings.Repeat("Long title ", 20)
|
||||
var w float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(120).Children(func() {
|
||||
w = EventChip(c, ev, EventChipOptions{Location: calTokyo, Inline: true}).Bounds().W
|
||||
})
|
||||
}, 300, 200)
|
||||
tt.Frame()
|
||||
if w > 120 || !tt.HasText("All day") {
|
||||
t.Fatalf("width %v texts %v", w, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChipDisabledAndInvalid(t *testing.T) {
|
||||
ev := calEvent("c", 6, 9, 0, 30)
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if EventChip(c, ev, EventChipOptions{Location: calTokyo, Disabled: true}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 300, 200)
|
||||
_ = tt.Click("Event c, 09:00 – 09:30")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 0 {
|
||||
t.Fatal("disabled chip clicked")
|
||||
}
|
||||
for _, f := range []func(){
|
||||
func() { EventChip(nil, ev, EventChipOptions{}) },
|
||||
func() { ev.Color = 9; eventColor(theme.Light(), ev.Color) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected panic")
|
||||
}
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventPaletteContrast(t *testing.T) {
|
||||
for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
|
||||
for i, col := range eventPalette(k) {
|
||||
if r := theme.Contrast(col, k.Surface); r < 3 {
|
||||
t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventColumns(t *testing.T) {
|
||||
col, cols := eventColumns([]eventSpan{{0, 2}, {1, 3}, {1.5, 2.5}, {2, 4}, {5, 6}})
|
||||
want := []int{0, 1, 2, 0, 0}
|
||||
wantN := []int{3, 3, 3, 3, 1}
|
||||
for i := range want {
|
||||
if col[i] != want[i] || cols[i] != wantN[i] {
|
||||
t.Fatalf("columns %v of %v", col, cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChipSnapshot(t *testing.T) {
|
||||
evs := []CalendarEvent{calEvent("1", 6, 9, 0, 60), calEvent("2", 6, 13, 0, 30), calEvent("3", 6, 15, 0, 45)}
|
||||
evs[1].Color, evs[2].Color = EventSuccess, EventWarning
|
||||
snapshot(t, "event_chip", 420, 150, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(8).Children(func() {
|
||||
EventChip(c, evs[0], EventChipOptions{Location: calTokyo})
|
||||
EventChip(c, evs[1], EventChipOptions{Location: calTokyo, Selected: true})
|
||||
EventChip(c, evs[2], EventChipOptions{Location: calTokyo, Inline: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// EventEditorOptions configure an EventEditor.
|
||||
type EventEditorOptions struct {
|
||||
// Location is the zone times are edited in; required.
|
||||
Location *time.Location
|
||||
Hour12 bool
|
||||
WeekStart WeekStart
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// EventEditorResult is the editor and its save or cancel.
|
||||
type EventEditorResult struct {
|
||||
Element *ui.Element
|
||||
saved bool
|
||||
canceled bool
|
||||
}
|
||||
|
||||
// Saved reports a valid draft written to the event.
|
||||
func (r EventEditorResult) Saved() bool { return r.saved }
|
||||
|
||||
// Canceled reports that the user discarded the draft.
|
||||
func (r EventEditorResult) Canceled() bool { return r.canceled }
|
||||
|
||||
// eventEditorState is the draft and whether Save was tried.
|
||||
type eventEditorState struct {
|
||||
draft CalendarEvent
|
||||
tried bool
|
||||
origin string
|
||||
}
|
||||
|
||||
// EventEditor edits a copy of *ev; Save checks and writes.
|
||||
func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) EventEditorResult {
|
||||
loc := eventLocation("EventEditor", opts.Location)
|
||||
d := Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "event_editor.label")
|
||||
}
|
||||
r := EventEditorResult{}
|
||||
root := ui.Column(c).Gap(d.Unit() * 4).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = root
|
||||
st := ui.Local(root, "event-editor", func() *eventEditorState { return nil })
|
||||
if *st == nil || (*st).origin != ev.ID {
|
||||
*st = &eventEditorState{draft: eventCopy(*ev), origin: ev.ID}
|
||||
}
|
||||
s := *st
|
||||
dr := &s.draft
|
||||
problems := eventEditorProblems(c, *dr, s.tried)
|
||||
root.Children(func() {
|
||||
FormField(c, msg(c, "event_editor.title"), FormFieldOptions{Required: true, Error: problems["title"]}, func() *ui.Element {
|
||||
return TextInput(c, &dr.Title, TextInputOptions{Label: msg(c, "event_editor.title"), Disabled: opts.Disabled, Error: problems["title"]}).Input
|
||||
})
|
||||
Checkbox(c, &dr.AllDay, msg(c, "event_editor.all_day"), CheckboxOptions{Disabled: opts.Disabled})
|
||||
ui.Row(c).Gap(d.Unit() * 3).Wrap().AlignItems(ui.Start).Children(func() {
|
||||
for _, f := range []struct {
|
||||
key string
|
||||
t *time.Time
|
||||
}{{"event_editor.starts", &dr.Start}, {"event_editor.ends", &dr.End}} {
|
||||
FormField(c, msg(c, f.key), FormFieldOptions{Error: problems[f.key]}, func() *ui.Element {
|
||||
if dr.AllDay {
|
||||
day := DateOf(f.t.In(loc))
|
||||
r := DatePicker(c, &day, DatePickerOptions{Label: msg(c, f.key), WeekStart: opts.WeekStart, Disabled: opts.Disabled})
|
||||
if r.Changed() {
|
||||
*f.t = eventDayStart(day, loc)
|
||||
}
|
||||
return r.Input
|
||||
}
|
||||
return DateTimePicker(c, f.t, DateTimePickerOptions{Location: loc, Hour12: opts.Hour12, MinuteStep: 5,
|
||||
WeekStart: opts.WeekStart, Label: msg(c, f.key), Disabled: opts.Disabled}).Element
|
||||
})
|
||||
}
|
||||
})
|
||||
FormField(c, msg(c, "event_editor.where"), FormFieldOptions{}, func() *ui.Element {
|
||||
return TextInput(c, &dr.Location, TextInputOptions{Label: msg(c, "event_editor.where"), Disabled: opts.Disabled}).Input
|
||||
})
|
||||
FormField(c, msg(c, "event_editor.guests"), FormFieldOptions{Description: msg(c, "event_editor.guests_help")}, func() *ui.Element {
|
||||
names := make([]string, len(dr.Attendees))
|
||||
for i, a := range dr.Attendees {
|
||||
names[i] = a.Name
|
||||
}
|
||||
p := TagsInput(c, &names, TagsInputOptions{Label: msg(c, "event_editor.guests"), Disabled: opts.Disabled})
|
||||
if p.Changed() {
|
||||
dr.Attendees = eventAttendees(dr.Attendees, names)
|
||||
}
|
||||
return p.Input
|
||||
})
|
||||
if len(dr.Attendees) > 0 {
|
||||
AttendeeList(c, dr.Attendees, AttendeeListOptions{Disabled: opts.Disabled})
|
||||
}
|
||||
FormField(c, msg(c, "event_editor.reminders"), FormFieldOptions{}, func() *ui.Element {
|
||||
return ReminderPicker(c, &dr.Reminders, ReminderPickerOptions{Disabled: opts.Disabled}).Element
|
||||
})
|
||||
var rec RecurrenceResult
|
||||
FormField(c, msg(c, "recurrence_editor.repeat"), FormFieldOptions{}, func() *ui.Element {
|
||||
rec = RecurrenceEditor(c, &dr.Repeat, RecurrenceEditorOptions{Start: DateOf(dr.Start.In(loc)), WeekStart: opts.WeekStart, Disabled: opts.Disabled})
|
||||
return rec.Element
|
||||
})
|
||||
datetimeFooter(c, func() {
|
||||
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() && !root.IsDisabled() {
|
||||
slog.Debug("mujica: event_editor cancel", "id", ev.ID)
|
||||
*st, r.canceled = nil, true
|
||||
}
|
||||
if Button(c, msg(c, "event_editor.save"), ButtonOptions{}).Clicked() && !root.IsDisabled() {
|
||||
s.tried = true
|
||||
if len(eventEditorProblems(c, *dr, true)) == 0 && rec.Problem() == "" {
|
||||
slog.Debug("mujica: event_editor save", "id", ev.ID)
|
||||
out := eventCopy(*dr)
|
||||
if out.AllDay {
|
||||
first, last := DateOf(out.Start.In(loc)), DateOf(out.End.Add(-time.Nanosecond).In(loc))
|
||||
out.Start, out.End = eventDayStart(first, loc), eventDayStart(last.AddDays(1), loc)
|
||||
}
|
||||
*ev, r.saved = out, true
|
||||
} else {
|
||||
slog.Debug("mujica: event_editor invalid", "id", ev.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// eventEditorProblems checks a draft once Save was tried.
|
||||
func eventEditorProblems(c *ui.Context, ev CalendarEvent, tried bool) map[string]string {
|
||||
p := map[string]string{}
|
||||
if !tried {
|
||||
return p
|
||||
}
|
||||
if strings.TrimSpace(ev.Title) == "" {
|
||||
p["title"] = msg(c, "event_editor.need_title")
|
||||
}
|
||||
if ev.Start.IsZero() {
|
||||
p["event_editor.starts"] = msg(c, "event_editor.need_start")
|
||||
}
|
||||
if !ev.End.After(ev.Start) {
|
||||
p["event_editor.ends"] = msg(c, "event_editor.end_order")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// eventAttendees keeps known attendees and adds new ones.
|
||||
func eventAttendees(old []Attendee, names []string) []Attendee {
|
||||
out := make([]Attendee, 0, len(names))
|
||||
for _, n := range names {
|
||||
i := slices.IndexFunc(old, func(a Attendee) bool { return a.Name == n })
|
||||
if i >= 0 {
|
||||
out = append(out, old[i])
|
||||
} else {
|
||||
out = append(out, Attendee{Name: n})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eventCopy clones an event's slices for a draft.
|
||||
func eventCopy(ev CalendarEvent) CalendarEvent {
|
||||
ev.Attendees = slices.Clone(ev.Attendees)
|
||||
ev.Reminders = slices.Clone(ev.Reminders)
|
||||
ev.Repeat.Weekdays = slices.Clone(ev.Repeat.Weekdays)
|
||||
return ev
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type editorProbe struct {
|
||||
ev CalendarEvent
|
||||
saves, cancels int
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func (p *editorProbe) tester() *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Scroll(c).Fill().Padding(16).Children(func() {
|
||||
r := EventEditor(c, &p.ev, EventEditorOptions{Location: calTokyo, Disabled: p.disabled})
|
||||
if r.Saved() {
|
||||
p.saves++
|
||||
}
|
||||
if r.Canceled() {
|
||||
p.cancels++
|
||||
}
|
||||
})
|
||||
}, 900, 1400)
|
||||
}
|
||||
|
||||
func TestEventEditorValidatesAndSaves(t *testing.T) {
|
||||
p := &editorProbe{ev: CalendarEvent{ID: "n", Start: calAt(t, 9, 0, 14), End: calAt(t, 9, 0, 13)}}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Save")
|
||||
if p.saves != 0 || !tt.HasText("Enter a title.") || !tt.HasText("The end must be after the start.") {
|
||||
t.Fatalf("invalid draft saved: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Title")
|
||||
tt.Type("Masquerade")
|
||||
_ = tt.Click("Location")
|
||||
tt.Type("Hall")
|
||||
_ = tt.Click("Guests")
|
||||
tt.Type("Mutsumi")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("15 minutes before")
|
||||
_ = tt.Click("2026-10-13 09:00 JST")
|
||||
_ = tt.Click(datetimeTestLabel(oct(14)))
|
||||
_ = tt.Click("Time: Hours")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
_ = tt.Click("Confirm")
|
||||
if p.ev.Title != "" {
|
||||
t.Fatal("the caller's event changed before Save")
|
||||
}
|
||||
_ = tt.Click("Save")
|
||||
ev := p.ev
|
||||
if p.saves != 1 || ev.Title != "Masquerade" || ev.Location != "Hall" || len(ev.Attendees) != 1 || ev.Attendees[0].Name != "Mutsumi" ||
|
||||
len(ev.Reminders) != 1 || !ev.End.Equal(calAt(t, 10, 0, 14)) {
|
||||
t.Fatalf("saved %+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventEditorAllDayRepeatCancel(t *testing.T) {
|
||||
p := &editorProbe{ev: calEvent("e", 14, 9, 0, 60)}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("All day")
|
||||
_ = tt.Click("Does not repeat")
|
||||
_ = tt.Click("Weekly")
|
||||
_ = tt.Click("Save")
|
||||
if p.saves != 1 || !p.ev.AllDay || p.ev.Repeat.Unit != RepeatWeekly || p.ev.Repeat.Weekdays[0] != time.Wednesday ||
|
||||
!p.ev.Start.Equal(calAt(t, 0, 0, 14)) || !p.ev.End.Equal(calAt(t, 0, 0, 15)) {
|
||||
t.Fatalf("saved %+v", p.ev)
|
||||
}
|
||||
_ = tt.Click("Title")
|
||||
tt.Type(" draft")
|
||||
_ = tt.Click("Cancel")
|
||||
if p.cancels != 1 || p.ev.Title != "Event e" {
|
||||
t.Fatalf("cancel kept %q", p.ev.Title)
|
||||
}
|
||||
tt.Frame()
|
||||
_ = tt.Click("Save")
|
||||
if p.ev.Title != "Event e" {
|
||||
t.Fatal("canceled draft came back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventEditorDisabled(t *testing.T) {
|
||||
p := &editorProbe{ev: calEvent("e", 14, 9, 0, 60), disabled: true}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Title")
|
||||
tt.Type("x")
|
||||
_ = tt.Click("Save")
|
||||
_ = tt.Click("Cancel")
|
||||
if p.saves != 0 || p.cancels != 0 || p.ev.Title != "Event e" {
|
||||
t.Fatal("disabled editor acted")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without Location")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
EventEditor(c, &p.ev, EventEditorOptions{})
|
||||
}, 10, 10)
|
||||
}
|
||||
|
||||
func TestEventEditorSnapshot(t *testing.T) {
|
||||
ev := calRichEvent()
|
||||
snapshot(t, "event_editor", 760, 1100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { EventEditor(c, &ev, EventEditorOptions{Location: calTokyo}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// EventPopoverOptions configure an EventPopover.
|
||||
type EventPopoverOptions struct {
|
||||
// Location is the zone times show in; required.
|
||||
Location *time.Location
|
||||
Hour12 bool
|
||||
// ReadOnly hides the edit and delete actions.
|
||||
ReadOnly bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// EventPopoverResult is the popover and the action the user chose.
|
||||
type EventPopoverResult struct {
|
||||
Element *ui.Element
|
||||
edit bool
|
||||
del bool
|
||||
}
|
||||
|
||||
// Edit reports a click on Edit.
|
||||
func (r EventPopoverResult) Edit() bool { return r.edit }
|
||||
|
||||
// Delete reports a click on Delete.
|
||||
func (r EventPopoverResult) Delete() bool { return r.del }
|
||||
|
||||
// EventPopover summarizes an event beside anchor while *open.
|
||||
func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult {
|
||||
loc := eventLocation("EventPopover", opts.Location)
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var r EventPopoverResult
|
||||
if *open && (opts.Disabled || anchor.IsDisabled()) {
|
||||
slog.Debug("mujica: event_popover close", "disabled", true)
|
||||
*open = false
|
||||
}
|
||||
r.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayEnter(c, panel))
|
||||
ww, wh := c.Size()
|
||||
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 4).Gap(d.Unit() * 2).Width(min(d.ControlHeight()*9, ww-16)).MaxHeight(wh - 16).
|
||||
Role(ui.RoleDialog).Label(ev.Title)
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Size(10, 10).Shrink(0).Draw(func(p *ui.Painter, rr ui.Rect) {
|
||||
p.FillPath(internal.Diamond(rr.X+rr.W/2, rr.Y+rr.H/2, rr.W), eventColor(k, ev.Color))
|
||||
})
|
||||
ui.Text(c, ev.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).Shrink(1)
|
||||
if iconAction(c, icons.Must("x"), msg(c, "event_popover.close")).Clicked() {
|
||||
*open = false
|
||||
}
|
||||
})
|
||||
ui.Scroll(c).Shrink(1).Children(func() {
|
||||
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
|
||||
for _, line := range eventPopoverLines(c, ev, loc, opts.Hour12) {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() {
|
||||
ui.Icon(c, icons.Must(line[0])).Size(16, 16).TextColor(k.TextMuted)
|
||||
ui.Text(c, line[1]).TextColor(k.Text).Grow(1).Shrink(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
if opts.ReadOnly {
|
||||
return
|
||||
}
|
||||
datetimeFooter(c, func() {
|
||||
if Button(c, msg(c, "event_popover.delete"), ButtonOptions{Variant: Danger}).Clicked() {
|
||||
slog.Debug("mujica: event_popover delete", "id", ev.ID)
|
||||
r.del, *open = true, false
|
||||
}
|
||||
if Button(c, msg(c, "event_popover.edit"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
slog.Debug("mujica: event_popover edit", "id", ev.ID)
|
||||
r.edit, *open = true, false
|
||||
}
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// eventPopoverLines lists icon and text pairs for the summary.
|
||||
func eventPopoverLines(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) [][2]string {
|
||||
s := ev.Start.In(loc)
|
||||
lines := [][2]string{{"clock", datetimeDayLabel(c, DateOf(s)) + " · " + eventTimeText(c, ev, loc, hour12)}}
|
||||
if ev.Location != "" {
|
||||
lines = append(lines, [2]string{"house", ev.Location})
|
||||
}
|
||||
if ev.Repeat.Unit != RepeatNone {
|
||||
lines = append(lines, [2]string{"refresh-cw", RecurrenceSummary(c, ev.Repeat)})
|
||||
}
|
||||
if len(ev.Attendees) > 0 {
|
||||
var names []string
|
||||
for _, a := range ev.Attendees {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
lines = append(lines, [2]string{"user", calendarCount(c, "event_popover.guests", len(names)) + ": " + strings.Join(names, ", ")})
|
||||
}
|
||||
if len(ev.Reminders) > 0 {
|
||||
var rs []string
|
||||
for _, d := range ev.Reminders {
|
||||
rs = append(rs, ReminderText(c, d))
|
||||
}
|
||||
lines = append(lines, [2]string{"bell", strings.Join(rs, msg(c, "recurrence_editor.list_sep"))})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func calRichEvent() CalendarEvent {
|
||||
ev := calEvent("r", 14, 19, 0, 120)
|
||||
ev.Title, ev.Location, ev.Color = "Masquerade rehearsal", "Rehearsal hall", EventInfo
|
||||
ev.Attendees = calAttendees[:2]
|
||||
ev.Reminders = []time.Duration{15 * time.Minute, time.Hour}
|
||||
ev.Repeat = Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Wednesday}}
|
||||
return ev
|
||||
}
|
||||
|
||||
type popoverProbe struct {
|
||||
open bool
|
||||
opts EventPopoverOptions
|
||||
edits, deletes int
|
||||
}
|
||||
|
||||
func (p *popoverProbe) tester() *ui.Tester {
|
||||
p.opts.Location = calTokyo
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
b := Button(c, "Details", ButtonOptions{Variant: Secondary})
|
||||
if b.Clicked() {
|
||||
p.open = true
|
||||
}
|
||||
r := EventPopover(c, b, &p.open, calRichEvent(), p.opts)
|
||||
if r.Edit() {
|
||||
p.edits++
|
||||
}
|
||||
if r.Delete() {
|
||||
p.deletes++
|
||||
}
|
||||
})
|
||||
}, 600, 500)
|
||||
}
|
||||
|
||||
func TestEventPopoverSummaryAndActions(t *testing.T) {
|
||||
p := &popoverProbe{}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Details")
|
||||
for _, s := range []string{"Masquerade rehearsal", "Wednesday, October 14, 2026 · 19:00 – 21:00", "Rehearsal hall",
|
||||
"Weekly on Wednesday", "2 guests: Sakiko Togawa, Mutsumi Wakaba", "15 minutes before, 1 hour before"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Edit")
|
||||
if p.edits != 1 || p.open {
|
||||
t.Fatal("Edit should report and close")
|
||||
}
|
||||
_ = tt.Click("Details")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
_ = tt.Click("Delete")
|
||||
if p.deletes != 1 || p.open {
|
||||
t.Fatal("Delete should report and close")
|
||||
}
|
||||
_ = tt.Click("Details")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if p.open {
|
||||
t.Fatal("Escape should close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventPopoverReadOnlyAndDisabled(t *testing.T) {
|
||||
p := &popoverProbe{opts: EventPopoverOptions{ReadOnly: true}}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Details")
|
||||
if tt.HasText("Edit") || tt.HasText("Delete") || !tt.HasText("Rehearsal hall") {
|
||||
t.Fatal("read-only popover shows actions")
|
||||
}
|
||||
p.opts.ReadOnly, p.opts.Disabled = false, true
|
||||
tt.Frame()
|
||||
if p.open || tt.HasText("Rehearsal hall") {
|
||||
t.Fatal("disabling should close the popover")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without Location")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
open := true
|
||||
EventPopover(c, ui.Box(c), &open, calRichEvent(), EventPopoverOptions{})
|
||||
}, 10, 10)
|
||||
}
|
||||
|
||||
func TestEventPopoverSnapshot(t *testing.T) {
|
||||
snapshot(t, "event_popover", 460, 400, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Children(func() {
|
||||
b := Button(c, "Details", ButtonOptions{Variant: Secondary})
|
||||
// Open once the anchor is laid out.
|
||||
frames := ui.Local(c.Root(), "frames", func() int { return 0 })
|
||||
*frames++
|
||||
if *frames == 1 {
|
||||
c.Invalidate()
|
||||
}
|
||||
open := *frames > 1
|
||||
EventPopover(c, b, &open, calRichEvent(), EventPopoverOptions{Location: calTokyo})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleCalendarMonthView() {
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
month := mujica.YearMonth{Year: 2026, Month: time.October}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc})
|
||||
if ch, ok := r.Changed(); ok {
|
||||
fmt.Println("move", ch.ID, "to", ch.Start)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCalendarWeekView() {
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
week := mujica.Date{Year: 2026, Month: time.October, Day: 14}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15})
|
||||
if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate {
|
||||
fmt.Println("new event", ch.Start, ch.End)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCalendarDayView() {
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
day := mujica.Date{Year: 2026, Month: time.October, Day: 14}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok {
|
||||
fmt.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgendaView() {
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14}
|
||||
if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok {
|
||||
fmt.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleYearView() {
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
year := 2026
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok {
|
||||
fmt.Println("go to", day)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleEventChip() {
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() {
|
||||
fmt.Println("open", ev.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleEventEditor() {
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc})
|
||||
if r.Saved() {
|
||||
fmt.Println("saved", ev.Title, ev.Start)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleEventPopover() {
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc})
|
||||
if chip.Clicked() {
|
||||
open = true
|
||||
}
|
||||
r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc})
|
||||
if r.Delete() {
|
||||
fmt.Println("delete", ev.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCurrentTimeIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Box(c).Size(200, 240).Children(func() {
|
||||
mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleRecurrenceEditor() {
|
||||
rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}})
|
||||
if r.Changed() && r.Problem() == "" {
|
||||
fmt.Println(mujica.RecurrenceSummary(c, rule))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAvailabilityPicker() {
|
||||
days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}}
|
||||
mine := [][]bool{make([]bool, 18), make([]bool, 18)}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() {
|
||||
fmt.Println("availability changed")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTimezoneSelect() {
|
||||
zone := "Asia/Shanghai"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() {
|
||||
fmt.Println("zone", zone)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleReminderPicker() {
|
||||
reminders := []time.Duration{15 * time.Minute}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() {
|
||||
fmt.Println("reminders", reminders)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAttendeeList() {
|
||||
people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok {
|
||||
people = append(people[:i], people[i+1:]...)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleWeekPicker() {
|
||||
var week mujica.Date
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() {
|
||||
fmt.Println("week of", week)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDurationPicker() {
|
||||
length := 90 * time.Minute
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour}
|
||||
if mujica.DurationPicker(c, &length, opts).Changed() {
|
||||
fmt.Println("length", length)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCronEditor() {
|
||||
expr := "0 9 * * MON-FRI"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() {
|
||||
if _, err := mujica.ParseCron(expr); err == nil {
|
||||
fmt.Println("schedule", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleRelativeTime() {
|
||||
posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCountdown() {
|
||||
deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() {
|
||||
fmt.Println("happy new year")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStopwatch() {
|
||||
var sw mujica.StopwatchState
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() {
|
||||
fmt.Println("laps", len(sw.Laps))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,46 @@
|
||||
package main
|
||||
|
||||
var calendarPages = []page{}
|
||||
const calCategory = "Calendar"
|
||||
|
||||
var calendarPages = []page{
|
||||
{ID: "421", Name: "CalendarMonthView", ZhName: "月视图", Category: calCategory, Demo: calMonthDemo, Code: calCalendarMonthViewCode,
|
||||
Keys: "Tab reaches day numbers and event chips; arrows move between them; Enter opens; Alt+Left/Right moves an event a day, Alt+Up/Down a week; Escape closes the +N list."},
|
||||
{ID: "422", Name: "CalendarWeekView", ZhName: "周视图", Category: calCategory, Demo: calWeekDemo, Code: calCalendarWeekViewCode,
|
||||
Keys: "Tab reaches a day column; Up/Down move the slot cursor, Enter creates a one-slot event; on a chip Alt+Up/Down moves by a slot, Alt+Left/Right by a day, Alt+Shift+Up/Down resizes."},
|
||||
{ID: "423", Name: "CalendarDayView", ZhName: "日视图", Category: calCategory, Demo: calDayDemo, Code: calCalendarDayViewCode,
|
||||
Keys: "As the week view: Up/Down and Enter in the column, Alt with arrows on a chip."},
|
||||
{ID: "424", Name: "AgendaView", ZhName: "日程列表", Category: calCategory, Demo: calAgendaDemo, Code: calAgendaViewCode,
|
||||
Keys: "Tab enters the list; Up/Down move between events; Enter opens."},
|
||||
{ID: "425", Name: "YearView", ZhName: "年视图", Category: calCategory, Demo: calYearDemo, Code: calYearViewCode,
|
||||
Keys: "Tab enters the days; arrows move by day and week, PageUp/PageDown by month, across years; Enter chooses."},
|
||||
{ID: "426", Name: "EventChip", ZhName: "事件块", Category: calCategory, Demo: calChipDemo, Code: calEventChipCode,
|
||||
Keys: "Tab focuses the chip; Enter or Space activates it."},
|
||||
{ID: "427", Name: "EventEditor", ZhName: "事件编辑器", Category: calCategory, Demo: calEditorDemo, Code: calEventEditorCode,
|
||||
Keys: "Tab moves through the fields; each field keeps its own keys; Save and Cancel are buttons."},
|
||||
{ID: "428", Name: "EventPopover", ZhName: "事件浮层", Category: calCategory, Demo: calPopoverDemo, Code: calEventPopoverCode,
|
||||
Keys: "Enter on the chip opens it; Tab reaches Close, Delete and Edit; Escape closes."},
|
||||
{ID: "429", Name: "CurrentTimeIndicator", ZhName: "当前时间线", Category: calCategory, Demo: calNowDemo, Code: calCurrentTimeIndicatorCode,
|
||||
Keys: "Not focusable; screen readers read the time from its label."},
|
||||
{ID: "430", Name: "RecurrenceEditor", ZhName: "重复规则编辑器", Category: calCategory, Demo: calRecurrenceDemo, Code: calRecurrenceEditorCode,
|
||||
Keys: "Tab moves between the repeat select, interval, weekdays and end choices; each control keeps its own keys."},
|
||||
{ID: "431", Name: "AvailabilityPicker", ZhName: "空闲时段选择", Category: calCategory, Demo: calAvailabilityDemo, Code: calAvailabilityPickerCode,
|
||||
Keys: "Tab focuses the grid; arrows move the cursor; Space or Enter toggles the slot."},
|
||||
{ID: "432", Name: "TimezoneSelect", ZhName: "时区选择", Category: calCategory, Demo: calTimezoneDemo, Code: calTimezoneSelectCode,
|
||||
Keys: "Enter, Space or Down opens; type to search; Up/Down move; Enter chooses; Escape closes."},
|
||||
{ID: "433", Name: "ReminderPicker", ZhName: "提醒选择", Category: calCategory, Demo: calReminderDemo, Code: calReminderPickerCode,
|
||||
Keys: "Tab enters the list; Up/Down move; Space toggles."},
|
||||
{ID: "434", Name: "AttendeeList", ZhName: "参与者列表", Category: calCategory, Demo: calAttendeeDemo, Code: calAttendeeListCode,
|
||||
Keys: "Tab reaches each remove button; Enter removes."},
|
||||
{ID: "435", Name: "WeekPicker", ZhName: "周选择器", Category: calCategory, Demo: calWeekPickerDemo, Code: calWeekPickerCode,
|
||||
Keys: "Enter, Space or Down opens; arrows move; Enter picks the week; Escape closes."},
|
||||
{ID: "436", Name: "DurationPicker", ZhName: "时长选择器", Category: calCategory, Demo: calDurationDemo, Code: calDurationPickerCode,
|
||||
Keys: "Tab moves between hours, minutes and seconds; Up/Down step; type digits and Enter."},
|
||||
{ID: "437", Name: "CronEditor", ZhName: "Cron 编辑器", Category: calCategory, Demo: calCronDemo, Code: calCronEditorCode,
|
||||
Keys: "Type in the expression field; errors and the next runs update as you type."},
|
||||
{ID: "438", Name: "RelativeTime", ZhName: "相对时间", Category: calCategory, Demo: calRelativeDemo, Code: calRelativeTimeCode,
|
||||
Keys: "Not focusable; the exact time is in its label and tooltip."},
|
||||
{ID: "439", Name: "Countdown", ZhName: "倒计时", Category: calCategory, Demo: calCountdownDemo, Code: calCountdownCode,
|
||||
Keys: "Not focusable; reaching zero is announced."},
|
||||
{ID: "440", Name: "Stopwatch", ZhName: "秒表", Category: calCategory, Demo: calStopwatchDemo, Code: calStopwatchCode,
|
||||
Keys: "Tab reaches Start/Pause, Lap and Reset; Enter or Space presses."},
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package main
|
||||
|
||||
// Code snippets mirror example_calendar_test.go.
|
||||
const (
|
||||
calCalendarMonthViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
month := mujica.YearMonth{Year: 2026, Month: time.October}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc})
|
||||
if ch, ok := r.Changed(); ok {
|
||||
fmt.Println("move", ch.ID, "to", ch.Start)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calCalendarWeekViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
week := mujica.Date{Year: 2026, Month: time.October, Day: 14}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15})
|
||||
if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate {
|
||||
fmt.Println("new event", ch.Start, ch.End)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calCalendarDayViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
day := mujica.Date{Year: 2026, Month: time.October, Day: 14}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok {
|
||||
fmt.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calAgendaViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14}
|
||||
if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok {
|
||||
fmt.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calYearViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
events := exampleEvents(loc)
|
||||
year := 2026
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok {
|
||||
fmt.Println("go to", day)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calEventChipCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() {
|
||||
fmt.Println("open", ev.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calEventEditorCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc})
|
||||
if r.Saved() {
|
||||
fmt.Println("saved", ev.Title, ev.Start)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calEventPopoverCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent {
|
||||
start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc)
|
||||
return []mujica.CalendarEvent{
|
||||
{ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"},
|
||||
{ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo},
|
||||
}
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
ev := exampleEvents(loc)[0]
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc})
|
||||
if chip.Clicked() {
|
||||
open = true
|
||||
}
|
||||
r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc})
|
||||
if r.Delete() {
|
||||
fmt.Println("delete", ev.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calCurrentTimeIndicatorCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Box(c).Size(200, 240).Children(func() {
|
||||
mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calRecurrenceEditorCode = `rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}})
|
||||
if r.Changed() && r.Problem() == "" {
|
||||
fmt.Println(mujica.RecurrenceSummary(c, rule))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calAvailabilityPickerCode = `days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}}
|
||||
mine := [][]bool{make([]bool, 18), make([]bool, 18)}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() {
|
||||
fmt.Println("availability changed")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calTimezoneSelectCode = `zone := "Asia/Shanghai"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() {
|
||||
fmt.Println("zone", zone)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calReminderPickerCode = `reminders := []time.Duration{15 * time.Minute}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() {
|
||||
fmt.Println("reminders", reminders)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calAttendeeListCode = `people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok {
|
||||
people = append(people[:i], people[i+1:]...)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calWeekPickerCode = `var week mujica.Date
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() {
|
||||
fmt.Println("week of", week)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calDurationPickerCode = `length := 90 * time.Minute
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour}
|
||||
if mujica.DurationPicker(c, &length, opts).Changed() {
|
||||
fmt.Println("length", length)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calCronEditorCode = `expr := "0 9 * * MON-FRI"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() {
|
||||
if _, err := mujica.ParseCron(expr); err == nil {
|
||||
fmt.Println("schedule", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calRelativeTimeCode = `posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calCountdownCode = `deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() {
|
||||
fmt.Println("happy new year")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
calStopwatchCode = `var sw mujica.StopwatchState
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() {
|
||||
fmt.Println("laps", len(sw.Laps))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
var calColors = []string{"Accent", "Info", "Success", "Warning", "Danger", "Neutral"}
|
||||
|
||||
func calChipDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
color string
|
||||
inline, selected, disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "426", func() state { return state{color: "Accent"} })
|
||||
dtSection(c, "EventChip", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Select(c, &s.color, calOptions(calColors), mujica.SelectOptions{Label: "Color"})
|
||||
mujica.Checkbox(c, &s.inline, "Inline", mujica.CheckboxOptions{})
|
||||
mujica.Checkbox(c, &s.selected, "Selected", mujica.CheckboxOptions{})
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
ev := calSample()[0]
|
||||
for i, n := range calColors {
|
||||
if n == s.color {
|
||||
ev.Color = mujica.EventColor(i)
|
||||
}
|
||||
}
|
||||
ui.Box(c).Width(240).Children(func() {
|
||||
if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: time.Local, Inline: s.inline, Selected: s.selected, Disabled: s.disabled}).Clicked() {
|
||||
log("clicked " + ev.Title)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func calEditorDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
ev mujica.CalendarEvent
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "427", func() state { return state{ev: calSample()[0]} })
|
||||
dtSection(c, "EventEditor", func() {
|
||||
dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) })
|
||||
r := mujica.EventEditor(c, &s.ev, mujica.EventEditorOptions{Location: time.Local, Disabled: s.disabled})
|
||||
if r.Saved() {
|
||||
log(fmt.Sprintf("saved %q %s, %d guests", s.ev.Title, s.ev.Start.Format("Jan 2 15:04"), len(s.ev.Attendees)))
|
||||
}
|
||||
if r.Canceled() {
|
||||
log("canceled")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calPopoverDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
open, readOnly, disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "428", func() state { return state{} })
|
||||
dtSection(c, "EventPopover", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Checkbox(c, &s.readOnly, "Read-only", mujica.CheckboxOptions{})
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
ev := calSample()[0]
|
||||
ev.Attendees = []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}}
|
||||
ev.Reminders = []time.Duration{15 * time.Minute}
|
||||
ui.Box(c).Width(240).Children(func() {
|
||||
chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: time.Local, Disabled: s.disabled})
|
||||
if chip.Clicked() {
|
||||
s.open = true
|
||||
log("opened popover")
|
||||
}
|
||||
r := mujica.EventPopover(c, chip, &s.open, ev, mujica.EventPopoverOptions{Location: time.Local, ReadOnly: s.readOnly, Disabled: s.disabled})
|
||||
if r.Edit() {
|
||||
log("edit " + ev.ID)
|
||||
}
|
||||
if r.Delete() {
|
||||
log("delete " + ev.ID)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func calNowDemo(c *ui.Context, log func(string)) {
|
||||
dtSection(c, "CurrentTimeIndicator", func() {
|
||||
k := mujica.Tokens(c)
|
||||
ui.Box(c).Size(320, 240).Border(1, k.Border).Children(func() {
|
||||
mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func calRecurrenceDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
rule mujica.Recurrence
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "430", func() state { return state{} })
|
||||
dtSection(c, "RecurrenceEditor", func() {
|
||||
dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) })
|
||||
r := mujica.RecurrenceEditor(c, &s.rule, mujica.RecurrenceEditorOptions{Start: mujica.DateOf(time.Now()), Disabled: s.disabled})
|
||||
if r.Changed() {
|
||||
log(mujica.RecurrenceSummary(c, s.rule) + strings.TrimSpace(" "+r.Problem()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calAvailabilityDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
mine, theirs [][]bool
|
||||
days []mujica.Date
|
||||
others, frozen bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "431", func() state {
|
||||
today := mujica.DateOf(time.Now())
|
||||
grid := func(seed int) [][]bool {
|
||||
g := make([][]bool, 5)
|
||||
for i := range g {
|
||||
g[i] = make([]bool, 18)
|
||||
for j := range g[i] {
|
||||
g[i][j] = (i*7+j*3+seed)%4 == 0
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
days := []mujica.Date{today, today.AddDays(1), today.AddDays(2), today.AddDays(3), today.AddDays(4)}
|
||||
return state{mine: grid(2), theirs: grid(1), days: days, others: true}
|
||||
})
|
||||
dtSection(c, "AvailabilityPicker", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Checkbox(c, &s.others, "Show Mutsumi", mujica.CheckboxOptions{})
|
||||
mujica.Checkbox(c, &s.frozen, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
var others []mujica.AvailabilityPerson
|
||||
if s.others {
|
||||
others = []mujica.AvailabilityPerson{{Name: "Mutsumi", Free: s.theirs}}
|
||||
}
|
||||
if mujica.AvailabilityPicker(c, &s.mine, mujica.AvailabilityPickerOptions{Days: s.days, Others: others, Disabled: s.frozen}).Changed() {
|
||||
n := 0
|
||||
for _, d := range s.mine {
|
||||
for _, v := range d {
|
||||
if v {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
log(fmt.Sprintf("%d free slots", n))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calTimezoneDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
zone string
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "432", func() state { return state{zone: "Asia/Shanghai"} })
|
||||
dtSection(c, "TimezoneSelect", func() {
|
||||
dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) })
|
||||
if mujica.TimezoneSelect(c, &s.zone, mujica.TimezoneSelectOptions{Disabled: s.disabled}).Changed() {
|
||||
log("zone " + s.zone)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calReminderDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
v []time.Duration
|
||||
max string
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "433", func() state { return state{v: []time.Duration{15 * time.Minute}, max: "0"} })
|
||||
dtSection(c, "ReminderPicker", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Select(c, &s.max, calOptions([]string{"0", "2", "3"}), mujica.SelectOptions{Label: "Max (0 = any)"})
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
if limit := calAtoi(s.max); limit > 0 && len(s.v) > limit {
|
||||
s.v = s.v[:limit]
|
||||
}
|
||||
if mujica.ReminderPicker(c, &s.v, mujica.ReminderPickerOptions{Max: calAtoi(s.max), Disabled: s.disabled}).Changed() {
|
||||
log(fmt.Sprint("reminders ", s.v))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calAttendeeDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
list []mujica.Attendee
|
||||
removable bool
|
||||
}
|
||||
people := func() []mujica.Attendee {
|
||||
return []mujica.Attendee{{Name: "Sakiko Togawa", Status: mujica.AttendeeAccepted, Organizer: true},
|
||||
{Name: "Mutsumi Wakaba", Status: mujica.AttendeeTentative}, {Name: "Uika Misumi", Status: mujica.AttendeeDeclined},
|
||||
{Name: "Nyamu Yuutenji", Email: "nyamu@example.com"}}
|
||||
}
|
||||
s := ui.Local(c.Root(), "434", func() state { return state{list: people(), removable: true} })
|
||||
dtSection(c, "AttendeeList", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Checkbox(c, &s.removable, "Removable", mujica.CheckboxOptions{})
|
||||
if mujica.Button(c, "Reset", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.list = people()
|
||||
log("reset")
|
||||
}
|
||||
})
|
||||
if i, ok := mujica.AttendeeList(c, s.list, mujica.AttendeeListOptions{Removable: s.removable}).Removed(); ok {
|
||||
log("removed " + s.list[i].Name)
|
||||
s.list = append(s.list[:i:i], s.list[i+1:]...)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func calWeekPickerDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
week mujica.Date
|
||||
weekStart string
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "435", func() state { return state{weekStart: "Locale"} })
|
||||
dtSection(c, "WeekPicker", func() {
|
||||
dtControls(c, func() {
|
||||
if mujica.Select(c, &s.weekStart, calOptions(calWeekStarts), mujica.SelectOptions{Label: "Week start"}).Changed() {
|
||||
s.week = mujica.Date{}
|
||||
log("week start " + s.weekStart + ", value cleared")
|
||||
}
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
opts := mujica.WeekPickerOptions{WeekStart: calWeekStart(s.weekStart), Clearable: true, Disabled: s.disabled}
|
||||
if mujica.WeekPicker(c, &s.week, opts).Changed() {
|
||||
log("week of " + s.week.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calDurationDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
v time.Duration
|
||||
seconds, disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "436", func() state { return state{v: 90 * time.Minute} })
|
||||
dtSection(c, "DurationPicker", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Checkbox(c, &s.seconds, "Seconds", mujica.CheckboxOptions{})
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
opts := mujica.DurationPickerOptions{Seconds: s.seconds, Min: 15 * time.Minute, Max: 8 * time.Hour, Disabled: s.disabled}
|
||||
if mujica.DurationPicker(c, &s.v, opts).Changed() {
|
||||
log("duration " + s.v.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calCronDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
expr string
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "437", func() state { return state{expr: "*/15 9-17 * * MON-FRI"} })
|
||||
dtSection(c, "CronEditor", func() {
|
||||
dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) })
|
||||
if mujica.CronEditor(c, &s.expr, mujica.CronEditorOptions{Location: time.Local, Disabled: s.disabled}).Changed() {
|
||||
if _, err := mujica.ParseCron(s.expr); err != nil {
|
||||
log("invalid: " + s.expr)
|
||||
} else {
|
||||
log("schedule " + s.expr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calRelativeDemo(c *ui.Context, log func(string)) {
|
||||
type state struct{ at time.Time }
|
||||
s := ui.Local(c.Root(), "438", func() state { return state{at: time.Now()} })
|
||||
dtSection(c, "RelativeTime", func() {
|
||||
dtControls(c, func() {
|
||||
for _, b := range []struct {
|
||||
label string
|
||||
d time.Duration
|
||||
}{{"Now", 0}, {"5 minutes ago", -5 * time.Minute}, {"In 3 hours", 3 * time.Hour}, {"40 days ago", -40 * 24 * time.Hour}} {
|
||||
if mujica.Button(c, b.label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.at = time.Now().Add(b.d)
|
||||
log("time set: " + b.label)
|
||||
}
|
||||
}
|
||||
})
|
||||
mujica.RelativeTime(c, s.at, mujica.RelativeTimeOptions{Location: time.Local})
|
||||
})
|
||||
}
|
||||
|
||||
func calCountdownDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
deadline time.Time
|
||||
words bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "439", func() state { return state{deadline: time.Now().Add(10 * time.Second)} })
|
||||
dtSection(c, "Countdown", func() {
|
||||
dtControls(c, func() {
|
||||
if mujica.Button(c, "Restart 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.deadline = time.Now().Add(10 * time.Second)
|
||||
log("restarted")
|
||||
}
|
||||
mujica.Checkbox(c, &s.words, "Words", mujica.CheckboxOptions{})
|
||||
})
|
||||
format := mujica.CountdownClock
|
||||
if s.words {
|
||||
format = mujica.CountdownWords
|
||||
}
|
||||
if mujica.Countdown(c, s.deadline, mujica.CountdownOptions{Format: format}).Finished() {
|
||||
log("finished")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calStopwatchDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
sw mujica.StopwatchState
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "440", func() state { return state{} })
|
||||
dtSection(c, "Stopwatch", func() {
|
||||
dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) })
|
||||
if mujica.Stopwatch(c, &s.sw, mujica.StopwatchOptions{Disabled: s.disabled}).Changed() {
|
||||
state := "paused"
|
||||
if s.sw.Running() {
|
||||
state = "running"
|
||||
}
|
||||
log(state + ", " + mujica.StopwatchText(s.sw.Elapsed(time.Now())))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
// calSample is demo events around today in the local zone.
|
||||
func calSample() []mujica.CalendarEvent {
|
||||
now := time.Now()
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
at := func(d, h, m, mins int) (time.Time, time.Time) {
|
||||
s := day.AddDate(0, 0, d).Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)
|
||||
return s, s.Add(time.Duration(mins) * time.Minute)
|
||||
}
|
||||
mk := func(id, title string, color mujica.EventColor, d, h, m, mins int) mujica.CalendarEvent {
|
||||
s, e := at(d, h, m, mins)
|
||||
return mujica.CalendarEvent{ID: id, Title: title, Color: color, Start: s, End: e, Location: "Rehearsal hall"}
|
||||
}
|
||||
evs := []mujica.CalendarEvent{
|
||||
mk("1", "Rehearsal", mujica.EventAccent, 0, 9, 0, 120), mk("2", "Costume fitting", mujica.EventInfo, 0, 10, 0, 60),
|
||||
mk("3", "Lunch", mujica.EventSuccess, 0, 12, 30, 60), mk("4", "Lyrics review", mujica.EventWarning, 1, 14, 0, 90),
|
||||
mk("5", "Press", mujica.EventDanger, 2, 16, 0, 45), mk("6", "Sound check", mujica.EventNeutral, 3, 18, 0, 60),
|
||||
mk("7", "Encore notes", mujica.EventInfo, 0, 15, 0, 30), mk("8", "Stage walk", mujica.EventAccent, 0, 17, 0, 30),
|
||||
}
|
||||
holiday := mk("9", "Masquerade", mujica.EventAccent, 4, 0, 0, 24*60)
|
||||
holiday.AllDay, holiday.Start = true, day.AddDate(0, 0, 4)
|
||||
holiday.End = holiday.Start.AddDate(0, 0, 1)
|
||||
return append(evs, holiday)
|
||||
}
|
||||
|
||||
// calApply applies and logs a view change.
|
||||
func calApply(evs *[]mujica.CalendarEvent, ch mujica.CalendarChange, log func(string)) {
|
||||
switch ch.Kind {
|
||||
case mujica.CalendarCreate:
|
||||
id := fmt.Sprint(len(*evs) + 100)
|
||||
*evs = append(*evs, mujica.CalendarEvent{ID: id, Title: "New event", Start: ch.Start, End: ch.End})
|
||||
log("created " + ch.Start.Format("Jan 2 15:04") + " – " + ch.End.Format("15:04"))
|
||||
return
|
||||
}
|
||||
for i := range *evs {
|
||||
if (*evs)[i].ID == ch.ID {
|
||||
(*evs)[i].Start, (*evs)[i].End = ch.Start, ch.End
|
||||
}
|
||||
}
|
||||
verb := "moved"
|
||||
if ch.Kind == mujica.CalendarResize {
|
||||
verb = "resized"
|
||||
}
|
||||
log(fmt.Sprintf("%s %s to %s – %s", verb, ch.ID, ch.Start.Format("Jan 2 15:04"), ch.End.Format("15:04")))
|
||||
}
|
||||
|
||||
// calViewResult logs what a view reported and applies its change.
|
||||
func calViewResult(r mujica.CalendarViewResult, evs *[]mujica.CalendarEvent, version *int, log func(string)) {
|
||||
if id, ok := r.Opened(); ok {
|
||||
log("opened " + id)
|
||||
}
|
||||
if d, ok := r.DayChosen(); ok {
|
||||
log("day " + d.String())
|
||||
}
|
||||
if ch, ok := r.Changed(); ok {
|
||||
calApply(evs, ch, log)
|
||||
*version++
|
||||
}
|
||||
}
|
||||
|
||||
var calWeekStarts = []string{"Locale", "Sunday", "Monday", "Saturday"}
|
||||
|
||||
func calWeekStart(s string) mujica.WeekStart {
|
||||
for i, n := range calWeekStarts {
|
||||
if n == s {
|
||||
return mujica.WeekStart(i)
|
||||
}
|
||||
}
|
||||
panic("gallery: unknown week start " + s)
|
||||
}
|
||||
|
||||
func calMonthDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
month mujica.YearMonth
|
||||
events []mujica.CalendarEvent
|
||||
version, maxRows int
|
||||
weekStart, maxStr string
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "421", func() state {
|
||||
now := time.Now()
|
||||
return state{month: mujica.YearMonth{Year: now.Year(), Month: now.Month()}, events: calSample(), weekStart: "Locale", maxStr: "3"}
|
||||
})
|
||||
dtSection(c, "CalendarMonthView", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Select(c, &s.weekStart, calOptions(calWeekStarts), mujica.SelectOptions{Label: "Week start"})
|
||||
mujica.Select(c, &s.maxStr, calOptions([]string{"1", "2", "3", "4"}), mujica.SelectOptions{Label: "Rows per day"})
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
s.maxRows = calAtoi(s.maxStr)
|
||||
r := mujica.CalendarMonthView(c, &s.month, s.events, mujica.CalendarMonthViewOptions{Location: time.Local, Version: s.version,
|
||||
WeekStart: calWeekStart(s.weekStart), MaxPerDay: s.maxRows, Disabled: s.disabled})
|
||||
calViewResult(r, &s.events, &s.version, log)
|
||||
})
|
||||
}
|
||||
|
||||
func calWeekDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
week mujica.Date
|
||||
events []mujica.CalendarEvent
|
||||
version int
|
||||
slot string
|
||||
hour12 bool
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "422", func() state {
|
||||
return state{week: mujica.DateOf(time.Now()), events: calSample(), slot: "30"}
|
||||
})
|
||||
dtSection(c, "CalendarWeekView", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Select(c, &s.slot, calOptions([]string{"15", "30", "60"}), mujica.SelectOptions{Label: "Slot minutes"})
|
||||
mujica.Checkbox(c, &s.hour12, "12-hour", mujica.CheckboxOptions{})
|
||||
mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{})
|
||||
})
|
||||
slot := calAtoi(s.slot)
|
||||
r := mujica.CalendarWeekView(c, &s.week, s.events, mujica.CalendarWeekViewOptions{Location: time.Local, Version: s.version,
|
||||
SlotMinutes: slot, Hour12: s.hour12, Disabled: s.disabled})
|
||||
calViewResult(r, &s.events, &s.version, log)
|
||||
})
|
||||
}
|
||||
|
||||
func calDayDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
day mujica.Date
|
||||
events []mujica.CalendarEvent
|
||||
version int
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "423", func() state { return state{day: mujica.DateOf(time.Now()), events: calSample()} })
|
||||
dtSection(c, "CalendarDayView", func() {
|
||||
dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) })
|
||||
r := mujica.CalendarDayView(c, &s.day, s.events, mujica.CalendarDayViewOptions{Location: time.Local, Version: s.version, Disabled: s.disabled})
|
||||
calViewResult(r, &s.events, &s.version, log)
|
||||
})
|
||||
}
|
||||
|
||||
func calAgendaDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
events []mujica.CalendarEvent
|
||||
days string
|
||||
}
|
||||
s := ui.Local(c.Root(), "424", func() state { return state{events: calSample(), days: "7"} })
|
||||
dtSection(c, "AgendaView", func() {
|
||||
dtControls(c, func() {
|
||||
mujica.Select(c, &s.days, calOptions([]string{"1", "7", "30"}), mujica.SelectOptions{Label: "Days"})
|
||||
})
|
||||
days := calAtoi(s.days)
|
||||
ui.Column(c).Height(420).Children(func() {
|
||||
if id, ok := mujica.AgendaView(c, s.events, mujica.AgendaViewOptions{Location: time.Local, Days: days}).Opened(); ok {
|
||||
log("opened " + id)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func calYearDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
year int
|
||||
events []mujica.CalendarEvent
|
||||
}
|
||||
s := ui.Local(c.Root(), "425", func() state { return state{year: time.Now().Year(), events: calSample()} })
|
||||
dtSection(c, "YearView", func() {
|
||||
if d, ok := mujica.YearView(c, &s.year, s.events, mujica.YearViewOptions{Location: time.Local}).DayChosen(); ok {
|
||||
log("day " + d.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func calAtoi(s string) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func calOptions(values []string) []mujica.SelectOption[string] {
|
||||
out := make([]mujica.SelectOption[string], len(values))
|
||||
for i, v := range values {
|
||||
out[i] = mujica.SelectOption[string]{Value: v, Label: v}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package internal
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Rule draws a pixel-aligned 1 DIP horizontal line at y.
|
||||
func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
|
||||
yy := ruleY(p, y)
|
||||
p.Line(x0, yy, x1, yy, 1, c)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// calendarCount formats a count, using "_one" for one.
|
||||
func calendarCount(c *ui.Context, key string, n int) string {
|
||||
if n == 1 {
|
||||
key += "_one"
|
||||
}
|
||||
return fmt.Sprintf(msg(c, key), n)
|
||||
}
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"event_chip.all_day": {"All day", "全天"},
|
||||
"event_chip.untitled": {"(No title)", "(无标题)"},
|
||||
|
||||
"attendee_list.label": {"Attendees", "参与者"},
|
||||
"attendee_list.summary": {"%d accepted · %d maybe · %d declined · %d awaiting", "%d 人接受 · %d 人待定 · %d 人拒绝 · %d 人未回复"},
|
||||
"attendee_list.empty": {"No attendees yet.", "暂无参与者。"},
|
||||
"attendee_list.organizer": {"Organizer", "组织者"},
|
||||
"attendee_list.optional": {"Optional", "可选"},
|
||||
"attendee_list.remove": {"Remove %s", "移除 %s"},
|
||||
"attendee_list.pending": {"Awaiting reply", "未回复"},
|
||||
"attendee_list.accepted": {"Accepted", "已接受"},
|
||||
"attendee_list.tentative": {"Maybe", "待定"},
|
||||
"attendee_list.declined": {"Declined", "已拒绝"},
|
||||
|
||||
"recurrence_editor.label": {"Recurrence", "重复规则"},
|
||||
"recurrence_editor.repeat": {"Repeat", "重复"},
|
||||
"recurrence_editor.none": {"Does not repeat", "不重复"},
|
||||
"recurrence_editor.daily": {"Daily", "每天"},
|
||||
"recurrence_editor.weekly": {"Weekly", "每周"},
|
||||
"recurrence_editor.monthly": {"Monthly", "每月"},
|
||||
"recurrence_editor.yearly": {"Yearly", "每年"},
|
||||
"recurrence_editor.custom": {"Custom", "自定义"},
|
||||
"recurrence_editor.every": {"Every", "每"},
|
||||
"recurrence_editor.interval": {"Interval", "间隔"},
|
||||
"recurrence_editor.unit": {"Unit", "单位"},
|
||||
"recurrence_editor.days": {"days", "天"},
|
||||
"recurrence_editor.weeks": {"weeks", "周"},
|
||||
"recurrence_editor.months": {"months", "个月"},
|
||||
"recurrence_editor.years": {"years", "年"},
|
||||
"recurrence_editor.on_days": {"On days", "重复于"},
|
||||
"recurrence_editor.ends": {"Ends", "结束"},
|
||||
"recurrence_editor.forever": {"Never", "永不"},
|
||||
"recurrence_editor.after": {"After", "次数"},
|
||||
"recurrence_editor.until": {"On date", "截止日期"},
|
||||
"recurrence_editor.count": {"Occurrences", "重复次数"},
|
||||
"recurrence_editor.until_date": {"End date", "结束日期"},
|
||||
"recurrence_editor.no_days": {"Choose at least one day.", "请至少选择一天。"},
|
||||
"recurrence_editor.bad_count": {"Repeat at least once.", "重复次数至少为 1。"},
|
||||
"recurrence_editor.bad_until": {"The end date must not precede the start.", "结束日期不能早于开始日期。"},
|
||||
"recurrence_editor.never": {"Does not repeat", "不重复"},
|
||||
"recurrence_editor.every_n": {"Every %d %s", "每 %d %s"},
|
||||
"recurrence_editor.on": {" on %s", ",于%s"},
|
||||
"recurrence_editor.list_sep": {", ", "、"},
|
||||
"recurrence_editor.times": {", %d times", ",共 %d 次"},
|
||||
"recurrence_editor.times_one": {", %d time", ",共 %d 次"},
|
||||
"recurrence_editor.until_text": {", until %v", ",至 %v"},
|
||||
|
||||
"reminder_picker.label": {"Reminders", "提醒"},
|
||||
"reminder_picker.at_start": {"At start", "开始时"},
|
||||
"reminder_picker.minutes": {"%d minutes before", "提前 %d 分钟"},
|
||||
"reminder_picker.minutes_one": {"%d minute before", "提前 %d 分钟"},
|
||||
"reminder_picker.hours": {"%d hours before", "提前 %d 小时"},
|
||||
"reminder_picker.hours_one": {"%d hour before", "提前 %d 小时"},
|
||||
"reminder_picker.days": {"%d days before", "提前 %d 天"},
|
||||
"reminder_picker.days_one": {"%d day before", "提前 %d 天"},
|
||||
"reminder_picker.weeks": {"%d weeks before", "提前 %d 周"},
|
||||
"reminder_picker.weeks_one": {"%d week before", "提前 %d 周"},
|
||||
|
||||
"calendar_month_view.more": {"+%d", "+%d"},
|
||||
"calendar_month_view.more_label": {"%d more events", "另有 %d 项"},
|
||||
"calendar_month_view.more_label_one": {"%d more event", "另有 %d 项"},
|
||||
|
||||
"current_time_indicator.label": {"Now, %s", "当前时间 %s"},
|
||||
|
||||
"calendar_week_view.all_day": {"All day", "全天"},
|
||||
"calendar_week_view.prev": {"Previous week", "上一周"},
|
||||
"calendar_week_view.next": {"Next week", "下一周"},
|
||||
"calendar_week_view.title": {"%[1]s %[2]d – %[3]s %[4]d, %[5]d", "%[5]d年%[1]s%[2]d日 – %[3]s%[4]d日"},
|
||||
"calendar_week_view.resize": {"Resize %s", "调整 %s 的时长"},
|
||||
"calendar_week_view.too_short": {"An event must end after it starts.", "结束时间须晚于开始时间。"},
|
||||
"calendar_week_view.column": {"Schedule, %s", "%s 日程"},
|
||||
"calendar_day_view.prev": {"Previous day", "前一天"},
|
||||
"calendar_day_view.next": {"Next day", "后一天"},
|
||||
|
||||
"agenda_view.label": {"Agenda", "日程"},
|
||||
"agenda_view.today": {"Today", "今天"},
|
||||
"agenda_view.empty": {"No events in the next %d days.", "未来 %d 天没有日程。"},
|
||||
|
||||
"year_view.events": {"%d events", "%d 项日程"},
|
||||
"year_view.events_one": {"%d event", "%d 项日程"},
|
||||
|
||||
"event_popover.close": {"Close", "关闭"},
|
||||
"event_popover.edit": {"Edit", "编辑"},
|
||||
"event_popover.delete": {"Delete", "删除"},
|
||||
"event_popover.guests": {"%d guests", "%d 位参与者"},
|
||||
"event_popover.guests_one": {"%d guest", "%d 位参与者"},
|
||||
|
||||
"week_picker.label": {"Week", "周"},
|
||||
"week_picker.placeholder": {"Select week", "选择周"},
|
||||
|
||||
"duration_picker.label": {"Duration", "时长"},
|
||||
"duration_picker.hours": {"Hours", "小时数"},
|
||||
"duration_picker.minutes": {"Minutes", "分钟数"},
|
||||
"duration_picker.seconds": {"Seconds", "秒数"},
|
||||
"duration_picker.hours_unit": {"h", "小时"},
|
||||
"duration_picker.minutes_unit": {"min", "分"},
|
||||
"duration_picker.seconds_unit": {"s", "秒"},
|
||||
"duration_picker.range": {"Choose a duration from %s to %s.", "请选择 %s 至 %s 之间的时长。"},
|
||||
|
||||
"timezone_select.label": {"Time zone", "时区"},
|
||||
"timezone_select.placeholder": {"Select time zone", "选择时区"},
|
||||
"timezone_select.search": {"Search city or offset", "搜索城市或偏移"},
|
||||
"timezone_select.empty": {"No matching time zone.", "没有匹配的时区。"},
|
||||
"timezone_select.common": {"Common", "常用"},
|
||||
"timezone_select.all": {"All time zones", "全部时区"},
|
||||
|
||||
"availability_picker.label": {"Availability", "空闲时段"},
|
||||
"availability_picker.mine": {"you are free", "你有空"},
|
||||
"availability_picker.others": {"%d of %d others free", "其他 %[2]d 人中 %[1]d 人有空"},
|
||||
|
||||
"event_editor.label": {"Event", "日程"},
|
||||
"event_editor.title": {"Title", "标题"},
|
||||
"event_editor.all_day": {"All day", "全天"},
|
||||
"event_editor.starts": {"Starts", "开始"},
|
||||
"event_editor.ends": {"Ends", "结束"},
|
||||
"event_editor.where": {"Location", "地点"},
|
||||
"event_editor.guests": {"Guests", "参与者"},
|
||||
"event_editor.guests_help": {"Type a name and press Enter.", "输入姓名后按回车。"},
|
||||
"event_editor.reminders": {"Reminders", "提醒"},
|
||||
"event_editor.save": {"Save", "保存"},
|
||||
"event_editor.need_title": {"Enter a title.", "请输入标题。"},
|
||||
"event_editor.need_start": {"Choose a start.", "请选择开始时间。"},
|
||||
"event_editor.end_order": {"The end must be after the start.", "结束须晚于开始。"},
|
||||
|
||||
"cron_editor.expression": {"Expression", "表达式"},
|
||||
"cron_editor.label": {"Cron schedule", "Cron 表达式"},
|
||||
"cron_editor.next": {"Next runs", "下次运行"},
|
||||
"cron_editor.never": {"No run in the next five years.", "未来五年内不会运行。"},
|
||||
"cron_editor.repeated": {"repeated hour, first run", "重复时段,取首次"},
|
||||
"cron_editor.minute": {"Minute", "分钟"},
|
||||
"cron_editor.hour": {"Hour", "小时"},
|
||||
"cron_editor.dom": {"Day of month", "日"},
|
||||
"cron_editor.month": {"Month", "月"},
|
||||
"cron_editor.dow": {"Day of week", "星期"},
|
||||
"cron_editor.err_count": {"Use five fields, not %s.", "需要 5 个字段,而不是 %s 个。"},
|
||||
"cron_editor.err_empty": {"an empty list item", "列表项为空"},
|
||||
"cron_editor.err_step": {"step %q must be a positive number", "步长 %q 须为正整数"},
|
||||
"cron_editor.err_number": {"%q is not a number", "%q 不是数字"},
|
||||
"cron_editor.err_range": {"%s is outside %d–%d", "%s 超出 %d–%d"},
|
||||
"cron_editor.err_backwards": {"range %s runs backwards", "范围 %s 起止颠倒"},
|
||||
"cron_editor.field_error": {"%s: %s.", "%s:%s。"},
|
||||
"cron_editor.invalid": {"Invalid", "无效"},
|
||||
"cron_editor.any": {"Every value", "任意"},
|
||||
"cron_editor.every": {"Every %s", "每 %s"},
|
||||
"cron_editor.every_in": {"Every %s in %s", "%[2]s 内每 %[1]s"},
|
||||
|
||||
"relative_time.now": {"just now", "刚刚"},
|
||||
"relative_time.ago": {"%s ago", "%s前"},
|
||||
"relative_time.in": {"in %s", "%s后"},
|
||||
"relative_time.minutes": {"%d minutes", "%d 分钟"},
|
||||
"relative_time.minutes_one": {"%d minute", "%d 分钟"},
|
||||
"relative_time.hours": {"%d hours", "%d 小时"},
|
||||
"relative_time.hours_one": {"%d hour", "%d 小时"},
|
||||
"relative_time.days": {"%d days", "%d 天"},
|
||||
"relative_time.days_one": {"%d day", "%d 天"},
|
||||
"relative_time.months": {"%d months", "%d 个月"},
|
||||
"relative_time.months_one": {"%d month", "%d 个月"},
|
||||
"relative_time.years": {"%d years", "%d 年"},
|
||||
"relative_time.years_one": {"%d year", "%d 年"},
|
||||
|
||||
"countdown.label": {"Time left", "剩余时间"},
|
||||
"countdown.done": {"%s: time is up.", "%s:时间到。"},
|
||||
"countdown.days_clock": {"%dd", "%d天"},
|
||||
"countdown.d": {"%d d", "%d 天"},
|
||||
"countdown.h": {"%d h", "%d 小时"},
|
||||
"countdown.m": {"%d min", "%d 分"},
|
||||
"countdown.s": {"%d s", "%d 秒"},
|
||||
|
||||
"stopwatch.label": {"Stopwatch", "秒表"},
|
||||
"stopwatch.start": {"Start", "开始"},
|
||||
"stopwatch.pause": {"Pause", "暂停"},
|
||||
"stopwatch.resume": {"Resume", "继续"},
|
||||
"stopwatch.lap": {"Lap", "计次"},
|
||||
"stopwatch.reset": {"Reset", "复位"},
|
||||
"stopwatch.lap_n": {"Lap %d", "第 %d 圈"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// RepeatUnit is how often a rule repeats; zero never does.
|
||||
type RepeatUnit int
|
||||
|
||||
const (
|
||||
RepeatNone RepeatUnit = iota
|
||||
RepeatDaily
|
||||
RepeatWeekly
|
||||
RepeatMonthly
|
||||
RepeatYearly
|
||||
)
|
||||
|
||||
// RepeatEnd says when a recurrence stops.
|
||||
type RepeatEnd int
|
||||
|
||||
const (
|
||||
RepeatForever RepeatEnd = iota
|
||||
RepeatCount
|
||||
RepeatUntil
|
||||
)
|
||||
|
||||
// Recurrence repeats every Interval units, on Weekdays when weekly.
|
||||
type Recurrence struct {
|
||||
Unit RepeatUnit
|
||||
Interval int
|
||||
Weekdays []time.Weekday
|
||||
End RepeatEnd
|
||||
Count int
|
||||
Until Date
|
||||
}
|
||||
|
||||
// RecurrenceEditorOptions configure a RecurrenceEditor.
|
||||
type RecurrenceEditorOptions struct {
|
||||
// Start is the first occurrence; Until must not precede it.
|
||||
Start Date
|
||||
WeekStart WeekStart
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// RecurrenceResult is the editor, its change and its problem.
|
||||
type RecurrenceResult struct {
|
||||
Element *ui.Element
|
||||
changed bool
|
||||
problem string
|
||||
}
|
||||
|
||||
// Changed reports whether the user changed the rule this frame.
|
||||
func (r RecurrenceResult) Changed() bool { return r.changed }
|
||||
|
||||
// Problem explains why the rule cannot be saved.
|
||||
func (r RecurrenceResult) Problem() string { return r.problem }
|
||||
|
||||
const recurrenceCustom = 5
|
||||
|
||||
// RecurrenceEditor edits a repeat rule and when it ends.
|
||||
func RecurrenceEditor(c *ui.Context, rule *Recurrence, opts RecurrenceEditorOptions) RecurrenceResult {
|
||||
recurrenceCheck(*rule)
|
||||
d := Density(c)
|
||||
k := Tokens(c)
|
||||
before := recurrenceKey(*rule)
|
||||
r := RecurrenceResult{}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "recurrence_editor.label")
|
||||
}
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
custom := ui.Local(r.Element, "recurrence-custom", func() bool { return rule.Interval > 1 })
|
||||
r.Element.Children(func() {
|
||||
mode := int(rule.Unit)
|
||||
if *custom && rule.Unit != RepeatNone {
|
||||
mode = recurrenceCustom
|
||||
}
|
||||
modes := []SelectOption[int]{}
|
||||
for i, key := range []string{"none", "daily", "weekly", "monthly", "yearly", "custom"} {
|
||||
modes = append(modes, SelectOption[int]{Value: i, Label: msg(c, "recurrence_editor."+key)})
|
||||
}
|
||||
if Select(c, &mode, modes, SelectOptions{Label: msg(c, "recurrence_editor.repeat"), Disabled: opts.Disabled}).Changed() {
|
||||
*custom = mode == recurrenceCustom
|
||||
switch {
|
||||
case *custom:
|
||||
rule.Unit = max(rule.Unit, RepeatDaily)
|
||||
rule.Interval = max(rule.Interval, 2)
|
||||
default:
|
||||
rule.Unit, rule.Interval = RepeatUnit(mode), 1
|
||||
if mode == int(RepeatNone) {
|
||||
*rule = Recurrence{}
|
||||
}
|
||||
}
|
||||
if rule.Unit == RepeatWeekly && len(rule.Weekdays) == 0 && !opts.Start.IsZero() {
|
||||
rule.Weekdays = []time.Weekday{opts.Start.Weekday()}
|
||||
}
|
||||
}
|
||||
if rule.Unit == RepeatNone {
|
||||
return
|
||||
}
|
||||
if *custom {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, msg(c, "recurrence_editor.every"))
|
||||
ui.Box(c).Width(d.ControlHeight() * 4).Children(func() {
|
||||
n := float64(rule.Interval)
|
||||
if NumberInput(c, &n, NumberInputOptions{Min: 1, Max: 999, Label: msg(c, "recurrence_editor.interval"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) {
|
||||
rule.Interval = int(n)
|
||||
}
|
||||
})
|
||||
units := []SelectOption[RepeatUnit]{}
|
||||
for u := RepeatDaily; u <= RepeatYearly; u++ {
|
||||
units = append(units, SelectOption[RepeatUnit]{Value: u, Label: msg(c, recurrenceUnitKeys[u])})
|
||||
}
|
||||
Select(c, &rule.Unit, units, SelectOptions{Label: msg(c, "recurrence_editor.unit"), Disabled: opts.Disabled})
|
||||
})
|
||||
}
|
||||
if rule.Unit == RepeatWeekly {
|
||||
first := calendarFirstWeekday(c, opts.WeekStart)
|
||||
items := []ToggleItem[time.Weekday]{}
|
||||
for i := range 7 {
|
||||
wd := time.Weekday((int(first) + i) % 7)
|
||||
items = append(items, ToggleItem[time.Weekday]{Value: wd, Label: msg(c, fmt.Sprintf("calendar.weekday_%d", wd)),
|
||||
Name: msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd))})
|
||||
}
|
||||
ToggleGroup(c, &rule.Weekdays, items, ToggleGroupOptions{Multiple: true, AllowEmpty: true, Label: msg(c, "recurrence_editor.on_days"), Disabled: opts.Disabled})
|
||||
}
|
||||
ends := []RadioItem[RepeatEnd]{{Value: RepeatForever, Label: msg(c, "recurrence_editor.forever")},
|
||||
{Value: RepeatCount, Label: msg(c, "recurrence_editor.after")}, {Value: RepeatUntil, Label: msg(c, "recurrence_editor.until")}}
|
||||
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Wrap().Children(func() {
|
||||
if RadioGroup(c, &rule.End, ends, RadioGroupOptions{Horizontal: true, Label: msg(c, "recurrence_editor.ends"), Disabled: opts.Disabled}).Changed() {
|
||||
if rule.End == RepeatCount && rule.Count == 0 {
|
||||
rule.Count = 10
|
||||
}
|
||||
if rule.End == RepeatUntil && rule.Until.IsZero() && !opts.Start.IsZero() {
|
||||
rule.Until = opts.Start.AddMonths(1)
|
||||
}
|
||||
}
|
||||
switch rule.End {
|
||||
case RepeatCount:
|
||||
ui.Box(c).Width(d.ControlHeight() * 4).Children(func() {
|
||||
n := float64(rule.Count)
|
||||
if NumberInput(c, &n, NumberInputOptions{Min: 1, Max: 999, Label: msg(c, "recurrence_editor.count"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) {
|
||||
rule.Count = int(n)
|
||||
}
|
||||
})
|
||||
case RepeatUntil:
|
||||
DatePicker(c, &rule.Until, DatePickerOptions{Label: msg(c, "recurrence_editor.until_date"), Min: opts.Start, Disabled: opts.Disabled})
|
||||
}
|
||||
})
|
||||
r.problem = recurrenceProblem(c, *rule, opts.Start)
|
||||
ui.Text(c, RecurrenceSummary(c, *rule)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||
datetimeError(c, r.problem)
|
||||
})
|
||||
if after := recurrenceKey(*rule); after != before && !r.Element.IsDisabled() {
|
||||
slog.Debug("mujica: recurrence_editor change", "rule", after)
|
||||
r.changed = true
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
var recurrenceUnitKeys = [...]string{"", "recurrence_editor.days", "recurrence_editor.weeks", "recurrence_editor.months", "recurrence_editor.years"}
|
||||
|
||||
func recurrenceKey(r Recurrence) string { return fmt.Sprintf("%v", r) }
|
||||
|
||||
// recurrenceCheck panics on rules no editor can show.
|
||||
func recurrenceCheck(r Recurrence) {
|
||||
if r.Unit < RepeatNone || r.Unit > RepeatYearly || r.End < RepeatForever || r.End > RepeatUntil || r.Interval < 0 || r.Count < 0 {
|
||||
panic(fmt.Sprintf("mujica: Recurrence %+v is out of range", r))
|
||||
}
|
||||
if r.Unit != RepeatNone && r.Interval == 0 {
|
||||
panic("mujica: Recurrence repeats with Interval 0")
|
||||
}
|
||||
datetimeCheckDate("Recurrence Until", r.Until)
|
||||
}
|
||||
|
||||
// recurrenceProblem explains why a rule cannot be saved.
|
||||
func recurrenceProblem(c *ui.Context, r Recurrence, start Date) string {
|
||||
switch {
|
||||
case r.Unit == RepeatNone:
|
||||
return ""
|
||||
case r.Unit == RepeatWeekly && len(r.Weekdays) == 0:
|
||||
return msg(c, "recurrence_editor.no_days")
|
||||
case r.End == RepeatCount && r.Count < 1:
|
||||
return msg(c, "recurrence_editor.bad_count")
|
||||
case r.End == RepeatUntil && (r.Until.IsZero() || !start.IsZero() && r.Until.Compare(start) < 0):
|
||||
return msg(c, "recurrence_editor.bad_until")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RecurrenceSummary describes a rule in the window's language.
|
||||
func RecurrenceSummary(c *ui.Context, r Recurrence) string {
|
||||
if r.Unit == RepeatNone {
|
||||
return msg(c, "recurrence_editor.never")
|
||||
}
|
||||
s := fmt.Sprintf(msg(c, "recurrence_editor.every_n"), r.Interval, msg(c, recurrenceUnitKeys[r.Unit]))
|
||||
if r.Interval == 1 {
|
||||
s = msg(c, []string{"", "recurrence_editor.daily", "recurrence_editor.weekly", "recurrence_editor.monthly", "recurrence_editor.yearly"}[r.Unit])
|
||||
}
|
||||
if r.Unit == RepeatWeekly && len(r.Weekdays) > 0 {
|
||||
var days []string
|
||||
for _, wd := range r.Weekdays {
|
||||
days = append(days, msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd)))
|
||||
}
|
||||
s += fmt.Sprintf(msg(c, "recurrence_editor.on"), strings.Join(days, msg(c, "recurrence_editor.list_sep")))
|
||||
}
|
||||
switch r.End {
|
||||
case RepeatCount:
|
||||
s += calendarCount(c, "recurrence_editor.times", r.Count)
|
||||
case RepeatUntil:
|
||||
s += fmt.Sprintf(msg(c, "recurrence_editor.until_text"), r.Until)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type recurrenceProbe struct {
|
||||
rule Recurrence
|
||||
opts RecurrenceEditorOptions
|
||||
changes int
|
||||
problem string
|
||||
summary string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func (p *recurrenceProbe) tester() *ui.Tester {
|
||||
p.opts.Start = oct(5)
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
o := p.opts
|
||||
o.Disabled = p.disabled
|
||||
r := RecurrenceEditor(c, &p.rule, o)
|
||||
if r.Changed() {
|
||||
p.changes++
|
||||
}
|
||||
p.problem = r.Problem()
|
||||
p.summary = RecurrenceSummary(c, p.rule)
|
||||
}, 720, 520)
|
||||
}
|
||||
|
||||
func recurrencePick(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
if err := tt.Click("Repeat"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click(label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceEditorPresets(t *testing.T) {
|
||||
p := &recurrenceProbe{}
|
||||
tt := p.tester()
|
||||
recurrencePick(t, tt, "Weekly")
|
||||
if p.rule.Unit != RepeatWeekly || p.rule.Interval != 1 || !slices.Equal(p.rule.Weekdays, []time.Weekday{time.Monday}) || p.changes != 1 {
|
||||
t.Fatalf("weekly preset %+v", p.rule)
|
||||
}
|
||||
_ = tt.Click("Wednesday")
|
||||
if p.summary != "Weekly on Monday, Wednesday" {
|
||||
t.Fatalf("summary %q", p.summary)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
label string
|
||||
unit RepeatUnit
|
||||
}{{"Daily", RepeatDaily}, {"Monthly", RepeatMonthly}, {"Yearly", RepeatYearly}} {
|
||||
recurrencePick(t, tt, c.label)
|
||||
if p.rule.Unit != c.unit || p.rule.Interval != 1 {
|
||||
t.Fatalf("%s gave %+v", c.label, p.rule)
|
||||
}
|
||||
}
|
||||
recurrencePick(t, tt, "Does not repeat")
|
||||
if p.rule.Unit != RepeatNone || p.summary != "Does not repeat" {
|
||||
t.Fatalf("none gave %+v", p.rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceEditorCustomAndEnds(t *testing.T) {
|
||||
p := &recurrenceProbe{rule: Recurrence{Unit: RepeatDaily, Interval: 1}}
|
||||
tt := p.tester()
|
||||
recurrencePick(t, tt, "Custom")
|
||||
if p.rule.Interval != 2 || !tt.HasText("Every") {
|
||||
t.Fatalf("custom %+v", p.rule)
|
||||
}
|
||||
_ = tt.Click("Interval")
|
||||
tt.Key(0, ui.KeyUp)
|
||||
_ = tt.Click("After")
|
||||
if p.rule.Interval != 3 || p.rule.End != RepeatCount || p.rule.Count != 10 || p.summary != "Every 3 days, 10 times" {
|
||||
t.Fatalf("custom count %+v %q", p.rule, p.summary)
|
||||
}
|
||||
_ = tt.Click("On date")
|
||||
if p.rule.End != RepeatUntil || p.rule.Until != (Date{2026, time.November, 5}) || p.problem != "" {
|
||||
t.Fatalf("until %+v %q", p.rule, p.problem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceEditorProblems(t *testing.T) {
|
||||
p := &recurrenceProbe{rule: Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Monday")
|
||||
if len(p.rule.Weekdays) != 0 || p.problem != "Choose at least one day." || !tt.HasText("Choose at least one day.") {
|
||||
t.Fatalf("no-day problem %+v %q", p.rule, p.problem)
|
||||
}
|
||||
p.rule = Recurrence{Unit: RepeatDaily, Interval: 1, End: RepeatUntil, Until: Date{2026, time.September, 1}}
|
||||
tt.Frame()
|
||||
if p.problem != "The end date must not precede the start." {
|
||||
t.Fatalf("until problem %q", p.problem)
|
||||
}
|
||||
for _, bad := range []Recurrence{{Unit: 9}, {Unit: RepeatDaily}, {Interval: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: expected panic", bad)
|
||||
}
|
||||
}()
|
||||
q := &recurrenceProbe{rule: bad}
|
||||
q.tester()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceEditorDisabled(t *testing.T) {
|
||||
p := &recurrenceProbe{rule: Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}, disabled: true}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Repeat")
|
||||
_ = tt.Click("Daily")
|
||||
_ = tt.Click("Friday")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if p.rule.Unit != RepeatWeekly || len(p.rule.Weekdays) != 1 || p.changes != 0 {
|
||||
t.Fatalf("disabled editor changed: %+v", p.rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceEditorSnapshot(t *testing.T) {
|
||||
r := Recurrence{Unit: RepeatWeekly, Interval: 2, Weekdays: []time.Weekday{time.Monday, time.Thursday}, End: RepeatCount, Count: 8}
|
||||
snapshot(t, "recurrence_editor", 640, 280, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { RecurrenceEditor(c, &r, RecurrenceEditorOptions{Start: oct(5)}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// RelativeTimeOptions configure a RelativeTime.
|
||||
type RelativeTimeOptions struct {
|
||||
// Location is the tooltip's zone; required.
|
||||
Location *time.Location
|
||||
// Now fixes the clock and stops updates.
|
||||
Now time.Time
|
||||
Hour12 bool
|
||||
}
|
||||
|
||||
// RelativeTime shows t as "3 minutes ago", refreshing while shown.
|
||||
func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element {
|
||||
loc := eventLocation("RelativeTime", opts.Location)
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
c.After(relativeTimeNext(now.Sub(t)))
|
||||
}
|
||||
text := RelativeTimeText(c, now.Sub(t))
|
||||
w := t.In(loc)
|
||||
exact := DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), 0}, timeConfig{hour12: opts.Hour12}) + " " + w.Format("MST")
|
||||
return ui.Text(c, text).Label(text + ", " + exact).Tooltip(exact).FontFeatures("tnum")
|
||||
}
|
||||
|
||||
// RelativeTimeText words d ago (positive) or ahead (negative).
|
||||
func RelativeTimeText(c *ui.Context, d time.Duration) string {
|
||||
past := d >= 0
|
||||
if !past {
|
||||
d = -d
|
||||
}
|
||||
var key string
|
||||
var n int
|
||||
switch {
|
||||
case d < 45*time.Second:
|
||||
return msg(c, "relative_time.now")
|
||||
case d < 45*time.Minute:
|
||||
key, n = "minutes", max(1, int(d/time.Minute))
|
||||
case d < 22*time.Hour:
|
||||
key, n = "hours", max(1, int(d/time.Hour))
|
||||
case d < 26*24*time.Hour:
|
||||
key, n = "days", max(1, int(d/(24*time.Hour)))
|
||||
case d < 320*24*time.Hour:
|
||||
key, n = "months", max(1, int(d/(30*24*time.Hour)))
|
||||
default:
|
||||
key, n = "years", max(1, int(d/(365*24*time.Hour)))
|
||||
}
|
||||
unit := calendarCount(c, "relative_time."+key, n)
|
||||
if past {
|
||||
return fmt.Sprintf(msg(c, "relative_time.ago"), unit)
|
||||
}
|
||||
return fmt.Sprintf(msg(c, "relative_time.in"), unit)
|
||||
}
|
||||
|
||||
// relativeTimeNext is how long until the wording may change.
|
||||
func relativeTimeNext(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
step := time.Hour
|
||||
switch {
|
||||
case d < 2*time.Minute:
|
||||
step = time.Second
|
||||
case d < 2*time.Hour:
|
||||
step = time.Minute
|
||||
}
|
||||
return step - d%step
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestRelativeTimeWording(t *testing.T) {
|
||||
var en, zh []string
|
||||
cases := []time.Duration{10 * time.Second, -90 * time.Second, 3 * time.Minute, 5 * time.Hour, -25 * time.Hour, 40 * 24 * time.Hour, 800 * 24 * time.Hour}
|
||||
for _, loc := range []Locale{EnUS, ZhCN} {
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: loc})
|
||||
var out []string
|
||||
for _, d := range cases {
|
||||
out = append(out, RelativeTimeText(c, d))
|
||||
}
|
||||
if loc == EnUS {
|
||||
en = out
|
||||
} else {
|
||||
zh = out
|
||||
}
|
||||
}, 10, 10)
|
||||
}
|
||||
wantEn := []string{"just now", "in 1 minute", "3 minutes ago", "5 hours ago", "in 1 day", "1 month ago", "2 years ago"}
|
||||
wantZh := []string{"刚刚", "1 分钟后", "3 分钟前", "5 小时前", "1 天后", "1 个月前", "2 年前"}
|
||||
for i := range cases {
|
||||
if en[i] != wantEn[i] || zh[i] != wantZh[i] {
|
||||
t.Fatalf("%v: %q %q", cases[i], en[i], zh[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeTimeRefreshFollowsDistance(t *testing.T) {
|
||||
for _, c := range []struct{ d, next time.Duration }{
|
||||
{10*time.Second + 300*time.Millisecond, 700 * time.Millisecond},
|
||||
{5*time.Minute + 20*time.Second, 40 * time.Second},
|
||||
{3*time.Hour + 15*time.Minute, 45 * time.Minute},
|
||||
{-(90 * time.Second), 1 * time.Second},
|
||||
} {
|
||||
if got := relativeTimeNext(c.d); got != c.next {
|
||||
t.Errorf("%v: next %v, want %v", c.d, got, c.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeTimeShowsExactTimeAndStopsWhenHidden(t *testing.T) {
|
||||
at := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
shown := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if shown {
|
||||
RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(3 * time.Hour)})
|
||||
}
|
||||
}, 300, 100)
|
||||
if !tt.HasText("3 hours ago, 2026-10-05 18:00 JST") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
shown = false
|
||||
tt.Frame()
|
||||
if tt.HasText("ago") {
|
||||
t.Fatal("hidden text remains")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic without Location")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); RelativeTime(c, at, RelativeTimeOptions{}) }, 10, 10)
|
||||
}
|
||||
|
||||
func TestRelativeTimeSnapshot(t *testing.T) {
|
||||
at := time.Date(2026, time.October, 5, 9, 0, 0, 0, calTokyo)
|
||||
snapshot(t, "relative_time", 260, 120, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(20 * time.Second)})
|
||||
RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(-2 * time.Hour)})
|
||||
RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(72 * time.Hour)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ReminderPickerOptions configure a ReminderPicker.
|
||||
type ReminderPickerOptions struct {
|
||||
// Choices are the lead times offered; empty uses defaults.
|
||||
Choices []time.Duration
|
||||
// Max limits how many are set; zero means any.
|
||||
Max int
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
var reminderDefaults = []time.Duration{0, 5 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour, 24 * time.Hour, 7 * 24 * time.Hour}
|
||||
|
||||
// ReminderPicker toggles reminder lead times, kept in choice order.
|
||||
func ReminderPicker(c *ui.Context, value *[]time.Duration, opts ReminderPickerOptions) ChoiceResult {
|
||||
choices := opts.Choices
|
||||
if len(choices) == 0 {
|
||||
choices = reminderDefaults
|
||||
}
|
||||
for i, d := range choices {
|
||||
if d < 0 || d%time.Minute != 0 || slices.Contains(choices[:i], d) {
|
||||
panic(fmt.Sprintf("mujica: ReminderPicker choice %v is negative, sub-minute or repeated", d))
|
||||
}
|
||||
}
|
||||
for _, d := range *value {
|
||||
if !slices.Contains(choices, d) {
|
||||
panic(fmt.Sprintf("mujica: ReminderPicker value %v is not a choice", d))
|
||||
}
|
||||
}
|
||||
if opts.Max < 0 || opts.Max > 0 && len(*value) > opts.Max {
|
||||
panic(fmt.Sprintf("mujica: ReminderPicker Max %d is negative or below the value", opts.Max))
|
||||
}
|
||||
full := opts.Max > 0 && len(*value) >= opts.Max
|
||||
items := make([]ToggleItem[time.Duration], len(choices))
|
||||
for i, d := range choices {
|
||||
items[i] = ToggleItem[time.Duration]{Value: d, Label: ReminderText(c, d), Disabled: full && !slices.Contains(*value, d)}
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "reminder_picker.label")
|
||||
}
|
||||
r := ToggleGroup(c, value, items, ToggleGroupOptions{Multiple: true, AllowEmpty: true, Vertical: true, Label: label, Disabled: opts.Disabled})
|
||||
if r.Changed() {
|
||||
slices.SortFunc(*value, func(a, b time.Duration) int { return slices.Index(choices, a) - slices.Index(choices, b) })
|
||||
slog.Debug("mujica: reminder_picker change", "value", *value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ReminderText words a lead time, as "15 minutes before".
|
||||
func ReminderText(c *ui.Context, d time.Duration) string {
|
||||
switch {
|
||||
case d == 0:
|
||||
return msg(c, "reminder_picker.at_start")
|
||||
case d%(7*24*time.Hour) == 0:
|
||||
return calendarCount(c, "reminder_picker.weeks", int(d/(7*24*time.Hour)))
|
||||
case d%(24*time.Hour) == 0:
|
||||
return calendarCount(c, "reminder_picker.days", int(d/(24*time.Hour)))
|
||||
case d%time.Hour == 0:
|
||||
return calendarCount(c, "reminder_picker.hours", int(d/time.Hour))
|
||||
}
|
||||
return calendarCount(c, "reminder_picker.minutes", int(d/time.Minute))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func reminderTester(v *[]time.Duration, opts ReminderPickerOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ReminderPicker(c, v, opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
}, 640, 200)
|
||||
}
|
||||
|
||||
func TestReminderPickerTogglesInChoiceOrder(t *testing.T) {
|
||||
v, n := []time.Duration{}, 0
|
||||
tt := reminderTester(&v, ReminderPickerOptions{}, &n)
|
||||
_ = tt.Click("1 day before")
|
||||
_ = tt.Click("15 minutes before")
|
||||
_ = tt.Click("At start")
|
||||
if !slices.Equal(v, []time.Duration{0, 15 * time.Minute, 24 * time.Hour}) || n != 3 {
|
||||
t.Fatalf("value %v (%d)", v, n)
|
||||
}
|
||||
_ = tt.Click("15 minutes before")
|
||||
if !slices.Equal(v, []time.Duration{0, 24 * time.Hour}) {
|
||||
t.Fatalf("untoggle left %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderPickerMaxKeyboardDisabled(t *testing.T) {
|
||||
v, n := []time.Duration{time.Hour}, 0
|
||||
tt := reminderTester(&v, ReminderPickerOptions{Max: 2}, &n)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
_ = tt.Click("1 week before")
|
||||
_ = tt.Click("5 minutes before")
|
||||
if !slices.Equal(v, []time.Duration{0, time.Hour}) {
|
||||
t.Fatalf("Max ignored: %v", v)
|
||||
}
|
||||
d := []time.Duration{}
|
||||
tt = reminderTester(&d, ReminderPickerOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("At start")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if len(d) != 0 {
|
||||
t.Fatal("disabled picker changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderTextAndInvalid(t *testing.T) {
|
||||
var got []string
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
got = []string{ReminderText(c, time.Minute), ReminderText(c, 2*time.Hour), ReminderText(c, 14*24*time.Hour)}
|
||||
}, 10, 10)
|
||||
if !slices.Equal(got, []string{"提前 1 分钟", "提前 2 小时", "提前 2 周"}) {
|
||||
t.Fatalf("texts %q", got)
|
||||
}
|
||||
for _, opts := range []ReminderPickerOptions{{Choices: []time.Duration{30 * time.Second}}, {Choices: []time.Duration{0, 0}}, {Max: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: expected panic", opts)
|
||||
}
|
||||
}()
|
||||
v := []time.Duration{}
|
||||
reminderTester(&v, opts, new(int))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderPickerSnapshot(t *testing.T) {
|
||||
v := []time.Duration{15 * time.Minute, 24 * time.Hour}
|
||||
snapshot(t, "reminder_picker", 300, 330, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ReminderPicker(c, &v, ReminderPickerOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// StopwatchState is the caller's run and laps.
|
||||
type StopwatchState struct {
|
||||
running bool
|
||||
since time.Time
|
||||
base time.Duration
|
||||
// Laps are the elapsed times at each lap, oldest first.
|
||||
Laps []time.Duration
|
||||
}
|
||||
|
||||
// Elapsed returns the time measured at now.
|
||||
func (s *StopwatchState) Elapsed(now time.Time) time.Duration {
|
||||
if s.running {
|
||||
return s.base + now.Sub(s.since)
|
||||
}
|
||||
return s.base
|
||||
}
|
||||
|
||||
// Running reports whether the stopwatch is counting.
|
||||
func (s *StopwatchState) Running() bool { return s.running }
|
||||
|
||||
// StopwatchOptions configure a Stopwatch.
|
||||
type StopwatchOptions struct {
|
||||
// Now fixes the clock and stops ticking.
|
||||
Now time.Time
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// Stopwatch measures time with laps, ticking while running and shown.
|
||||
func Stopwatch(c *ui.Context, s *StopwatchState, opts StopwatchOptions) ChoiceResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
now := opts.Now
|
||||
live := now.IsZero()
|
||||
if live {
|
||||
now = c.Now()
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "stopwatch.label")
|
||||
}
|
||||
changed := false
|
||||
col := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
act := func(what string, fn func()) {
|
||||
if col.IsDisabled() {
|
||||
return
|
||||
}
|
||||
fn()
|
||||
changed = true
|
||||
slog.Debug("mujica: stopwatch "+what, "elapsed", s.Elapsed(now))
|
||||
}
|
||||
col.Children(func() {
|
||||
el := s.Elapsed(now)
|
||||
ui.Text(c, StopwatchText(el)).Font(theme.MonoFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum").
|
||||
Label(label + ", " + StopwatchText(el))
|
||||
ui.Row(c).Gap(d.Unit() * 2).Children(func() {
|
||||
start, variant := "stopwatch.start", Primary
|
||||
if s.running {
|
||||
start, variant = "stopwatch.pause", Secondary
|
||||
} else if el > 0 {
|
||||
start = "stopwatch.resume"
|
||||
}
|
||||
if Button(c, msg(c, start), ButtonOptions{Variant: variant}).Clicked() {
|
||||
act("toggle", func() {
|
||||
if s.running {
|
||||
s.base, s.running = s.Elapsed(now), false
|
||||
} else {
|
||||
s.since, s.running = now, true
|
||||
}
|
||||
})
|
||||
}
|
||||
if Button(c, msg(c, "stopwatch.lap"), ButtonOptions{Variant: Secondary, Disabled: !s.running}).Clicked() {
|
||||
act("lap", func() { s.Laps = append(s.Laps, s.Elapsed(now)) })
|
||||
}
|
||||
if Button(c, msg(c, "stopwatch.reset"), ButtonOptions{Variant: Ghost, Disabled: s.running || el == 0 && len(s.Laps) == 0}).Clicked() {
|
||||
act("reset", func() { *s = StopwatchState{} })
|
||||
}
|
||||
})
|
||||
if len(s.Laps) > 0 {
|
||||
ui.Column(c).Role(ui.RoleList).Children(func() {
|
||||
for i := len(s.Laps) - 1; i >= 0; i-- {
|
||||
split := s.Laps[i]
|
||||
if i > 0 {
|
||||
split -= s.Laps[i-1]
|
||||
}
|
||||
row := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit() * 4).Role(ui.RoleListItem)
|
||||
row.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Rule(p, r.X, r.X+r.W, r.Y+r.H-1, k.Border) })
|
||||
row.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "stopwatch.lap_n"), i+1)).Width(d.ControlHeight() * 2).TextColor(k.TextMuted)
|
||||
ui.Text(c, StopwatchText(split)).Font(theme.MonoFont).FontFeatures("tnum").TextColor(k.Text).Grow(1)
|
||||
ui.Text(c, StopwatchText(s.Laps[i])).Font(theme.MonoFont).FontFeatures("tnum").TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if s.running && live {
|
||||
c.After(100*time.Millisecond - s.Elapsed(now)%(100*time.Millisecond))
|
||||
}
|
||||
return ChoiceResult{Element: col, changed: changed}
|
||||
}
|
||||
|
||||
// StopwatchText formats elapsed time as 01:02.3, with hours when needed.
|
||||
func StopwatchText(d time.Duration) string {
|
||||
t := int(d / (100 * time.Millisecond))
|
||||
h, m, s, tenth := t/36000, t%36000/600, t%600/10, t%10
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d.%d", h, m, s, tenth)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d.%d", m, s, tenth)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestStopwatchStartLapPauseReset(t *testing.T) {
|
||||
var s StopwatchState
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
n, disabled := 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Stopwatch(c, &s, StopwatchOptions{Now: now, Disabled: disabled}).Changed() {
|
||||
n++
|
||||
}
|
||||
}, 500, 500)
|
||||
_ = tt.Click("Start")
|
||||
now = now.Add(12*time.Second + 340*time.Millisecond)
|
||||
tt.Frame()
|
||||
if !tt.HasText("00:12.3") || !s.Running() {
|
||||
t.Fatalf("running %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Lap")
|
||||
now = now.Add(5 * time.Second)
|
||||
_ = tt.Click("Lap")
|
||||
if len(s.Laps) != 2 || !tt.HasText("Lap 2") || !tt.HasText("00:05.0") {
|
||||
t.Fatalf("laps %v %v", s.Laps, tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Pause")
|
||||
now = now.Add(time.Minute)
|
||||
tt.Frame()
|
||||
if s.Running() || s.Elapsed(now) != 17340*time.Millisecond || !tt.HasText("Resume") {
|
||||
t.Fatalf("paused at %v", s.Elapsed(now))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
now = now.Add(time.Hour)
|
||||
tt.Frame()
|
||||
if !tt.HasText("1:00:17.3") {
|
||||
t.Fatalf("resumed by keyboard: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Pause")
|
||||
_ = tt.Click("Reset")
|
||||
if s.Elapsed(now) != 0 || len(s.Laps) != 0 || n != 7 {
|
||||
t.Fatalf("reset %v laps %v changes %d", s.Elapsed(now), s.Laps, n)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Start")
|
||||
if s.Running() {
|
||||
t.Fatal("disabled stopwatch started")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopwatchSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
s := StopwatchState{base: 83*time.Second + 400*time.Millisecond, Laps: []time.Duration{31 * time.Second, 62*time.Second + 500*time.Millisecond}}
|
||||
snapshot(t, "stopwatch", 420, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { Stopwatch(c, &s, StopwatchOptions{Now: now}) })
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 858 B |
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |