From c16c778c60deaafa0c9e5bbc1db9a10f64fec9b2 Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Sat, 22 Aug 2026 19:12:15 -0400 Subject: pim: a window for each kind of thing, and a picker for dates 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 --- pim/cmd/cal9/event.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 pim/cmd/cal9/event.go (limited to 'pim/cmd/cal9/event.go') diff --git a/pim/cmd/cal9/event.go b/pim/cmd/cal9/event.go new file mode 100644 index 0000000..6d23b11 --- /dev/null +++ b/pim/cmd/cal9/event.go @@ -0,0 +1,164 @@ +package main + +// The event view: one event, in a window of its own. +// +// Spawned by clicking an event in any grid, or from the command line: +// +// cal9 -v event /mnt/pim/calendars/work/events/date/2026/08/24/1000-API-WG +// +// It exists rather than a panel because a window is a thing you can +// keep, put beside the calendar, and close when you are done -- and +// because it can carry buttons the grid has no room for. Answering an +// invitation is the whole reason: pim/showwin can print an event, but +// it cannot offer you Accept. + +import ( + "fmt" + "os" + "path" + "strings" + "time" + + "9front/gui/draw" + "9front/gui/ui" +) + +// drawEvent fills the window with one event. +func (s *state) drawEvent(r draw.Rectangle) { + s.Fill(r, "bg") + hdrs, body := readEvent(s.eventPath) + det := uuidDir(s.eventPath, hdrs["event"]) + + y := r.Min.Y + pad + 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 + } + s.selLines = s.selLines[:0] + + // what it is + line("ink", hdrs["summary"]) + if d, ok := dayOf(hdrs["start"]); ok { + line("ink", d.Format("Monday 2 January 2006")+" "+ + clock(hdrs["start"])+" - "+clock(hdrs["end"])) + } + if hdrs["rrule"] != "" { + line("ink", "repeats "+hdrs["rrule"]) + } + if loc := strings.TrimSpace(field(det, "location")); loc != "" { + line("ink", "at "+loc) + } + if org := strings.TrimSpace(field(det, "organizer")); org != "" { + line("ink", "from "+org) + } + y += 4 + + // who, and where you stand + st := strings.TrimSpace(field(det, "partstat")) + if att := attendees(det); len(att) > 0 { + line("ink", fmt.Sprintf("%d attendees", len(att))) + for _, a := range att { + line("ink", " "+a) + } + y += 4 + } + if st != "" { + line("ink", "you: "+st) + } + + // and what you can do about it + y += 4 + if s.canRSVP() { + x := r.Min.X + pad + for _, w := range []string{"ACCEPTED", "TENTATIVE", "DECLINED"} { + want := w + label := strings.Title(strings.ToLower(w)) + bw := s.F.Width(label) + 20 + br := draw.Rect(x, y, x+bw, y+lh+4) + s.Button(br, label, st == want, func() { s.rsvp(want) }) + x = br.Max.X + 6 + } + y += lh + 10 + } else if st != "" { + line("rule", "this calendar cannot answer invitations") + } + + if b := strings.TrimSpace(body); b != "" { + y += 4 + for _, l := range s.Wrap(b, r.Dx()-pad*2) { + line("ink", l) + } + } + s.D.Flush() +} + +// canRSVP asks the calendar, rather than guessing from the event. +func (s *state) canRSVP() bool { + c := calRoot(s.eventPath) + if c == "" { + return false + } + b, err := os.ReadFile(path.Join(c, "ctl")) + if err != nil { + return false + } + for _, l := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(l, "caps "); ok { + for _, f := range strings.Fields(v) { + if f == "rsvp" { + return true + } + } + } + } + return false +} + +// rsvp answers, by writing the file. Whether that means a PUT, a piece +// of mail or a refusal is the backend's business. +func (s *state) rsvp(want string) { + hdrs, _ := readEvent(s.eventPath) + det := uuidDir(s.eventPath, hdrs["event"]) + if det == "" { + return + } + f, err := os.OpenFile(path.Join(det, "partstat"), os.O_WRONLY, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "cal: rsvp: %v\n", err) + return + } + _, err = f.WriteString(want) + f.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "cal: rsvp: %v\n", err) + } + s.redraw() +} + +// calRoot walks back to /mnt/pim/calendars/ from a path inside it. +func calRoot(p string) string { + for d := p; d != "/" && d != "."; d = path.Dir(d) { + if path.Base(path.Dir(d)) == "calendars" { + return d + } + } + return "" +} + +// openEventWindow puts an event in a window of its own. +func (s *state) openEventWindow(e *event) { + s.New(560, 420, "cal9", "-v", "event", e.file) +} + +var _ = ui.Hit{} +var _ = time.Time{} -- cgit v1.2.3