// 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" "9front/gui/ui" ) 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 vEvent vInvite ) var viewName = map[view]string{vDay: "Day", vWeek: "Week", vMonth: "Month", vEvent: "Event", vInvite: "Invite"} // 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. // One colour per calendar, plus a pale version of each. An event you // have not answered is drawn pale, so a glance tells you what is still // waiting without opening anything. var calPalette = []struct{ fill, pale, ink uint32 }{ {0x8888CCFF, 0xD8D8F0FF, 0x333388FF}, // purpleblue, the original {0x88CC88FF, 0xD8F0D8FF, 0x226622FF}, // green {0xE0B080FF, 0xF4E2CCFF, 0x805000FF}, // tan {0xCC8888FF, 0xF0D8D8FF, 0x883333FF}, // red {0x88CCCCFF, 0xD8F0F0FF, 0x226666FF}, // cyan {0xCCCC88FF, 0xF0F0D8FF, 0x666622FF}, // olive } type event struct { min int // minutes past midnight title string file string cal string // which calendar it came from pending bool // you have not answered it yet } // selLine is one line of panel text, recorded so it can be selected. type selLine struct { r draw.Rectangle text string } type state struct { ui.UI view view cals []string // every calendar mounted under mtpt on map[string]bool // the ones being shown // 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 eventPath string // -v event: the occurrence this window is showing form *form // -v invite: the composer picked chan string // a date coming back from datepick 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 case "event": want = vEvent case "invite": want = vInvite default: return fmt.Errorf("usage: cal [-n] [-v day|week|month|event] [YYYY-MM-DD|path]") } args = args[2:] default: goto done } } done: s := &state{view: want, at: time.Now(), top: 8 * 60, selA: -1, selB: -1} s.picked = make(chan string, 1) s.cals = calendars() s.on = map[string]bool{} for _, c := range s.cals { s.on[c] = true } if want == vInvite { w := writable() if len(args) > 0 { s.form = newForm(args[0]) } else if len(w) == 1 { s.form = newForm(w[0]) } else if len(w) == 0 { return fmt.Errorf("cal: no calendar here can create events") } else { return fmt.Errorf("cal: which calendar? one of: %s", strings.Join(w, " ")) } } else if want == vEvent { if len(args) == 0 { return fmt.Errorf("usage: cal -v event ") } s.eventPath = args[0] } else 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|event] [YYYY-MM-DD|path]") } 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("pale%d", i)], err = s.D.Color(p.pale); 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(s.menuItems(), m.Point, mc, s.redraw) 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 } // In a form the keys are the point; the view shortcuts // would eat every letter you tried to type. if s.view == vInvite && s.form != nil { f := s.form switch r { case '\t', '\n', '\r': f.focus = (f.focus + 1) % len(f.fields) s.redraw() case 0x1B: // Esc gives up on the window return nil default: if f.fields[f.focus].Key(r) { s.redraw() } } 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.clearSel() s.redraw() } case v := <-s.picked: if s.form != nil && v != "" { for _, fl := range s.form.fields { if fl.Label == "when" { fl.Value = v } } 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 } // ---------------------------------------------------------------- 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() { if s.view == vEvent || s.view == vInvite { return // these views read what they need themselves } 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 } ev.pending = needsReply(ev.file) 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) redraw() { s.Reset() r := s.Body() s.Fill(r, "bg") if r.Dy() < compactH { s.drawCompact(r) s.D.Flush() return } if s.view == vEvent { s.drawEvent(r) return } if s.view == vInvite { s.drawInvite(r) return } hdr := s.drawHeader(r) body := draw.Rect(r.Min.X, hdr.Max.Y+pad, r.Max.X, r.Max.Y-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()) { 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() { s.view = vv if vv == vDay { } s.load() s.redraw() }) } x -= 6 mk(">", false, func() { s.step(1) }) mk("<", false, func() { s.step(-1) }) x -= 6 mk("Today", false, func() { s.at = time.Now() s.top = 8 * 60 s.load() s.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 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.On(hdr, func() { s.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 } kind := "fill" if e.pending { kind = "pale" // unanswered: lighter than accepted } s.Fill(box, s.calKey(kind, 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.On(box, func() { s.openEventWindow(&ev) }) } // 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") 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 []ui.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 == 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.On(draw.Rect(cell.Min.X, y, cell.Max.X, y+lh), func() { s.openEventWindow(&ev) }) y += lh } dd := d dayHits = append(dayHits, ui.Hit{R: cell, Do: func() { s.pickDay(dd) }}) } for _, h := range dayHits { s.On(h.R, h.Do) } } 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 opens a day in a window of its own. A month cell has room for // the first few events; this is how you see the rest, and the month // stays where it was. func (s *state) pickDay(d time.Time) { s.New(820, 620, "cal9", "-v", "day", d.Format("2006-01-02")) } // 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)) } // 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 } // needsReply says whether this event is still waiting on you. The // answer lives with the event, not the occurrence, so it costs one // more read per event -- a few dozen for a week, which is cheap enough // to be worth seeing at a glance. func needsReply(occ string) bool { hdrs, _ := readEvent(occ) det := uuidDir(occ, hdrs["event"]) if det == "" { return false } return strings.TrimSpace(field(det, "partstat")) == "NEEDS-ACTION" }