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/main.go | 531 ++++++++++++++++++++------------------------------- 1 file changed, 204 insertions(+), 327 deletions(-) (limited to 'pim/cmd/cal9/main.go') diff --git a/pim/cmd/cal9/main.go b/pim/cmd/cal9/main.go index aa9a370..26f7de0 100644 --- a/pim/cmd/cal9/main.go +++ b/pim/cmd/cal9/main.go @@ -16,6 +16,7 @@ import ( "time" "9front/gui/draw" + "9front/gui/ui" ) const ( @@ -34,27 +35,33 @@ const ( vDay view = iota vWeek vMonth + vEvent + vInvite ) -var viewName = map[view]string{vDay: "Day", vWeek: "Week", vMonth: "Month"} +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. -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 +// 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 + 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. @@ -63,38 +70,29 @@ type selLine struct { 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 + ui.UI 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 + 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() } @@ -124,8 +122,12 @@ func run() error { 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] [YYYY-MM-DD]") + return fmt.Errorf("usage: cal [-n] [-v day|week|month|event] [YYYY-MM-DD|path]") } args = args[2:] default: @@ -135,15 +137,32 @@ func run() error { 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 len(args) > 0 { + 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] [YYYY-MM-DD]") + return fmt.Errorf("usage: cal [-n] [-v day|week|month|event] [YYYY-MM-DD|path]") } s.at = t } @@ -165,14 +184,14 @@ done: } var err error - if s.d, err = draw.Init("/dev"); err != nil { + 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 { + defer s.D.Close() + if s.F, err = s.D.OpenFont(fontpath); err != nil { return err } - s.col = map[string]*draw.Image{} + s.Col = map[string]*draw.Image{} for k, v := range map[string]uint32{ "bg": 0xFFFFEAFF, // acme body "tag": 0xEAFFFFFF, // acme tag @@ -185,7 +204,7 @@ done: "now": 0xCC0000FF, "border": 0x8888CCFF, } { - if s.col[k], err = s.d.Color(v); err != nil { + if s.Col[k], err = s.D.Color(v); err != nil { return err } } @@ -193,15 +212,18 @@ done: // 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 { + 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 { + 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 { + if s.Win, err = s.D.Window("/dev"); err != nil { return err } @@ -239,7 +261,7 @@ done: 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) + s.Menu(s.menuItems(), m.Point, mc, s.redraw) if s.quit { return nil } @@ -264,7 +286,7 @@ done: s.redraw() } else { s.clearSel() - s.click(m.Point) + s.Click(m.Point) } case down && dragging: if i := s.lineAt(m.Point); i >= 0 && i != s.selB { @@ -276,7 +298,7 @@ done: if s.selA == s.selB { // no drag: it was a click after all s.clearSel() - s.click(pressed) + s.Click(pressed) } else { s.snarfSel() } @@ -287,6 +309,23 @@ done: 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 @@ -312,13 +351,21 @@ done: s.load() s.redraw() case 0x1B: // Esc clears whatever is showing - s.sel = nil - s.pick = time.Time{} 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 { + if s.Win, err = s.D.Reattach("/dev", s.Win); err != nil { return err } s.redraw() @@ -366,15 +413,6 @@ func sign(n int) int { 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. @@ -402,6 +440,9 @@ func (s *state) days() []time.Time { 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 @@ -477,6 +518,7 @@ func readDay(cal string, t time.Time) ([]event, error) { 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 }) @@ -512,7 +554,7 @@ 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 { + if _, ok := s.Col[k]; ok { return k } } @@ -523,62 +565,28 @@ func (s *state) calKey(what, cal string) string { 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") + s.Reset() + r := s.Body() + s.Fill(r, "bg") if r.Dy() < compactH { s.drawCompact(r) - s.d.Flush() + 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) - 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}) @@ -587,45 +595,44 @@ func (s *state) redraw() { case vMonth: s.drawMonth(body) } - s.d.Flush() + s.D.Flush() } func (s *state) drawHeader(r draw.Rectangle) draw.Rectangle { - h := int32(s.f.Height) + 8 + 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") + 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 + 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) + 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 + mk(viewName[v], s.view == v, func() { + s.view = vv if vv == vDay { - st.pick = time.Time{} } - st.load() - st.redraw() + s.load() + s.redraw() }) } x -= 6 - mk(">", false, func(st *state) { st.step(1) }) - mk("<", false, func(st *state) { st.step(-1) }) + mk(">", false, func() { s.step(1) }) + mk("<", false, func() { s.step(-1) }) x -= 6 - mk("Today", false, func(st *state) { - st.at = time.Now() - st.top = 8 * 60 - st.load() - st.redraw() + 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)) + 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 } @@ -642,7 +649,7 @@ func (s *state) title() string { // 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 + top, bot := r.Min.Y+int32(s.F.Height)+4, r.Max.Y if bot <= top { return } @@ -652,8 +659,8 @@ func (s *state) drawTimeGrid(r draw.Rectangle, days []time.Time) { // 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", + 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)) } @@ -669,14 +676,12 @@ func (s *state) drawTimeGrid(r draw.Rectangle, days []time.Time) { 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.Fill(hdr, "today") } - s.text(draw.Point{X: cr.Min.X + 4, Y: r.Min.Y}, c, s.fit(lab, colw-8)) + 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) }}) + s.On(hdr, func() { s.pickDay(dd) }) } for _, e := range s.evs[key] { @@ -684,19 +689,20 @@ func (s *state) drawTimeGrid(r draw.Rectangle, days []time.Time) { continue } y := yOf(e.min) - box := draw.Rect(cr.Min.X+2, y+1, cr.Max.X, y+int32(s.f.Height)+6) + 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)) + 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)) + 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() - }}) + s.On(box, func() { s.openEventWindow(&ev) }) } // Now line. @@ -704,7 +710,7 @@ func (s *state) drawTimeGrid(r draw.Rectangle, days []time.Time) { 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") + s.Fill(draw.Rect(cr.Min.X, y, cr.Max.X, y+2), "now") } } } @@ -719,14 +725,10 @@ func (s *state) drawMonth(r draw.Rectangle) { 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 + 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 + 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 @@ -734,18 +736,16 @@ func (s *state) drawMonth(r draw.Rectangle) { key := d.Format("2006-01-02") switch { - case key == picked: - s.fill(cell, "pick") case key == today: - s.fill(cell, "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") + 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.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")) + 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] { @@ -753,37 +753,46 @@ func (s *state) drawMonth(r draw.Rectangle) { 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)) + 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() }, - }) + s.On(draw.Rect(cell.Min.X, y, cell.Max.X, y+lh), func() { s.openEventWindow(&ev) }) y += lh } dd := d - dayHits = append(dayHits, hit{cell, func(st *state) { st.pickDay(dd) }}) + dayHits = append(dayHits, ui.Hit{R: cell, Do: func() { s.pickDay(dd) }}) + } + for _, h := range dayHits { + s.On(h.R, h.Do) } - 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 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", + 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)) + 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 @@ -822,19 +831,17 @@ func (s *state) snarfSel() { } } -// 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. +// 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.pick = d - s.sel = nil - s.redraw() + 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") + s.Fill(r, "tag") now := time.Now() key := now.Format("2006-01-02") cur := now.Hour()*60 + now.Minute() @@ -847,158 +854,15 @@ func (s *state) drawCompact(r draw.Rectangle) { } 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)) + 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) - } - } + 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 @@ -1114,7 +978,7 @@ func wrap(s *state, text string, w int32) []string { if cur != "" { try = cur + " " + word } - if s.f.Width(try) <= w { + if s.F.Width(try) <= w { cur = try continue } @@ -1129,3 +993,16 @@ func wrap(s *state, text string, w int32) []string { } 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" +} -- cgit v1.2.3