summaryrefslogtreecommitdiff
path: root/pim/cmd/datepick/main.go
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-22 19:12:15 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-22 19:12:15 -0400
commitc16c778c60deaafa0c9e5bbc1db9a10f64fec9b2 (patch)
treeaced25a7611486d61f20094728b41fe322ad6feb /pim/cmd/datepick/main.go
parent77c7bc9b187450818ec068239a3ae836d01fdebc (diff)
pim: a window for each kind of thing, and a picker for datesHEADmaster
cal9 kept an event in a panel and a day in another, with the state that implies: a selection, a picked day, rectangles for the buttons on it, and a hundred-odd lines to draw it. Opening a window instead deletes all of that rather than tidying it away. Clicking an event opens cal9 -v event, clicking a day opens cal9 -v day, and cal9 is three grids again. The event window earns being cal9 rather than pim/showwin: it carries Accept, Tentative and Declined, lit to show where you stand, and only when the calendar's ctl says it can rsvp. cal9 -v invite is a form that writes the calendar's new file in the tree's key: value form, so the composer knows nothing about iCalendar either. datepick is a program rather than a widget so anything can use it: pim/agenda -d `{datepick} -t asks for a time as well, in stages, because a grid of ninety-six five-minute slots is not something you want to aim at. A window started from wctl has nowhere useful to print, so -o and -s hand the answer back by file or by snarf; the invite form spawns it that way and fills the field when it returns. An event you have not answered draws pale, so a glance at the week says what is still waiting. Move and Resize are gone from the menus: rio owns the border and does both, better. Compact is one item showing whichever state you are not in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'pim/cmd/datepick/main.go')
-rw-r--r--pim/cmd/datepick/main.go395
1 files changed, 395 insertions, 0 deletions
diff --git a/pim/cmd/datepick/main.go b/pim/cmd/datepick/main.go
new file mode 100644
index 0000000..bd3090d
--- /dev/null
+++ b/pim/cmd/datepick/main.go
@@ -0,0 +1,395 @@
+// datepick shows a month, and prints the day you click.
+//
+// datepick one date, as yyyy-mm-dd
+// datepick -r two, a range, one per line
+// datepick -d 2026-08-01 open on that month
+// datepick -t a time too: yyyy-mm-dd hh:mm
+// datepick -o file write it there as well
+// datepick -s put it on /dev/snarf as well
+//
+// The last two exist because a window started from wctl has nowhere
+// useful to print: nothing is reading its standard output.
+//
+// It is a program rather than a widget so that anything can use it:
+//
+// pim/agenda -d `{datepick}
+// pim/invite 'review' `{datepick}' 15:00'
+//
+// which is the same reason plumb and 9fs are programs. A widget would
+// only ever be usable by whatever linked it.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "9front/gui/draw"
+ "9front/gui/ui"
+)
+
+const (
+ fontpath = "/lib/font/bit/lucidasans/unicode.8.font"
+ pad = 6
+)
+
+type picker struct {
+ ui.UI
+ at time.Time // the month on show
+ got []time.Time // what has been clicked
+ want int // how many we need
+ mc *draw.Mousectl
+ done bool
+
+ // With -t each answer is picked in stages: the day, then the hour,
+ // then the minute. Splitting it keeps every target big enough to
+ // hit, where a grid of 96 five-minute slots would not be.
+ withTime bool
+ stage int // 0 day, 1 hour, 2 minute
+ day time.Time
+ hour int
+}
+
+func main() {
+ rng := flag.Bool("r", false, "pick a range: two dates, earliest first")
+ from := flag.String("d", "", "open on this month (yyyy-mm-dd)")
+ tim := flag.Bool("t", false, "pick a time as well, giving yyyy-mm-dd hh:mm")
+ out := flag.String("o", "", "write the answer here as well as to standard output")
+ snarf := flag.Bool("s", false, "put the answer on /dev/snarf too")
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "usage: datepick [-r] [-t] [-s] [-o file] [-d yyyy-mm-dd]\n")
+ os.Exit(2)
+ }
+ flag.Parse()
+
+ p := &picker{at: time.Now(), want: 1}
+ p.withTime = *tim
+ if *rng {
+ p.want = 2
+ }
+ if *from != "" {
+ t, err := time.Parse("2006-01-02", *from)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "datepick: %v\n", err)
+ os.Exit(1)
+ }
+ p.at = t
+ }
+ if err := p.run(); err != nil {
+ fmt.Fprintf(os.Stderr, "datepick: %v\n", err)
+ os.Exit(1)
+ }
+ // nothing chosen is not an error, it is a change of mind, but it
+ // should not look like a date to whatever runs us
+ if len(p.got) < p.want {
+ os.Exit(1)
+ }
+ var b strings.Builder
+ for _, t := range p.got {
+ fmt.Fprintln(&b, t.Format(p.layout()))
+ }
+ fmt.Print(b.String())
+ // A window started from wctl has nowhere useful to print, so it can
+ // hand the answer over by file or by snarf instead.
+ if *out != "" {
+ os.WriteFile(*out, []byte(b.String()), 0600)
+ }
+ if *snarf {
+ draw.Snarf("/dev", b.String())
+ }
+}
+
+func (p *picker) run() error {
+ var err error
+ if p.D, err = draw.Init("/dev"); err != nil {
+ return err
+ }
+ defer p.D.Close()
+ if p.F, err = p.D.OpenFont(fontpath); err != nil {
+ return err
+ }
+ p.Col = map[string]*draw.Image{}
+ for k, v := range map[string]uint32{
+ "bg": 0xFFFFEAFF,
+ "tag": 0xEAFFFFFF,
+ "rule": 0x99994CFF,
+ "ink": 0x000000FF,
+ "today": 0xFFFFAAFF,
+ "pick": 0xEEF2FFFF,
+ "chosen": 0x99AAEEFF,
+ "border": 0x8888CCFF,
+ } {
+ if p.Col[k], err = p.D.Color(v); err != nil {
+ return err
+ }
+ }
+ if p.Win, err = p.D.Window("/dev"); err != nil {
+ return err
+ }
+ kb, err := draw.OpenKeyboard("/dev")
+ if err == nil {
+ defer kb.Close()
+ }
+ var keys <-chan rune
+ if kb != nil {
+ keys = kb.C
+ }
+ if p.mc, err = draw.OpenMouse("/dev"); err != nil {
+ return err
+ }
+ defer p.mc.Close()
+
+ p.redraw()
+ var wasDown bool
+ for !p.done {
+ select {
+ case m, ok := <-p.mc.C:
+ if !ok {
+ return nil
+ }
+ if m.Buttons&4 != 0 {
+ p.Menu([]ui.Item{
+ {Label: "Today", Do: func() { p.at = time.Now(); p.redraw() }},
+ {Label: "Restart", Do: func() { p.restart() }},
+ {Label: "Cancel", Do: func() { p.done = true }},
+ }, m.Point, p.mc, p.redraw)
+ wasDown = false
+ continue
+ }
+ switch {
+ case m.Buttons&8 != 0:
+ p.step(-1)
+ case m.Buttons&16 != 0:
+ p.step(1)
+ }
+ down := m.Buttons&1 != 0
+ if down && !wasDown {
+ p.Click(m.Point)
+ }
+ wasDown = down
+ case r, ok := <-keys:
+ if !ok {
+ keys = nil
+ continue
+ }
+ switch r {
+ case 'q', 0x1B:
+ return nil
+ case 'h':
+ p.step(-1)
+ case 'l':
+ p.step(1)
+ case 't':
+ p.at = time.Now()
+ p.redraw()
+ case 'r':
+ p.restart()
+ }
+ case <-p.mc.Resize:
+ if p.Win, err = p.D.Reattach("/dev", p.Win); err != nil {
+ return err
+ }
+ p.redraw()
+ }
+ }
+ return nil
+}
+
+// restart forgets what has been picked and goes back to the first
+// question. Getting the second date wrong should not mean killing the
+// window and starting the command again.
+func (p *picker) restart() {
+ p.got = p.got[:0]
+ p.stage = 0
+ p.hour = 0
+ p.day = time.Time{}
+ p.redraw()
+}
+
+func (p *picker) step(n int) {
+ p.at = p.at.AddDate(0, n, 0)
+ p.redraw()
+}
+
+// chooseDay takes the day, and then the clock if we were asked for one.
+func (p *picker) chooseDay(d time.Time) {
+ if p.withTime {
+ p.day = d
+ p.stage = 1
+ p.redraw()
+ return
+ }
+ p.choose(d)
+}
+
+func (p *picker) choose(d time.Time) {
+ p.got = append(p.got, d)
+ p.stage = 0
+ if len(p.got) >= p.want {
+ // a range is a range whichever end you clicked first
+ if len(p.got) == 2 && p.got[1].Before(p.got[0]) {
+ p.got[0], p.got[1] = p.got[1], p.got[0]
+ }
+ p.done = true
+ return
+ }
+ p.redraw()
+}
+
+// layout is how an answer is written: with the time, if we asked for one.
+func (p *picker) layout() string {
+ if p.withTime {
+ return "2006-01-02 15:04"
+ }
+ return "2006-01-02"
+}
+
+func (p *picker) redraw() {
+ p.Reset()
+ r := p.Body()
+ p.Fill(r, "bg")
+ if p.stage != 0 {
+ p.drawClock(r)
+ return
+ }
+
+ lh := int32(p.F.Height) + 4
+ hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+lh+4)
+ p.Fill(hdr, "tag")
+
+ x := r.Max.X - pad
+ mk := func(label string, do func()) {
+ w := p.F.Width(label) + 16
+ br := draw.Rect(x-w, hdr.Min.Y+2, x, hdr.Max.Y-2)
+ p.Button(br, label, false, do)
+ x = br.Min.X - 4
+ }
+ mk(">", func() { p.step(1) })
+ mk("<", func() { p.step(-1) })
+
+ title := p.at.Format("January 2006")
+ if p.want == 2 {
+ if len(p.got) == 0 {
+ title += " pick from"
+ } else {
+ // having chosen one end, show it: otherwise the second
+ // question is asked with no sign of the first answer
+ title += " from " + p.got[0].Format(p.layout()) + " -- pick to"
+ }
+ }
+ p.Text(draw.Point{X: r.Min.X + pad, Y: hdr.Min.Y + 3}, "ink", title)
+
+ // the grid: whole weeks, Monday first
+ first := time.Date(p.at.Year(), p.at.Month(), 1, 0, 0, 0, 0, time.Local)
+ off := (int(first.Weekday()) + 6) % 7
+ start := first.AddDate(0, 0, -off)
+
+ body := draw.Rect(r.Min.X, hdr.Max.Y+2, r.Max.X, r.Max.Y)
+ cw := body.Dx() / 7
+ ch := (body.Dy() - lh) / 6
+
+ for i, d := range []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} {
+ p.Text(draw.Point{X: body.Min.X + int32(i)*cw + 4, Y: body.Min.Y}, "rule", d)
+ }
+
+ today := time.Now().Format("2006-01-02")
+ for i := 0; i < 42; i++ {
+ d := start.AddDate(0, 0, i)
+ key := d.Format("2006-01-02")
+ cx := body.Min.X + int32(i%7)*cw
+ cy := body.Min.Y + lh + int32(i/7)*ch
+ cell := draw.Rect(cx, cy, cx+cw-2, cy+ch-2)
+
+ switch {
+ case len(p.got) > 0 && key == p.got[0].Format("2006-01-02"):
+ p.Fill(cell, "chosen") // the end already picked
+ case len(p.got) > 0 && d.After(p.got[0]) && p.want == 2:
+ p.Fill(cell, "pick") // still selectable as the other end
+ case key == today:
+ p.Fill(cell, "today")
+ }
+ c := "ink"
+ if d.Month() != p.at.Month() {
+ c = "rule"
+ }
+ p.Text(draw.Point{X: cell.Min.X + 4, Y: cell.Min.Y + 2}, c, d.Format("2"))
+ dd := d
+ p.On(cell, func() { p.chooseDay(dd) })
+ }
+ p.D.Flush()
+}
+
+// drawClock is the hour, then the minute. Two grids of a couple of dozen
+// cells each, rather than one of ninety-six: the point of picking with a
+// mouse is that the target is easy to hit.
+func (p *picker) drawClock(r draw.Rectangle) {
+ lh := int32(p.F.Height) + 4
+ hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+lh+4)
+ p.Fill(hdr, "tag")
+
+ title := p.day.Format("Monday 2 January")
+ if p.stage == 1 {
+ title += " hour"
+ } else {
+ title += fmt.Sprintf(" %02d:__", p.hour)
+ }
+ if p.want == 2 && len(p.got) == 1 {
+ title = "from " + p.got[0].Format(p.layout()) + " -- " + title
+ }
+ p.Text(draw.Point{X: r.Min.X + pad, Y: hdr.Min.Y + 3}, "ink", title)
+
+ x := r.Max.X - pad
+ w := p.F.Width("back") + 16
+ p.Button(draw.Rect(x-w, hdr.Min.Y+2, x, hdr.Max.Y-2), "back", false, func() {
+ p.stage--
+ p.redraw()
+ })
+
+ body := draw.Rect(r.Min.X, hdr.Max.Y+2, r.Max.X, r.Max.Y)
+
+ var labels []string
+ var vals []int
+ if p.stage == 1 {
+ for h := 0; h < 24; h++ {
+ labels = append(labels, fmt.Sprintf("%02d", h))
+ vals = append(vals, h)
+ }
+ } else {
+ for m := 0; m < 60; m += 5 {
+ labels = append(labels, fmt.Sprintf(":%02d", m))
+ vals = append(vals, m)
+ }
+ }
+
+ cols := int32(6)
+ rows := (int32(len(labels)) + cols - 1) / cols
+ cw := body.Dx() / cols
+ ch := body.Dy() / rows
+ if ch > lh*3 {
+ ch = lh * 3
+ }
+
+ for i, lab := range labels {
+ cx := body.Min.X + int32(i)%cols*cw
+ cy := body.Min.Y + int32(i)/cols*ch
+ cell := draw.Rect(cx+1, cy+1, cx+cw-2, cy+ch-2)
+ p.Fill(cell, "tag")
+ p.Border(cell, "border")
+ tw := p.F.Width(lab)
+ p.Text(draw.Point{X: cell.Min.X + (cell.Dx()-tw)/2, Y: cell.Min.Y + 2}, "ink", lab)
+ v := vals[i]
+ p.On(cell, func() {
+ if p.stage == 1 {
+ p.hour = v
+ p.stage = 2
+ p.redraw()
+ return
+ }
+ p.choose(time.Date(p.day.Year(), p.day.Month(), p.day.Day(),
+ p.hour, v, 0, 0, time.Local))
+ })
+ }
+ p.D.Flush()
+}