diff options
Diffstat (limited to 'pim/cmd/datepick/main.go')
| -rw-r--r-- | pim/cmd/datepick/main.go | 395 |
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() +} |
