summaryrefslogtreecommitdiff
path: root/pim/cmd/cal9/menu.go
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/menu.go
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/menu.go')
-rw-r--r--pim/cmd/cal9/menu.go267
1 files changed, 267 insertions, 0 deletions
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
+}