diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-08-22 19:12:15 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-08-22 19:12:15 -0400 |
| commit | c16c778c60deaafa0c9e5bbc1db9a10f64fec9b2 (patch) | |
| tree | aced25a7611486d61f20094728b41fe322ad6feb /pim/cmd/cal9/event.go | |
| parent | 77c7bc9b187450818ec068239a3ae836d01fdebc (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'pim/cmd/cal9/event.go')
| -rw-r--r-- | pim/cmd/cal9/event.go | 164 |
1 files changed, 164 insertions, 0 deletions
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/<name> 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{} |
