// 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 }