// 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() }