summaryrefslogtreecommitdiff
path: root/pim/cmd/cal9
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:49 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-22 13:16:49 -0400
commit8a3d2c99bff60cb775ebc42516c0c3912d54ba3d (patch)
treefd136a3b7927146763cdc11e6be07c71f92bb92c /pim/cmd/cal9
parent9aacce8b3b54060d0037eca897856d7273e6e5e8 (diff)
pim: personal information management, starting with a calendar
A calendar as a file tree, and tools that only know the tree: events/date/yyyy/mm/dd/hhmm-summary as lived events/uuid/<uid>/ as stored ctl query alarm changed lib/cal owns all of that. A backend supplies events and, where its protocol allows, takes changes back -- six methods. cmd/icalfs is the first: it reads .ics files from a directory and nothing else, because fetching is rc/fetch's job and hget already exists. That keeps net/http out of the binary and makes a subscribed calendar and a local one the same thing. The tools are rc on purpose. If the tree needs a compiled program to be useful, the tree is the wrong shape. Three things were added to the tree because the rc port needed them: a path from an occurrence to its event, epoch seconds beside RFC3339, and a numeric slot for all-day events so test(1) can compare it. ctl reports caps, so a tool can say "read only" instead of trying and failing. A published .ics is read only: nowhere to PUT, and no METHOD:REQUEST to reply to. CalDAV would be read write rsvp schedule, and that is the next backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'pim/cmd/cal9')
-rw-r--r--pim/cmd/cal9/main.go1131
-rw-r--r--pim/cmd/cal9/menu.go267
2 files changed, 1398 insertions, 0 deletions
diff --git a/pim/cmd/cal9/main.go b/pim/cmd/cal9/main.go
new file mode 100644
index 0000000..aa9a370
--- /dev/null
+++ b/pim/cmd/cal9/main.go
@@ -0,0 +1,1131 @@
+// cal: a calendar over the pim filesystem.
+//
+// Day, week and month views; < > step by the current unit; the wheel rolls
+// the time axis. There is no minimise button: rio has no iconify, so
+// "compact" is driven by how small you make the window instead.
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "9front/gui/draw"
+)
+
+const (
+ mtpt = "/mnt/pim"
+ fontpath = "/lib/font/bit/lucidasans/unicode.8.font"
+
+ pad = 6
+ gutter = 52 // hour-label column
+ spanMin = 11 * 60
+ compactH = 90 // below this window height, show the one-line view
+)
+
+type view int
+
+const (
+ vDay view = iota
+ vWeek
+ vMonth
+)
+
+var viewName = map[view]string{vDay: "Day", vWeek: "Week", vMonth: "Month"}
+
+// One colour per calendar. The fill is pale enough to take black text in
+// the day and week views; the ink is the same hue darkened, for the month
+// grid where events are text on the background rather than in a box.
+var calPalette = []struct{ fill, ink uint32 }{
+ {0x8888CCFF, 0x333388FF}, // purpleblue, the original event colour
+ {0x88CC88FF, 0x226622FF}, // green
+ {0xE0B080FF, 0x805000FF}, // tan
+ {0xCC8888FF, 0x883333FF}, // red
+ {0x88CCCCFF, 0x226666FF}, // cyan
+ {0xCCCC88FF, 0x666622FF}, // olive
+}
+
+type event struct {
+ min int // minutes past midnight
+ title string
+ file string
+ cal string // which calendar it came from
+}
+
+// selLine is one line of panel text, recorded so it can be selected.
+type selLine struct {
+ r draw.Rectangle
+ text string
+}
+
+// hit is a clickable region recorded during redraw.
+type hit struct {
+ r draw.Rectangle
+ do func(*state)
+}
+
+type state struct {
+ d *draw.Display
+ win *draw.Image
+ f *draw.Font
+ col map[string]*draw.Image
+ view view
+ cals []string // every calendar mounted under mtpt
+ on map[string]bool // the ones being shown
+ pick time.Time // the day whose list is in the panel; zero for none
+
+ // Text selection in the panel, by line. Character granularity would
+ // need the font metrics per glyph; whole lines are what you want to
+ // snarf out of a calendar anyway.
+ panelR draw.Rectangle
+ backAt draw.Rectangle
+ openAt draw.Rectangle
+ selLines []selLine
+ selA int // first selected line, -1 for none
+ selB int
+ at time.Time // the anchor day
+ top int // first visible minute, day/week views
+ evs map[string][]event
+ hits []hit
+ sel *event // the opened event, if any
+ mc *draw.Mousectl
+ quit bool
+}
+
+func now() time.Time { return time.Now() }
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ // -n: report what would be loaded and exit, so the calendar
+ // plumbing can be checked without a display
+ dry := false
+ want := vDay
+ args := os.Args[1:]
+ for len(args) > 0 {
+ switch {
+ case args[0] == "-n":
+ dry, args = true, args[1:]
+ case args[0] == "-v" && len(args) > 1:
+ switch args[1] {
+ case "day":
+ want = vDay
+ case "week":
+ want = vWeek
+ case "month":
+ want = vMonth
+ default:
+ return fmt.Errorf("usage: cal [-n] [-v day|week|month] [YYYY-MM-DD]")
+ }
+ args = args[2:]
+ default:
+ goto done
+ }
+ }
+done:
+
+ s := &state{view: want, at: time.Now(), top: 8 * 60, selA: -1, selB: -1}
+ s.cals = calendars()
+ s.on = map[string]bool{}
+ for _, c := range s.cals {
+ s.on[c] = true
+ }
+ if len(args) > 0 {
+ t, err := time.Parse("2006-01-02", args[0])
+ if err != nil {
+ return fmt.Errorf("usage: cal [-n] [-v day|week|month] [YYYY-MM-DD]")
+ }
+ s.at = t
+ }
+
+ if dry {
+ fmt.Printf("calendars: %v\n", s.cals)
+ for _, c := range s.cals {
+ evs, err := readDay(c, s.at)
+ fmt.Printf("%s: %d events on %s (err %v)\n",
+ c, len(evs), s.at.Format("2006-01-02"), err)
+ }
+ s.load()
+ k := s.at.Format("2006-01-02")
+ fmt.Printf("merged %s: %d\n", k, len(s.evs[k]))
+ for _, e := range s.evs[k] {
+ fmt.Printf(" %02d:%02d %-40s [%s]\n", e.min/60, e.min%60, e.title, e.cal)
+ }
+ return nil
+ }
+
+ var err error
+ if s.d, err = draw.Init("/dev"); err != nil {
+ return err
+ }
+ defer s.d.Close()
+ if s.f, err = s.d.OpenFont(fontpath); err != nil {
+ return err
+ }
+ s.col = map[string]*draw.Image{}
+ for k, v := range map[string]uint32{
+ "bg": 0xFFFFEAFF, // acme body
+ "tag": 0xEAFFFFFF, // acme tag
+ "rule": 0x99994CFF,
+ "ink": 0x000000FF,
+ "event": 0x8888CCFF, // DPurpleblue
+ "today": 0xFFFFAAFF, // DPaleyellow
+ "pick": 0xDDE4FFFF, // the anchor day, when it is not today
+ "snarf": 0xAAC4FFFF, // selected text, on its way to /dev/snarf
+ "now": 0xCC0000FF,
+ "border": 0x8888CCFF,
+ } {
+ if s.col[k], err = s.d.Color(v); err != nil {
+ return err
+ }
+ }
+ // a fill and an ink for every calendar, wrapping if there are more
+ // calendars than colours
+ for i := range s.cals {
+ p := calPalette[i%len(calPalette)]
+ if s.col[fmt.Sprintf("fill%d", i)], err = s.d.Color(p.fill); err != nil {
+ return err
+ }
+ if s.col[fmt.Sprintf("ink%d", i)], err = s.d.Color(p.ink); err != nil {
+ return err
+ }
+ }
+
+ if s.win, err = s.d.Window("/dev"); err != nil {
+ return err
+ }
+
+ s.load()
+ s.redraw()
+
+ mc, err := draw.OpenMouse("/dev")
+ if err != nil {
+ return err
+ }
+ defer mc.Close()
+ s.mc = mc
+
+ // Consume the keyboard. Without this rio keeps its line editor on the
+ // window and paints what you type over the drawing.
+ kb, err := draw.OpenKeyboard("/dev")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "cal: keyboard: %v\n", err)
+ } else {
+ defer kb.Close()
+ }
+ var keys <-chan rune
+ if kb != nil {
+ keys = kb.C
+ }
+
+ var wasDown, dragging bool
+ var pressed draw.Point
+ for {
+ select {
+ case m, ok := <-mc.C:
+ if !ok {
+ return nil
+ }
+ if m.Buttons&4 != 0 {
+ // Button 3 is the menu button on this system; Exit lives
+ // in there rather than owning the whole button.
+ s.menu(m.Point, mc)
+ if s.quit {
+ return nil
+ }
+ wasDown = false
+ continue
+ }
+ switch {
+ case m.Buttons&8 != 0: // wheel up
+ s.scroll(-30)
+ case m.Buttons&16 != 0: // wheel down
+ s.scroll(30)
+ }
+ down := m.Buttons&1 != 0
+ switch {
+ case down && !wasDown:
+ // A press inside the panel may be the start of a text
+ // selection, so it is not resolved until the release.
+ if i := s.lineAt(m.Point); i >= 0 && !s.inBack(m.Point) {
+ s.selA, s.selB = i, i
+ pressed = m.Point
+ dragging = true
+ s.redraw()
+ } else {
+ s.clearSel()
+ s.click(m.Point)
+ }
+ case down && dragging:
+ if i := s.lineAt(m.Point); i >= 0 && i != s.selB {
+ s.selB = i
+ s.redraw()
+ }
+ case !down && dragging:
+ dragging = false
+ if s.selA == s.selB {
+ // no drag: it was a click after all
+ s.clearSel()
+ s.click(pressed)
+ } else {
+ s.snarfSel()
+ }
+ }
+ wasDown = down
+ case r, ok := <-keys:
+ if !ok {
+ keys = nil
+ continue
+ }
+ switch r {
+ case 'q', 0x7F: // q or Del
+ return nil
+ case 't':
+ s.at = time.Now()
+ s.top = 8 * 60
+ s.load()
+ s.redraw()
+ case 'h':
+ s.step(-1)
+ case 'l':
+ s.step(1)
+ case 'd':
+ s.view = vDay
+ s.load()
+ s.redraw()
+ case 'w':
+ s.view = vWeek
+ s.load()
+ s.redraw()
+ case 'm':
+ s.view = vMonth
+ s.load()
+ s.redraw()
+ case 0x1B: // Esc clears whatever is showing
+ s.sel = nil
+ s.pick = time.Time{}
+ s.clearSel()
+ s.redraw()
+ }
+ case <-mc.Resize:
+ if s.win, err = s.d.Reattach("/dev", s.win); err != nil {
+ return err
+ }
+ s.redraw()
+ }
+ }
+}
+
+// step moves the anchor by one unit of the current view.
+func (s *state) step(n int) {
+ switch s.view {
+ case vDay:
+ s.at = s.at.AddDate(0, 0, n)
+ case vWeek:
+ s.at = s.at.AddDate(0, 0, 7*n)
+ case vMonth:
+ s.at = s.at.AddDate(0, n, 0)
+ }
+ s.load()
+ s.redraw()
+}
+
+// scroll rolls the time axis in day/week; in month it rolls whole weeks,
+// which is the only thing "up and down" can mean on a grid of days.
+func (s *state) scroll(mins int) {
+ if s.view == vMonth {
+ s.at = s.at.AddDate(0, 0, 7*sign(mins))
+ s.load()
+ s.redraw()
+ return
+ }
+ s.top += mins
+ if s.top < 0 {
+ s.top = 0
+ }
+ if s.top > 24*60-spanMin {
+ s.top = 24*60 - spanMin
+ }
+ s.redraw()
+}
+
+func sign(n int) int {
+ if n < 0 {
+ return -1
+ }
+ return 1
+}
+
+func (s *state) click(p draw.Point) {
+ for _, h := range s.hits {
+ if p.X >= h.r.Min.X && p.X < h.r.Max.X && p.Y >= h.r.Min.Y && p.Y < h.r.Max.Y {
+ h.do(s)
+ return
+ }
+ }
+}
+
+// ---------------------------------------------------------------- loading
+
+// days returns the days the current view covers.
+func (s *state) days() []time.Time {
+ switch s.view {
+ case vWeek:
+ start := s.at.AddDate(0, 0, -weekday(s.at))
+ out := make([]time.Time, 7)
+ for i := range out {
+ out[i] = start.AddDate(0, 0, i)
+ }
+ return out
+ case vMonth:
+ first := time.Date(s.at.Year(), s.at.Month(), 1, 0, 0, 0, 0, s.at.Location())
+ start := first.AddDate(0, 0, -weekday(first))
+ out := make([]time.Time, 42) // 6 weeks, the usual grid
+ for i := range out {
+ out[i] = start.AddDate(0, 0, i)
+ }
+ return out
+ }
+ return []time.Time{s.at}
+}
+
+func weekday(t time.Time) int { return int(t.Weekday()) }
+
+func (s *state) load() {
+ s.evs = map[string][]event{}
+ for _, d := range s.days() {
+ var all []event
+ for _, c := range s.cals {
+ if !s.on[c] {
+ continue
+ }
+ e, err := readDay(c, d)
+ if err != nil {
+ continue
+ }
+ all = append(all, e...)
+ }
+ if len(all) > 0 {
+ sort.Slice(all, func(i, j int) bool {
+ if all[i].min == all[j].min {
+ return all[i].title < all[j].title
+ }
+ return all[i].min < all[j].min
+ })
+ s.evs[d.Format("2006-01-02")] = all
+ }
+ }
+}
+
+// calendars names every live calendar. mntgen leaves the directory
+// behind when a server goes away, so a name only counts if something
+// still answers for ctl.
+func calendars() []string {
+ ents, err := os.ReadDir(path.Join(mtpt, "calendars"))
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range ents {
+ n := e.Name()
+ if _, err := os.Stat(path.Join(mtpt, "calendars", n, "ctl")); err == nil {
+ out = append(out, n)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func readDay(cal string, t time.Time) ([]event, error) {
+ dir := path.Join(mtpt, "calendars", cal, "events", "date",
+ t.Format("2006"), t.Format("01"), t.Format("02"))
+ f, err := os.Open(dir)
+ if err != nil {
+ return nil, nil // a day with nothing on it is not an error
+ }
+ defer f.Close()
+ names, err := f.Readdirnames(-1)
+ if err != nil {
+ return nil, err
+ }
+ var evs []event
+ for _, n := range names {
+ if len(n) < 5 || n[4] != '-' {
+ continue
+ }
+ hh, e1 := strconv.Atoi(n[0:2])
+ mm, e2 := strconv.Atoi(n[2:4])
+ if e1 != nil || e2 != nil {
+ continue
+ }
+ ev := event{
+ min: hh*60 + mm,
+ title: strings.ReplaceAll(n[5:], "-", " "),
+ file: path.Join(dir, n),
+ cal: cal,
+ }
+ if t := summary(ev.file); t != "" {
+ ev.title = t
+ }
+ evs = append(evs, ev)
+ }
+ sort.Slice(evs, func(i, j int) bool { return evs[i].min < evs[j].min })
+ return evs, nil
+}
+
+// summary reads only the header block, not the whole body.
+func summary(file string) string {
+ f, err := os.Open(file)
+ if err != nil {
+ return ""
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := sc.Text()
+ if line == "" {
+ break
+ }
+ if v, ok := strings.CutPrefix(line, "summary:"); ok {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+// ---------------------------------------------------------------- drawing
+
+// fit truncates s to w pixels.
+// calKey names the colour registered for a calendar, falling back to the
+// generic event colour for anything that appeared since startup.
+func (s *state) calKey(what, cal string) string {
+ for i, c := range s.cals {
+ if c == cal {
+ k := fmt.Sprintf("%s%d", what, i)
+ if _, ok := s.col[k]; ok {
+ return k
+ }
+ }
+ }
+ if what == "fill" {
+ return "event"
+ }
+ return "ink"
+}
+
+func (s *state) fit(str string, w int32) string {
+ if s.f.Width(str) <= w {
+ return str
+ }
+ r := []rune(str)
+ for len(r) > 1 {
+ r = r[:len(r)-1]
+ if s.f.Width(string(r)+"..") <= w {
+ return string(r) + ".."
+ }
+ }
+ return ""
+}
+
+func (s *state) text(p draw.Point, c string, str string) {
+ s.f.String(s.win, p, s.col[c], str)
+}
+
+func (s *state) fill(r draw.Rectangle, c string) {
+ draw.Draw(s.win, r, s.col[c], nil, draw.ZP)
+}
+
+// button draws a labelled box and records its hit region.
+func (s *state) button(r draw.Rectangle, label string, on bool, do func(*state)) {
+ bg := "tag"
+ if on {
+ bg = "today"
+ }
+ s.fill(r, bg)
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), "border")
+ s.fill(draw.Rect(r.Min.X, r.Max.Y-1, r.Max.X, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Min.X+1, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Max.X-1, r.Min.Y, r.Max.X, r.Max.Y), "border")
+ w := s.f.Width(label)
+ s.text(draw.Point{X: r.Min.X + (r.Dx()-w)/2, Y: r.Min.Y + 3}, "ink", label)
+ s.hits = append(s.hits, hit{r, do})
+}
+
+func (s *state) redraw() {
+ s.hits = s.hits[:0]
+ r := s.win.Rect()
+ s.fill(r, "bg")
+
+ if r.Dy() < compactH {
+ s.drawCompact(r)
+ s.d.Flush()
+ return
+ }
+
+ hdr := s.drawHeader(r)
+ body := draw.Rect(r.Min.X, hdr.Max.Y+pad, r.Max.X, r.Max.Y-pad)
+ if (s.sel != nil || (!s.pick.IsZero() && s.view != vDay)) && body.Dy() > 160 {
+ split := body.Max.Y - body.Dy()*2/5
+ s.drawDetail(draw.Rect(body.Min.X, split, body.Max.X, body.Max.Y))
+ body.Max.Y = split - pad
+ }
+ switch s.view {
+ case vDay:
+ s.drawTimeGrid(body, []time.Time{s.at})
+ case vWeek:
+ s.drawTimeGrid(body, s.days())
+ case vMonth:
+ s.drawMonth(body)
+ }
+ s.d.Flush()
+}
+
+func (s *state) drawHeader(r draw.Rectangle) draw.Rectangle {
+ h := int32(s.f.Height) + 8
+ hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+h)
+ s.fill(hdr, "tag")
+
+ // Buttons, right to left: Month Week Day > <
+ x := r.Max.X - pad
+ mk := func(label string, on bool, do func(*state)) {
+ w := s.f.Width(label) + 16
+ br := draw.Rect(x-w, hdr.Min.Y+2, x, hdr.Max.Y-2)
+ s.button(br, label, on, do)
+ x = br.Min.X - 4
+ }
+ for _, v := range []view{vMonth, vWeek, vDay} {
+ vv := v
+ mk(viewName[v], s.view == v, func(st *state) {
+ st.view = vv
+ if vv == vDay {
+ st.pick = time.Time{}
+ }
+ st.load()
+ st.redraw()
+ })
+ }
+ x -= 6
+ mk(">", false, func(st *state) { st.step(1) })
+ mk("<", false, func(st *state) { st.step(-1) })
+ x -= 6
+ mk("Today", false, func(st *state) {
+ st.at = time.Now()
+ st.top = 8 * 60
+ st.load()
+ st.redraw()
+ })
+
+ s.text(draw.Point{X: r.Min.X + pad, Y: hdr.Min.Y + 4}, "ink", s.fit(s.title(), x-r.Min.X-pad*2))
+ return hdr
+}
+
+func (s *state) title() string {
+ switch s.view {
+ case vWeek:
+ d := s.days()
+ return d[0].Format("2 Jan") + " - " + d[6].Format("2 Jan 2006")
+ case vMonth:
+ return s.at.Format("January 2006")
+ }
+ return s.at.Format("Monday 2 January 2006")
+}
+
+// drawTimeGrid renders one or more day columns against an hour axis.
+func (s *state) drawTimeGrid(r draw.Rectangle, days []time.Time) {
+ top, bot := r.Min.Y+int32(s.f.Height)+4, r.Max.Y
+ if bot <= top {
+ return
+ }
+ pxPerMin := float64(bot-top) / float64(spanMin)
+ yOf := func(m int) int32 { return top + int32(float64(m-s.top)*pxPerMin) }
+
+ // Hour rules across the whole body.
+ for m := (s.top/60 + 1) * 60; m < s.top+spanMin; m += 60 {
+ y := yOf(m)
+ s.fill(draw.Rect(r.Min.X+gutter, y, r.Max.X-pad, y+1), "rule")
+ s.text(draw.Point{X: r.Min.X + pad, Y: y - int32(s.f.Height)/2}, "ink",
+ fmt.Sprintf("%02d:00", m/60))
+ }
+
+ colw := (r.Dx() - gutter - pad) / int32(len(days))
+ today := time.Now().Format("2006-01-02")
+ for i, d := range days {
+ x0 := r.Min.X + gutter + int32(i)*colw
+ cr := draw.Rect(x0, top, x0+colw-2, bot)
+ key := d.Format("2006-01-02")
+
+ if len(days) > 1 {
+ lab := d.Format("Mon 2")
+ c := "ink"
+ hdr := draw.Rect(cr.Min.X, r.Min.Y, cr.Max.X, top-2)
+ switch {
+ case !s.pick.IsZero() && key == s.pick.Format("2006-01-02"):
+ s.fill(hdr, "pick")
+ case key == time.Now().Format("2006-01-02"):
+ s.fill(hdr, "today")
+ }
+ s.text(draw.Point{X: cr.Min.X + 4, Y: r.Min.Y}, c, s.fit(lab, colw-8))
+ dd := d
+ s.hits = append(s.hits, hit{hdr, func(st *state) { st.pickDay(dd) }})
+ }
+
+ for _, e := range s.evs[key] {
+ if e.min < s.top || e.min > s.top+spanMin {
+ continue
+ }
+ y := yOf(e.min)
+ box := draw.Rect(cr.Min.X+2, y+1, cr.Max.X, y+int32(s.f.Height)+6)
+ if box.Max.Y > bot {
+ continue
+ }
+ s.fill(box, s.calKey("fill", e.cal))
+ lab := fmt.Sprintf("%02d:%02d %s", e.min/60, e.min%60, e.title)
+ s.text(draw.Point{X: box.Min.X + 4, Y: box.Min.Y + 2}, "ink",
+ s.fit(lab, box.Dx()-8))
+ ev := e
+ s.hits = append(s.hits, hit{box, func(st *state) {
+ st.sel = &ev
+ st.redraw()
+ }})
+ }
+
+ // Now line.
+ if key == today {
+ m := time.Now().Hour()*60 + time.Now().Minute()
+ if m >= s.top && m <= s.top+spanMin {
+ y := yOf(m)
+ s.fill(draw.Rect(cr.Min.X, y, cr.Max.X, y+2), "now")
+ }
+ }
+ }
+}
+
+// drawMonth renders a 6x7 grid of days with as many titles as fit.
+func (s *state) drawMonth(r draw.Rectangle) {
+ days := s.days()
+ cw := r.Dx() / 7
+ ch := r.Dy() / 6
+ if cw < 20 || ch < 20 {
+ return
+ }
+ today := time.Now().Format("2006-01-02")
+ picked := ""
+ if !s.pick.IsZero() {
+ picked = s.pick.Format("2006-01-02")
+ }
+ lh := int32(s.f.Height) + 1
+ // A day cell covers its events, and click takes the first hit, so
+ // these are appended only once every event hit is already down.
+ var dayHits []hit
+ for i, d := range days {
+ cx := r.Min.X + int32(i%7)*cw
+ cy := r.Min.Y + int32(i/7)*ch
+ cell := draw.Rect(cx, cy, cx+cw-2, cy+ch-2)
+ key := d.Format("2006-01-02")
+
+ switch {
+ case key == picked:
+ s.fill(cell, "pick")
+ case key == today:
+ s.fill(cell, "today")
+ }
+ if d.Month() != s.at.Month() {
+ // Outside the anchor month: leave it on the background.
+ s.fill(draw.Rect(cell.Min.X, cell.Min.Y, cell.Max.X, cell.Min.Y+1), "rule")
+ } else {
+ s.fill(draw.Rect(cell.Min.X, cell.Min.Y, cell.Max.X, cell.Min.Y+1), "rule")
+ }
+ s.text(draw.Point{X: cell.Min.X + 3, Y: cell.Min.Y + 2}, "ink", d.Format("2"))
+
+ y := cell.Min.Y + 2 + lh
+ for _, e := range s.evs[key] {
+ if y+lh > cell.Max.Y {
+ break
+ }
+ lab := fmt.Sprintf("%02d:%02d %s", e.min/60, e.min%60, e.title)
+ s.text(draw.Point{X: cell.Min.X + 3, Y: y}, s.calKey("ink", e.cal),
+ s.fit(lab, cell.Dx()-6))
+ ev := e
+ s.hits = append(s.hits, hit{
+ draw.Rect(cell.Min.X, y, cell.Max.X, y+lh),
+ func(st *state) { st.sel = &ev; st.redraw() },
+ })
+ y += lh
+ }
+
+ dd := d
+ dayHits = append(dayHits, hit{cell, func(st *state) { st.pickDay(dd) }})
+ }
+ s.hits = append(s.hits, dayHits...)
+}
+
+func min(a, b int) int { if a < b { return a }; return b }
+func max(a, b int) int { if a > b { return a }; return b }
+
+func (s *state) clearSel() { s.selA, s.selB = -1, -1 }
+
+// openCal starts another cal in its own window. A second view is a
+// second process here, not a second window inside this one.
+func (s *state) openCal(v view, d time.Time) {
+ s.wctl(fmt.Sprintf("new -dx 820 -dy 620 cal9 -v %s %s",
+ strings.ToLower(viewName[v]), d.Format("2006-01-02")))
+}
+
+// openEvent pins one event in a window of its own.
+func (s *state) openEvent(e *event) {
+ s.wctl(fmt.Sprintf("new -dx 540 -dy 360 pim/showwin %s", e.file))
+}
+
+// inBack reports whether a point is on the panel's Back button, which
+// must act as a button rather than start a selection.
+func (s *state) inBack(p draw.Point) bool {
+ for _, r := range []draw.Rectangle{s.backAt, s.openAt} {
+ if p.X >= r.Min.X && p.X < r.Max.X && p.Y >= r.Min.Y && p.Y < r.Max.Y {
+ return true
+ }
+ }
+ return false
+}
+
+// lineAt finds the panel line under a point, -1 if there is none.
+func (s *state) lineAt(p draw.Point) int {
+ for i, l := range s.selLines {
+ if p.X >= l.r.Min.X && p.X < l.r.Max.X && p.Y >= l.r.Min.Y && p.Y < l.r.Max.Y {
+ return i
+ }
+ }
+ return -1
+}
+
+// snarfSel puts the selected lines on /dev/snarf.
+func (s *state) snarfSel() {
+ if s.selA < 0 {
+ return
+ }
+ var b strings.Builder
+ for i := min(s.selA, s.selB); i <= max(s.selA, s.selB) && i < len(s.selLines); i++ {
+ b.WriteString(strings.TrimRight(s.selLines[i].text, " "))
+ b.WriteByte('\n')
+ }
+ if err := draw.Snarf("/dev", b.String()); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: snarf: %v\n", err)
+ }
+}
+
+// pickDay puts a whole day in the panel below. A month cell only has
+// room for the first few events, and this is how you see the rest without
+// losing the month.
+func (s *state) pickDay(d time.Time) {
+ s.pick = d
+ s.sel = nil
+ s.redraw()
+}
+
+// drawCompact is what you get by making the window small: the next thing
+// due. No minimise button, because rio has no iconify to hook one to.
+func (s *state) drawCompact(r draw.Rectangle) {
+ s.fill(r, "tag")
+ now := time.Now()
+ key := now.Format("2006-01-02")
+ cur := now.Hour()*60 + now.Minute()
+ var next *event
+ for i, e := range s.evs[key] {
+ if e.min >= cur {
+ next = &s.evs[key][i]
+ break
+ }
+ }
+ y := r.Min.Y + 2
+ if next == nil {
+ s.text(draw.Point{X: r.Min.X + pad, Y: y}, "ink",
+ s.fit("nothing else today", r.Dx()-pad*2))
+ return
+ }
+ in := next.min - cur
+ s.text(draw.Point{X: r.Min.X + pad, Y: y}, "ink",
+ s.fit(fmt.Sprintf("%02d:%02d %s", next.min/60, next.min%60, next.title), r.Dx()-pad*2))
+ s.text(draw.Point{X: r.Min.X + pad, Y: y + int32(s.f.Height) + 2}, "ink",
+ s.fit(fmt.Sprintf("in %d min", in), r.Dx()-pad*2))
+}
+
+// drawDetail shows the opened event. The date tree carries only the basics;
+// everything else -- attendees, organizer, location, the full description --
+// lives in the per-event directory in the uuid tree, which the "event:"
+// header points at relative to the day directory.
+func (s *state) drawDetail(r draw.Rectangle) {
+ s.fill(r, "tag")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), "border")
+ s.panelR = r
+ s.selLines = s.selLines[:0]
+
+ y := r.Min.Y + 4
+ lh := int32(s.f.Height) + 2
+ line := func(c, str string) {
+ if y+lh > r.Max.Y {
+ return
+ }
+ lr := draw.Rect(r.Min.X, y, r.Max.X, y+lh)
+ i := len(s.selLines)
+ if s.selA >= 0 && i >= min(s.selA, s.selB) && i <= max(s.selA, s.selB) {
+ s.fill(lr, "snarf")
+ }
+ s.selLines = append(s.selLines, selLine{lr, str})
+ s.text(draw.Point{X: r.Min.X + pad, Y: y}, c, s.fit(str, r.Dx()-pad*2))
+ y += lh
+ }
+
+ // A real button, like the ones in the header, rather than a line of
+ // text that happens to be clickable.
+ // Back, and Open beside it. Open means "put this in a window of its
+ // own and leave it there".
+ bar := func(back func(*state), open func(*state)) {
+ x := r.Min.X + pad
+ mk := func(label string, do func(*state)) draw.Rectangle {
+ bw := s.f.Width(label) + 20
+ br := draw.Rect(x, y, x+bw, y+lh+2)
+ s.button(br, label, false, do)
+ x = br.Max.X + 6
+ return br
+ }
+ s.backAt = mk("Back", back)
+ s.openAt = mk("Open", open)
+ y = s.openAt.Max.Y + 4
+ }
+
+ // A day was clicked and no single event chosen: list the day.
+ if s.sel == nil {
+ key := s.pick.Format("2006-01-02")
+ evs := s.evs[key]
+ // Back from a day means no panel at all; the month is behind it.
+ d := s.pick
+ bar(func(st *state) { st.pick = time.Time{}; st.clearSel(); st.redraw() },
+ func(st *state) { st.openCal(vDay, d) })
+ line("ink", s.pick.Format("Monday 2 January 2006")+
+ fmt.Sprintf(" (%d)", len(evs)))
+ y += 3
+ for _, e := range evs {
+ ev := e
+ top := y
+ lab := fmt.Sprintf("%02d:%02d %s", e.min/60, e.min%60, e.title)
+ if e.min == 0 && strings.HasPrefix(path.Base(e.file), "0000-allday-") {
+ lab = "all day " + e.title
+ }
+ line(s.calKey("ink", e.cal), lab)
+ if y > top {
+ s.hits = append(s.hits, hit{
+ draw.Rect(r.Min.X, top, r.Max.X, y),
+ func(st *state) { st.sel = &ev; st.redraw() },
+ })
+ }
+ }
+ if len(evs) == 0 {
+ line("ink", "nothing")
+ }
+ return
+ }
+
+ e := s.sel
+ hdrs, body := readEvent(e.file)
+
+ // A way back up. Coming from a month cell there is otherwise nothing
+ // to click but the menu, and no sign of which day this was.
+ day, haveDay := dayOf(hdrs["start"])
+ if haveDay && s.view != vDay {
+ d := day
+ ev := e
+ bar(func(st *state) { st.pick = d; st.sel = nil; st.clearSel(); st.redraw() },
+ func(st *state) { st.openEvent(ev) })
+ line("ink", d.Format("Monday 2 January 2006"))
+ y += 3
+ } else {
+ // In day view the grid behind is already the whole day, so
+ // there is nothing to go back to but the grid itself.
+ ev := e
+ bar(func(st *state) {
+ st.sel = nil
+ st.pick = time.Time{}
+ st.clearSel()
+ st.redraw()
+ }, func(st *state) { st.openEvent(ev) })
+ if haveDay {
+ line("ink", day.Format("Monday 2 January 2006"))
+ y += 3
+ }
+ }
+
+ line("ink", hdrs["summary"])
+ if st, en := hdrs["start"], hdrs["end"]; st != "" {
+ when := clock(st) + " - " + clock(en)
+ if !haveDay {
+ when = st
+ }
+ if hdrs["rrule"] != "" {
+ when += " (repeats)"
+ }
+ line("ink", when)
+ }
+
+ det := uuidDir(e.file, hdrs["event"])
+ if loc := strings.TrimSpace(field(det, "location")); loc != "" {
+ line("ink", "at "+loc)
+ }
+ if org := strings.TrimSpace(field(det, "organizer")); org != "" {
+ line("ink", "organizer: "+org)
+ }
+ if att := attendees(det); len(att) > 0 {
+ y += 3
+ line("ink", fmt.Sprintf("%d attendees", len(att)))
+ for _, a := range att {
+ line("ink", " "+a)
+ }
+ }
+
+ if strings.TrimSpace(body) == "" {
+ body = field(det, "description")
+ }
+ if strings.TrimSpace(body) != "" {
+ y += 3
+ for _, w := range wrap(s, body, r.Dx()-pad*2) {
+ line("ink", w)
+ }
+ }
+}
+
+// uuidDir resolves the "event:" cross-link, which is relative to the day
+// directory the event file sits in.
+func uuidDir(eventFile, link string) string {
+ if link == "" {
+ return ""
+ }
+ return path.Clean(path.Join(path.Dir(eventFile), link))
+}
+
+// field reads one file out of the event's uuid directory.
+func field(dir, name string) string {
+ if dir == "" {
+ return ""
+ }
+ b, err := os.ReadFile(path.Join(dir, name))
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+// attendees renders the tab-separated STATUS/name/email rows compactly, with
+// the status as a leading mark so a long list still scans.
+func attendees(dir string) []string {
+ raw := field(dir, "attendees")
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ mark := map[string]string{
+ "ACCEPTED": "+",
+ "DECLINED": "-",
+ "TENTATIVE": "~",
+ "NEEDS-ACTION": "?",
+ }
+ var out []string
+ for _, ln := range strings.Split(strings.TrimRight(raw, "\n"), "\n") {
+ f := strings.Split(ln, "\t")
+ if len(f) == 0 || strings.TrimSpace(ln) == "" {
+ continue
+ }
+ st := strings.TrimSpace(f[0])
+ m, ok := mark[st]
+ if !ok {
+ m = "."
+ }
+ who := st
+ if len(f) > 1 && strings.TrimSpace(f[1]) != "" {
+ who = strings.TrimSpace(f[1])
+ } else if len(f) > 2 {
+ who = strings.TrimSpace(f[2])
+ }
+ out = append(out, m+" "+who)
+ }
+ return out
+}
+
+// clock pulls HH:MM out of an RFC3339 timestamp without parsing it; the
+// filesystem already guarantees the shape.
+// dayOf returns the date part of a start header as a time, so the event
+// panel can say which day it is on and offer a way back to that day.
+func dayOf(ts string) (time.Time, bool) {
+ if len(ts) < 10 {
+ return time.Time{}, false
+ }
+ t, err := time.ParseInLocation("2006-01-02", ts[:10], time.Local)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return t, true
+}
+
+func clock(ts string) string {
+ if i := strings.IndexByte(ts, 'T'); i >= 0 && len(ts) >= i+6 {
+ return ts[i+1 : i+6]
+ }
+ return ts
+}
+
+// readEvent splits the header block from the body.
+func readEvent(file string) (map[string]string, string) {
+ h := map[string]string{}
+ b, err := os.ReadFile(file)
+ if err != nil {
+ return h, ""
+ }
+ txt := string(b)
+ i := strings.Index(txt, "\n\n")
+ head, body := txt, ""
+ if i >= 0 {
+ head, body = txt[:i], txt[i+2:]
+ }
+ for _, ln := range strings.Split(head, "\n") {
+ if k, v, ok := strings.Cut(ln, ":"); ok {
+ h[strings.TrimSpace(k)] = strings.TrimSpace(v)
+ }
+ }
+ return h, body
+}
+
+// wrap breaks text to fit w pixels, keeping existing line breaks.
+func wrap(s *state, text string, w int32) []string {
+ var out []string
+ for _, para := range strings.Split(text, "\n") {
+ if strings.TrimSpace(para) == "" {
+ out = append(out, "")
+ continue
+ }
+ cur := ""
+ for _, word := range strings.Fields(para) {
+ try := word
+ if cur != "" {
+ try = cur + " " + word
+ }
+ if s.f.Width(try) <= w {
+ cur = try
+ continue
+ }
+ if cur != "" {
+ out = append(out, cur)
+ }
+ cur = word
+ }
+ if cur != "" {
+ out = append(out, cur)
+ }
+ }
+ return out
+}
diff --git a/pim/cmd/cal9/menu.go b/pim/cmd/cal9/menu.go
new file mode 100644
index 0000000..4443b00
--- /dev/null
+++ b/pim/cmd/cal9/menu.go
@@ -0,0 +1,267 @@
+package main
+
+import (
+ "fmt"
+ "time"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "9front/gui/draw"
+)
+
+// A button-3 menu, the way page and vdir do it: press 3, drag, release on an
+// item. acme's plumb-on-3 is the outlier on this system, so plumbing lives
+// in here as an item rather than owning the button.
+type menuItem struct {
+ label string
+ do func(*state)
+}
+
+func (s *state) menu(at draw.Point, mc *draw.Mousectl) {
+ items := s.menuItems()
+ if len(items) == 0 {
+ return
+ }
+
+ lh := int32(s.f.Height) + 4
+ var w int32
+ for _, it := range items {
+ if x := s.f.Width(it.label) + 20; x > w {
+ w = x
+ }
+ }
+ h := lh*int32(len(items)) + 4
+ win := s.win.Rect()
+ r := draw.Rect(at.X, at.Y, at.X+w, at.Y+h)
+ // Keep it on screen.
+ if r.Max.X > win.Max.X {
+ r = r.Add(draw.Point{X: win.Max.X - r.Max.X})
+ }
+ if r.Max.Y > win.Max.Y {
+ r = r.Add(draw.Point{Y: win.Max.Y - r.Max.Y})
+ }
+
+ sel := -1
+ paint := func() {
+ s.fill(r, "tag")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+1), "border")
+ s.fill(draw.Rect(r.Min.X, r.Max.Y-1, r.Max.X, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Min.X, r.Min.Y, r.Min.X+1, r.Max.Y), "border")
+ s.fill(draw.Rect(r.Max.X-1, r.Min.Y, r.Max.X, r.Max.Y), "border")
+ for i, it := range items {
+ ir := draw.Rect(r.Min.X+1, r.Min.Y+2+int32(i)*lh, r.Max.X-1, r.Min.Y+2+int32(i+1)*lh)
+ if i == sel {
+ s.fill(ir, "today")
+ }
+ s.text(draw.Point{X: ir.Min.X + 8, Y: ir.Min.Y + 2}, "ink", it.label)
+ }
+ s.d.Flush()
+ }
+ itemAt := func(p draw.Point) int {
+ if p.X < r.Min.X || p.X >= r.Max.X {
+ return -1
+ }
+ i := int((p.Y - r.Min.Y - 2) / lh)
+ if i < 0 || i >= len(items) || p.Y < r.Min.Y+2 {
+ return -1
+ }
+ return i
+ }
+ paint()
+
+ // Track until button 3 comes back up.
+ for m := range mc.C {
+ if n := itemAt(m.Point); n != sel {
+ sel = n
+ paint()
+ }
+ if m.Buttons&4 == 0 {
+ if sel >= 0 {
+ items[sel].do(s)
+ } else {
+ s.redraw()
+ }
+ return
+ }
+ }
+}
+
+func (s *state) menuItems() []menuItem {
+ items := []menuItem{}
+ if s.sel != nil {
+ items = append(items, menuItem{"Plumb", func(st *state) {
+ if err := st.plumb(st.sel); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: plumb: %v\n", err)
+ }
+ st.redraw()
+ }})
+ items = append(items, menuItem{"Open event", func(st *state) {
+ st.openEvent(st.sel)
+ }})
+ items = append(items, menuItem{"Close event", func(st *state) {
+ st.sel = nil
+ st.redraw()
+ }})
+ }
+ if s.sel == nil && !s.pick.IsZero() {
+ items = append(items, menuItem{"Close day", func(st *state) {
+ st.pick = time.Time{}
+ st.redraw()
+ }})
+ }
+ // one toggle per calendar, so a crowded work calendar can be put
+ // aside without unmounting anything
+ for _, c := range s.cals {
+ c := c
+ mark := "[ ] "
+ if s.on[c] {
+ mark = "[x] "
+ }
+ items = append(items, menuItem{mark + c, func(st *state) {
+ st.on[c] = !st.on[c]
+ st.load()
+ st.redraw()
+ }})
+ }
+ // A second view of the calendar is a second cal, in its own window.
+ items = append(items, menuItem{"Open " + strings.ToLower(viewName[s.view]),
+ func(st *state) { st.openCal(st.view, st.at) }})
+ if !s.pick.IsZero() && s.view != vDay {
+ d := s.pick
+ items = append(items, menuItem{"Open " + d.Format("2 Jan"),
+ func(st *state) { st.openCal(vDay, d) }})
+ }
+ items = append(items,
+ menuItem{"Today", func(st *state) {
+ st.at = now()
+ st.top = 8 * 60
+ st.load()
+ st.redraw()
+ }},
+ // rio has no iconify, so "compact" is a resize request: shrink the
+ // window and the small-window view takes over by itself.
+ menuItem{"Compact", func(st *state) {
+ r := st.win.Rect()
+ st.wctl(fmt.Sprintf("resize -r %d %d %d %d",
+ r.Min.X, r.Min.Y, r.Min.X+320, r.Min.Y+72))
+ }},
+ menuItem{"Restore", func(st *state) {
+ r := st.win.Rect()
+ st.wctl(fmt.Sprintf("resize -r %d %d %d %d",
+ r.Min.X, r.Min.Y, r.Min.X+820, r.Min.Y+620))
+ }},
+ menuItem{"Move", func(st *state) { st.track(st.mc, false) }},
+ menuItem{"Resize", func(st *state) { st.track(st.mc, true) }},
+ menuItem{"Hide", func(st *state) { st.wctl("hide") }},
+ menuItem{"Exit", func(st *state) { st.quit = true }},
+ )
+ return items
+}
+
+// wctl asks rio to do something to our window. Errors are worth showing:
+// outside rio there is no wctl and the menu items simply do nothing.
+func (s *state) wctl(cmd string) {
+ f, err := os.OpenFile("/dev/wctl", os.O_WRONLY, 0)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "cal: wctl: %v\n", err)
+ return
+ }
+ defer f.Close()
+ if _, err := f.WriteString(cmd); err != nil {
+ fmt.Fprintf(os.Stderr, "cal: wctl %q: %v\n", cmd, err)
+ }
+}
+
+var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
+
+// plumb sends the event's join link to the plumber, falling back to the
+// event file itself, which lands in acme.
+func (s *state) plumb(e *event) error {
+ data := e.file
+ if body, err := os.ReadFile(e.file); err == nil {
+ if m := urlRe.Find(body); m != nil {
+ data = strings.TrimRight(string(m), ".,)")
+ }
+ }
+ return exec.Command("/bin/plumb", data).Run()
+}
+
+// track implements Move and Resize ourselves. rio's wctl has no "let the
+// user sweep" verb -- only move/resize with an explicit rectangle -- so we
+// follow the pointer and write a new rect as it goes, and rio does the
+// actual work. Any button press drops the window where it is.
+//
+// rio hands out a new image whenever the window's screen rect changes, so
+// this loop has to service resize events as well as motion, or every write
+// would be drawing into a stale image.
+func (s *state) track(mc *draw.Mousectl, resize bool) {
+ r := s.win.Rect()
+ w, h := r.Dx(), r.Dy()
+ grab := draw.Point{} // pointer offset within the window, for Move
+ first := true
+ last := r
+
+ for {
+ select {
+ case m, ok := <-mc.C:
+ if !ok {
+ return
+ }
+ if first {
+ grab = draw.Point{X: m.X - r.Min.X, Y: m.Y - r.Min.Y}
+ first = false
+ continue
+ }
+ if m.Buttons != 0 { // any press drops it
+ s.redraw()
+ return
+ }
+ var nr draw.Rectangle
+ if resize {
+ nr = draw.Rect(r.Min.X, r.Min.Y, m.X, m.Y)
+ if nr.Dx() < 120 {
+ nr.Max.X = nr.Min.X + 120
+ }
+ if nr.Dy() < 60 {
+ nr.Max.Y = nr.Min.Y + 60
+ }
+ } else {
+ min := draw.Point{X: m.X - grab.X, Y: m.Y - grab.Y}
+ nr = draw.Rect(min.X, min.Y, min.X+w, min.Y+h)
+ }
+ // Only bother rio when it would actually change something.
+ if abs(nr.Min.X-last.Min.X)+abs(nr.Min.Y-last.Min.Y)+
+ abs(nr.Max.X-last.Max.X)+abs(nr.Max.Y-last.Max.Y) < 4 {
+ continue
+ }
+ last = nr
+ verb := "move"
+ if resize {
+ verb = "resize"
+ }
+ s.wctl(fmt.Sprintf("%s -r %d %d %d %d", verb,
+ nr.Min.X, nr.Min.Y, nr.Max.X, nr.Max.Y))
+
+ case <-mc.Resize:
+ win, err := s.d.Reattach("/dev", s.win)
+ if err != nil {
+ return
+ }
+ s.win = win
+ r = s.win.Rect()
+ if !resize {
+ w, h = r.Dx(), r.Dy()
+ }
+ s.redraw()
+ }
+ }
+}
+
+func abs(n int32) int32 {
+ if n < 0 {
+ return -n
+ }
+ return n
+}