diff options
| -rw-r--r-- | pim/.gitignore | 2 | ||||
| -rw-r--r-- | pim/cmd/cal9/event.go | 164 | ||||
| -rw-r--r-- | pim/cmd/cal9/invite.go | 242 | ||||
| -rw-r--r-- | pim/cmd/cal9/main.go | 531 | ||||
| -rw-r--r-- | pim/cmd/cal9/menu.go | 263 | ||||
| -rw-r--r-- | pim/cmd/datepick/main.go | 395 |
6 files changed, 1045 insertions, 552 deletions
diff --git a/pim/.gitignore b/pim/.gitignore index 637cff0..2db36b4 100644 --- a/pim/.gitignore +++ b/pim/.gitignore @@ -7,8 +7,10 @@ bin/ /icalfs /caldavfs /cal9 +/datepick # and "go build" inside a cmd directory leaves it there cmd/*/icalfs cmd/*/caldavfs cmd/*/cal9 +cmd/*/datepick 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{} diff --git a/pim/cmd/cal9/invite.go b/pim/cmd/cal9/invite.go new file mode 100644 index 0000000..25dcb06 --- /dev/null +++ b/pim/cmd/cal9/invite.go @@ -0,0 +1,242 @@ +package main + +// The invite view: a window you fill in to make an event. +// +// cal9 -v invite [calendar] +// +// It writes the calendar's new file in the tree's own key: value form, +// so nothing here knows what iCalendar looks like -- calfs composes it, +// and a comma in a summary stays a comma. With attendees on a backend +// that schedules, writing the file is also sending the invitation. + +import ( + "fmt" + "os" + "os/exec" + "path" + "strings" + + "time" + + "9front/gui/draw" + "9front/gui/ui" +) + +type form struct { + cal string + fields []*ui.Field + focus int + note string // what happened, shown under the buttons +} + +func newForm(cal string) *form { + return &form{ + cal: cal, + fields: []*ui.Field{ + {Label: "summary"}, + {Label: "when"}, + {Label: "minutes", Value: "30"}, + {Label: "where"}, + {Label: "attendees"}, + }, + } +} + +func (s *state) drawInvite(r draw.Rectangle) { + s.Fill(r, "bg") + f := s.form + lh := int32(s.F.Height) + 8 + + y := r.Min.Y + pad + s.Text(draw.Point{X: r.Min.X + pad, Y: y}, "ink", "new event on "+f.cal) + y += lh + + for i, fl := range f.fields { + w := r.Max.X - pad + // the when field gets a button that opens the picker + if fl.Label == "when" { + bw := s.F.Width("pick") + 20 + w -= bw + 6 + s.Button(draw.Rect(w+6, y, w+6+bw, y+lh-4), "pick", false, s.pickWhen) + } + s.Draw(fl, draw.Rect(r.Min.X+pad, y, w, y+lh-4), i == f.focus) + n := i + s.On(fl.R, func() { f.focus = n; s.redraw() }) + y += lh + } + + y += 6 + x := r.Min.X + pad + for _, b := range []struct { + label string + do func() + }{ + {"Create", func() { s.create() }}, + {"Clear", func() { + for _, fl := range f.fields { + fl.Value = "" + } + f.fields[2].Value = "30" + f.note = "" + s.redraw() + }}, + } { + do := b.do + bw := s.F.Width(b.label) + 20 + br := draw.Rect(x, y, x+bw, y+lh) + s.Button(br, b.label, false, do) + x = br.Max.X + 6 + } + y += lh + 6 + + if f.note != "" { + s.Text(draw.Point{X: r.Min.X + pad, Y: y}, "ink", s.Fit(f.note, r.Dx()-pad*2)) + y += lh + } + s.Text(draw.Point{X: r.Min.X + pad, Y: r.Max.Y - int32(s.F.Height) - pad}, "rule", + "tab moves, ^U clears a field, when: 2026-08-28 15:00") + s.D.Flush() +} + +// pickWhen opens datepick in a window of its own and waits for it. +// +// A window started through wctl has nowhere useful to print -- nothing +// is reading its standard output -- so datepick is asked to leave the +// answer in a file, and a goroutine watches for it. That keeps the form +// answering the mouse while the picker is up. +func (s *state) pickWhen() { + tmp := fmt.Sprintf("/tmp/cal9.pick.%d", os.Getpid()) + os.Remove(tmp) + s.New(360, 320, "datepick", "-t", "-o", tmp) + go func() { + for i := 0; i < 600; i++ { // two minutes is long enough to choose + if b, err := os.ReadFile(tmp); err == nil && len(b) > 0 { + os.Remove(tmp) + s.picked <- strings.TrimSpace(string(b)) + return + } + time.Sleep(200 * time.Millisecond) + } + }() +} + +// create writes the form to the calendar's new file. The fs composes +// the icalendar; this only has to say what it means. +func (s *state) create() { + f := s.form + get := func(i int) string { return strings.TrimSpace(f.fields[i].Value) } + + if get(0) == "" || get(1) == "" { + f.note = "summary and when are needed" + s.redraw() + return + } + start, err := seconds(get(1)) + if err != nil { + f.note = "cannot read the time: " + get(1) + s.redraw() + return + } + mins := int64(30) + fmt.Sscan(get(2), &mins) + if mins <= 0 { + mins = 30 + } + + var b strings.Builder + fmt.Fprintf(&b, "summary: %s\n", get(0)) + fmt.Fprintf(&b, "start: %d\n", start) + fmt.Fprintf(&b, "end: %d\n", start+mins*60) + if v := get(3); v != "" { + fmt.Fprintf(&b, "location: %s\n", v) + } + if v := get(4); v != "" { + me := calMe(path.Join(mtpt, "calendars", f.cal)) + if me == "" { + f.note = f.cal + " has no me=; cannot say who is organising" + s.redraw() + return + } + fmt.Fprintf(&b, "organizer: %s\n", me) + for _, a := range strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == ' ' + }) { + fmt.Fprintf(&b, "attendee: %s\n", a) + } + } + + nw := path.Join(mtpt, "calendars", f.cal, "new") + fd, err := os.OpenFile(nw, os.O_WRONLY, 0) + if err != nil { + f.note = err.Error() + s.redraw() + return + } + _, err = fd.WriteString(b.String()) + if cerr := fd.Close(); err == nil { + err = cerr + } + if err != nil { + f.note = err.Error() + } else { + f.note = "created: " + get(0) + for _, fl := range f.fields { + fl.Value = "" + } + f.fields[2].Value = "30" + } + s.redraw() +} + +// calMe reads whose calendar this is, the same line pim/invite reads. +func calMe(dir string) string { + b, err := os.ReadFile(path.Join(dir, "ctl")) + if err != nil { + return "" + } + for _, l := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(l, "me "); ok { + if i := strings.IndexByte(v, ','); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) + } + } + return "" +} + +// writable names the calendars that can take a new event. +func writable() []string { + var out []string + for _, c := range calendars() { + b, err := os.ReadFile(path.Join(mtpt, "calendars", c, "ctl")) + if err != nil { + continue + } + for _, l := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(l, "caps "); ok { + for _, f := range strings.Fields(v) { + if f == "write" { + out = append(out, c) + } + } + } + } + } + return out +} + +// seconds parses a human date the way seconds(1) does, by asking it. +// Reimplementing tmparse here would be a second answer to a question +// the system already answers. +func seconds(when string) (int64, error) { + out, err := exec.Command("/bin/seconds", when).Output() + if err != nil { + return 0, err + } + var n int64 + if _, err := fmt.Sscan(strings.TrimSpace(string(out)), &n); err != nil { + return 0, err + } + return n, nil +} 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 <occurrence path>") + } + 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" +} diff --git a/pim/cmd/cal9/menu.go b/pim/cmd/cal9/menu.go index 4443b00..f210ed0 100644 --- a/pim/cmd/cal9/menu.go +++ b/pim/cmd/cal9/menu.go @@ -1,116 +1,16 @@ package main import ( - "fmt" - "time" "os" "os/exec" "regexp" "strings" - "9front/gui/draw" + "9front/gui/ui" ) -// A button-3 menu, the way page and vdir do it: press 3, drag, release on an -// item. acme's plumb-on-3 is the outlier on this system, so plumbing lives -// in here as an item rather than owning the button. -type menuItem struct { - label string - do func(*state) -} - -func (s *state) menu(at draw.Point, mc *draw.Mousectl) { - items := s.menuItems() - if len(items) == 0 { - return - } - - lh := int32(s.f.Height) + 4 - var w int32 - for _, it := range items { - if x := s.f.Width(it.label) + 20; x > w { - w = x - } - } - h := lh*int32(len(items)) + 4 - win := s.win.Rect() - r := draw.Rect(at.X, at.Y, at.X+w, at.Y+h) - // Keep it on screen. - if r.Max.X > win.Max.X { - r = r.Add(draw.Point{X: win.Max.X - r.Max.X}) - } - if r.Max.Y > win.Max.Y { - r = r.Add(draw.Point{Y: win.Max.Y - r.Max.Y}) - } - - sel := -1 - paint := func() { - s.fill(r, "tag") - 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") - for i, it := range items { - ir := draw.Rect(r.Min.X+1, r.Min.Y+2+int32(i)*lh, r.Max.X-1, r.Min.Y+2+int32(i+1)*lh) - if i == sel { - s.fill(ir, "today") - } - s.text(draw.Point{X: ir.Min.X + 8, Y: ir.Min.Y + 2}, "ink", it.label) - } - s.d.Flush() - } - itemAt := func(p draw.Point) int { - if p.X < r.Min.X || p.X >= r.Max.X { - return -1 - } - i := int((p.Y - r.Min.Y - 2) / lh) - if i < 0 || i >= len(items) || p.Y < r.Min.Y+2 { - return -1 - } - return i - } - paint() - - // Track until button 3 comes back up. - for m := range mc.C { - if n := itemAt(m.Point); n != sel { - sel = n - paint() - } - if m.Buttons&4 == 0 { - if sel >= 0 { - items[sel].do(s) - } else { - s.redraw() - } - return - } - } -} - -func (s *state) menuItems() []menuItem { - items := []menuItem{} - if s.sel != nil { - items = append(items, menuItem{"Plumb", func(st *state) { - if err := st.plumb(st.sel); err != nil { - fmt.Fprintf(os.Stderr, "cal: plumb: %v\n", err) - } - st.redraw() - }}) - items = append(items, menuItem{"Open event", func(st *state) { - st.openEvent(st.sel) - }}) - items = append(items, menuItem{"Close event", func(st *state) { - st.sel = nil - st.redraw() - }}) - } - if s.sel == nil && !s.pick.IsZero() { - items = append(items, menuItem{"Close day", func(st *state) { - st.pick = time.Time{} - st.redraw() - }}) - } +func (s *state) menuItems() []ui.Item { + items := []ui.Item{} // one toggle per calendar, so a crowded work calendar can be put // aside without unmounting anything for _, c := range s.cals { @@ -119,61 +19,42 @@ func (s *state) menuItems() []menuItem { if s.on[c] { mark = "[x] " } - items = append(items, menuItem{mark + c, func(st *state) { - st.on[c] = !st.on[c] - st.load() - st.redraw() + items = append(items, ui.Item{Label: mark + c, Do: func() { + s.on[c] = !s.on[c] + s.load() + s.redraw() }}) } - // A second view of the calendar is a second cal, in its own window. - items = append(items, menuItem{"Open " + strings.ToLower(viewName[s.view]), - func(st *state) { st.openCal(st.view, st.at) }}) - if !s.pick.IsZero() && s.view != vDay { - d := s.pick - items = append(items, menuItem{"Open " + d.Format("2 Jan"), - func(st *state) { st.openCal(vDay, d) }}) + if len(writable()) > 0 { + items = append(items, ui.Item{Label: "New event", Do: func() { + s.New(520, 300, "cal9", "-v", "invite") + }}) } + + // A second view of the calendar is a second cal, in its own window. + items = append(items, ui.Item{Label: "Open " + strings.ToLower(viewName[s.view]), Do: func() { s.openCal(s.view, s.at) }}) items = append(items, - menuItem{"Today", func(st *state) { - st.at = now() - st.top = 8 * 60 - st.load() - st.redraw() - }}, - // rio has no iconify, so "compact" is a resize request: shrink the - // window and the small-window view takes over by itself. - menuItem{"Compact", func(st *state) { - r := st.win.Rect() - st.wctl(fmt.Sprintf("resize -r %d %d %d %d", - r.Min.X, r.Min.Y, r.Min.X+320, r.Min.Y+72)) - }}, - menuItem{"Restore", func(st *state) { - r := st.win.Rect() - st.wctl(fmt.Sprintf("resize -r %d %d %d %d", - r.Min.X, r.Min.Y, r.Min.X+820, r.Min.Y+620)) + ui.Item{Label: "Today", Do: func() { + s.at = now() + s.top = 8 * 60 + s.load() + s.redraw() }}, - menuItem{"Move", func(st *state) { st.track(st.mc, false) }}, - menuItem{"Resize", func(st *state) { st.track(st.mc, true) }}, - menuItem{"Hide", func(st *state) { st.wctl("hide") }}, - menuItem{"Exit", func(st *state) { st.quit = true }}, + // rio has no iconify, so "compact" is a resize request: shrink + // the window and the small-window view takes over by itself. + // One item, not two: the window is either compact or it is not, + // and offering the state you are already in is noise. + s.compactItem(), + // No move or resize here. rio owns the border -- it puts corner + // cursors there and takes the clicks before we see them -- so + // dragging it already moves and resizes the window, and doing + // it worse from in here helps nobody. + ui.Item{Label: "Hide", Do: func() { s.Wctl("hide") }}, + ui.Item{Label: "Exit", Do: func() { s.quit = true }}, ) return items } -// wctl asks rio to do something to our window. Errors are worth showing: -// outside rio there is no wctl and the menu items simply do nothing. -func (s *state) wctl(cmd string) { - f, err := os.OpenFile("/dev/wctl", os.O_WRONLY, 0) - if err != nil { - fmt.Fprintf(os.Stderr, "cal: wctl: %v\n", err) - return - } - defer f.Close() - if _, err := f.WriteString(cmd); err != nil { - fmt.Fprintf(os.Stderr, "cal: wctl %q: %v\n", cmd, err) - } -} - var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`) // plumb sends the event's join link to the plumber, falling back to the @@ -188,80 +69,12 @@ func (s *state) plumb(e *event) error { return exec.Command("/bin/plumb", data).Run() } -// track implements Move and Resize ourselves. rio's wctl has no "let the -// user sweep" verb -- only move/resize with an explicit rectangle -- so we -// follow the pointer and write a new rect as it goes, and rio does the -// actual work. Any button press drops the window where it is. -// -// rio hands out a new image whenever the window's screen rect changes, so -// this loop has to service resize events as well as motion, or every write -// would be drawing into a stale image. -func (s *state) track(mc *draw.Mousectl, resize bool) { - r := s.win.Rect() - w, h := r.Dx(), r.Dy() - grab := draw.Point{} // pointer offset within the window, for Move - first := true - last := r - - for { - select { - case m, ok := <-mc.C: - if !ok { - return - } - if first { - grab = draw.Point{X: m.X - r.Min.X, Y: m.Y - r.Min.Y} - first = false - continue - } - if m.Buttons != 0 { // any press drops it - s.redraw() - return - } - var nr draw.Rectangle - if resize { - nr = draw.Rect(r.Min.X, r.Min.Y, m.X, m.Y) - if nr.Dx() < 120 { - nr.Max.X = nr.Min.X + 120 - } - if nr.Dy() < 60 { - nr.Max.Y = nr.Min.Y + 60 - } - } else { - min := draw.Point{X: m.X - grab.X, Y: m.Y - grab.Y} - nr = draw.Rect(min.X, min.Y, min.X+w, min.Y+h) - } - // Only bother rio when it would actually change something. - if abs(nr.Min.X-last.Min.X)+abs(nr.Min.Y-last.Min.Y)+ - abs(nr.Max.X-last.Max.X)+abs(nr.Max.Y-last.Max.Y) < 4 { - continue - } - last = nr - verb := "move" - if resize { - verb = "resize" - } - s.wctl(fmt.Sprintf("%s -r %d %d %d %d", verb, - nr.Min.X, nr.Min.Y, nr.Max.X, nr.Max.Y)) - - case <-mc.Resize: - win, err := s.d.Reattach("/dev", s.win) - if err != nil { - return - } - s.win = win - r = s.win.Rect() - if !resize { - w, h = r.Dx(), r.Dy() - } - s.redraw() - } - } -} - -func abs(n int32) int32 { - if n < 0 { - return -n - } - return n +// compactItem offers whichever of compact and restore you are not +// already in. compactH is the height below which the small view takes +// over, so it is also how we tell which state we are in. +func (s *state) compactItem() ui.Item { + if s.Win.Rect().Dy() < compactH { + return ui.Item{Label: "Restore", Do: func() { s.Resize(820, 620) }} + } + return ui.Item{Label: "Compact", Do: func() { s.Resize(320, 72) }} } diff --git a/pim/cmd/datepick/main.go b/pim/cmd/datepick/main.go new file mode 100644 index 0000000..bd3090d --- /dev/null +++ b/pim/cmd/datepick/main.go @@ -0,0 +1,395 @@ +// datepick shows a month, and prints the day you click. +// +// datepick one date, as yyyy-mm-dd +// datepick -r two, a range, one per line +// datepick -d 2026-08-01 open on that month +// datepick -t a time too: yyyy-mm-dd hh:mm +// datepick -o file write it there as well +// datepick -s put it on /dev/snarf as well +// +// The last two exist because a window started from wctl has nowhere +// useful to print: nothing is reading its standard output. +// +// It is a program rather than a widget so that anything can use it: +// +// pim/agenda -d `{datepick} +// pim/invite 'review' `{datepick}' 15:00' +// +// which is the same reason plumb and 9fs are programs. A widget would +// only ever be usable by whatever linked it. +package main + +import ( + "flag" + "fmt" + "os" + "strings" + "time" + + "9front/gui/draw" + "9front/gui/ui" +) + +const ( + fontpath = "/lib/font/bit/lucidasans/unicode.8.font" + pad = 6 +) + +type picker struct { + ui.UI + at time.Time // the month on show + got []time.Time // what has been clicked + want int // how many we need + mc *draw.Mousectl + done bool + + // With -t each answer is picked in stages: the day, then the hour, + // then the minute. Splitting it keeps every target big enough to + // hit, where a grid of 96 five-minute slots would not be. + withTime bool + stage int // 0 day, 1 hour, 2 minute + day time.Time + hour int +} + +func main() { + rng := flag.Bool("r", false, "pick a range: two dates, earliest first") + from := flag.String("d", "", "open on this month (yyyy-mm-dd)") + tim := flag.Bool("t", false, "pick a time as well, giving yyyy-mm-dd hh:mm") + out := flag.String("o", "", "write the answer here as well as to standard output") + snarf := flag.Bool("s", false, "put the answer on /dev/snarf too") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: datepick [-r] [-t] [-s] [-o file] [-d yyyy-mm-dd]\n") + os.Exit(2) + } + flag.Parse() + + p := &picker{at: time.Now(), want: 1} + p.withTime = *tim + if *rng { + p.want = 2 + } + if *from != "" { + t, err := time.Parse("2006-01-02", *from) + if err != nil { + fmt.Fprintf(os.Stderr, "datepick: %v\n", err) + os.Exit(1) + } + p.at = t + } + if err := p.run(); err != nil { + fmt.Fprintf(os.Stderr, "datepick: %v\n", err) + os.Exit(1) + } + // nothing chosen is not an error, it is a change of mind, but it + // should not look like a date to whatever runs us + if len(p.got) < p.want { + os.Exit(1) + } + var b strings.Builder + for _, t := range p.got { + fmt.Fprintln(&b, t.Format(p.layout())) + } + fmt.Print(b.String()) + // A window started from wctl has nowhere useful to print, so it can + // hand the answer over by file or by snarf instead. + if *out != "" { + os.WriteFile(*out, []byte(b.String()), 0600) + } + if *snarf { + draw.Snarf("/dev", b.String()) + } +} + +func (p *picker) run() error { + var err error + if p.D, err = draw.Init("/dev"); err != nil { + return err + } + defer p.D.Close() + if p.F, err = p.D.OpenFont(fontpath); err != nil { + return err + } + p.Col = map[string]*draw.Image{} + for k, v := range map[string]uint32{ + "bg": 0xFFFFEAFF, + "tag": 0xEAFFFFFF, + "rule": 0x99994CFF, + "ink": 0x000000FF, + "today": 0xFFFFAAFF, + "pick": 0xEEF2FFFF, + "chosen": 0x99AAEEFF, + "border": 0x8888CCFF, + } { + if p.Col[k], err = p.D.Color(v); err != nil { + return err + } + } + if p.Win, err = p.D.Window("/dev"); err != nil { + return err + } + kb, err := draw.OpenKeyboard("/dev") + if err == nil { + defer kb.Close() + } + var keys <-chan rune + if kb != nil { + keys = kb.C + } + if p.mc, err = draw.OpenMouse("/dev"); err != nil { + return err + } + defer p.mc.Close() + + p.redraw() + var wasDown bool + for !p.done { + select { + case m, ok := <-p.mc.C: + if !ok { + return nil + } + if m.Buttons&4 != 0 { + p.Menu([]ui.Item{ + {Label: "Today", Do: func() { p.at = time.Now(); p.redraw() }}, + {Label: "Restart", Do: func() { p.restart() }}, + {Label: "Cancel", Do: func() { p.done = true }}, + }, m.Point, p.mc, p.redraw) + wasDown = false + continue + } + switch { + case m.Buttons&8 != 0: + p.step(-1) + case m.Buttons&16 != 0: + p.step(1) + } + down := m.Buttons&1 != 0 + if down && !wasDown { + p.Click(m.Point) + } + wasDown = down + case r, ok := <-keys: + if !ok { + keys = nil + continue + } + switch r { + case 'q', 0x1B: + return nil + case 'h': + p.step(-1) + case 'l': + p.step(1) + case 't': + p.at = time.Now() + p.redraw() + case 'r': + p.restart() + } + case <-p.mc.Resize: + if p.Win, err = p.D.Reattach("/dev", p.Win); err != nil { + return err + } + p.redraw() + } + } + return nil +} + +// restart forgets what has been picked and goes back to the first +// question. Getting the second date wrong should not mean killing the +// window and starting the command again. +func (p *picker) restart() { + p.got = p.got[:0] + p.stage = 0 + p.hour = 0 + p.day = time.Time{} + p.redraw() +} + +func (p *picker) step(n int) { + p.at = p.at.AddDate(0, n, 0) + p.redraw() +} + +// chooseDay takes the day, and then the clock if we were asked for one. +func (p *picker) chooseDay(d time.Time) { + if p.withTime { + p.day = d + p.stage = 1 + p.redraw() + return + } + p.choose(d) +} + +func (p *picker) choose(d time.Time) { + p.got = append(p.got, d) + p.stage = 0 + if len(p.got) >= p.want { + // a range is a range whichever end you clicked first + if len(p.got) == 2 && p.got[1].Before(p.got[0]) { + p.got[0], p.got[1] = p.got[1], p.got[0] + } + p.done = true + return + } + p.redraw() +} + +// layout is how an answer is written: with the time, if we asked for one. +func (p *picker) layout() string { + if p.withTime { + return "2006-01-02 15:04" + } + return "2006-01-02" +} + +func (p *picker) redraw() { + p.Reset() + r := p.Body() + p.Fill(r, "bg") + if p.stage != 0 { + p.drawClock(r) + return + } + + lh := int32(p.F.Height) + 4 + hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+lh+4) + p.Fill(hdr, "tag") + + x := r.Max.X - pad + mk := func(label string, do func()) { + w := p.F.Width(label) + 16 + br := draw.Rect(x-w, hdr.Min.Y+2, x, hdr.Max.Y-2) + p.Button(br, label, false, do) + x = br.Min.X - 4 + } + mk(">", func() { p.step(1) }) + mk("<", func() { p.step(-1) }) + + title := p.at.Format("January 2006") + if p.want == 2 { + if len(p.got) == 0 { + title += " pick from" + } else { + // having chosen one end, show it: otherwise the second + // question is asked with no sign of the first answer + title += " from " + p.got[0].Format(p.layout()) + " -- pick to" + } + } + p.Text(draw.Point{X: r.Min.X + pad, Y: hdr.Min.Y + 3}, "ink", title) + + // the grid: whole weeks, Monday first + first := time.Date(p.at.Year(), p.at.Month(), 1, 0, 0, 0, 0, time.Local) + off := (int(first.Weekday()) + 6) % 7 + start := first.AddDate(0, 0, -off) + + body := draw.Rect(r.Min.X, hdr.Max.Y+2, r.Max.X, r.Max.Y) + cw := body.Dx() / 7 + ch := (body.Dy() - lh) / 6 + + for i, d := range []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} { + p.Text(draw.Point{X: body.Min.X + int32(i)*cw + 4, Y: body.Min.Y}, "rule", d) + } + + today := time.Now().Format("2006-01-02") + for i := 0; i < 42; i++ { + d := start.AddDate(0, 0, i) + key := d.Format("2006-01-02") + cx := body.Min.X + int32(i%7)*cw + cy := body.Min.Y + lh + int32(i/7)*ch + cell := draw.Rect(cx, cy, cx+cw-2, cy+ch-2) + + switch { + case len(p.got) > 0 && key == p.got[0].Format("2006-01-02"): + p.Fill(cell, "chosen") // the end already picked + case len(p.got) > 0 && d.After(p.got[0]) && p.want == 2: + p.Fill(cell, "pick") // still selectable as the other end + case key == today: + p.Fill(cell, "today") + } + c := "ink" + if d.Month() != p.at.Month() { + c = "rule" + } + p.Text(draw.Point{X: cell.Min.X + 4, Y: cell.Min.Y + 2}, c, d.Format("2")) + dd := d + p.On(cell, func() { p.chooseDay(dd) }) + } + p.D.Flush() +} + +// drawClock is the hour, then the minute. Two grids of a couple of dozen +// cells each, rather than one of ninety-six: the point of picking with a +// mouse is that the target is easy to hit. +func (p *picker) drawClock(r draw.Rectangle) { + lh := int32(p.F.Height) + 4 + hdr := draw.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+lh+4) + p.Fill(hdr, "tag") + + title := p.day.Format("Monday 2 January") + if p.stage == 1 { + title += " hour" + } else { + title += fmt.Sprintf(" %02d:__", p.hour) + } + if p.want == 2 && len(p.got) == 1 { + title = "from " + p.got[0].Format(p.layout()) + " -- " + title + } + p.Text(draw.Point{X: r.Min.X + pad, Y: hdr.Min.Y + 3}, "ink", title) + + x := r.Max.X - pad + w := p.F.Width("back") + 16 + p.Button(draw.Rect(x-w, hdr.Min.Y+2, x, hdr.Max.Y-2), "back", false, func() { + p.stage-- + p.redraw() + }) + + body := draw.Rect(r.Min.X, hdr.Max.Y+2, r.Max.X, r.Max.Y) + + var labels []string + var vals []int + if p.stage == 1 { + for h := 0; h < 24; h++ { + labels = append(labels, fmt.Sprintf("%02d", h)) + vals = append(vals, h) + } + } else { + for m := 0; m < 60; m += 5 { + labels = append(labels, fmt.Sprintf(":%02d", m)) + vals = append(vals, m) + } + } + + cols := int32(6) + rows := (int32(len(labels)) + cols - 1) / cols + cw := body.Dx() / cols + ch := body.Dy() / rows + if ch > lh*3 { + ch = lh * 3 + } + + for i, lab := range labels { + cx := body.Min.X + int32(i)%cols*cw + cy := body.Min.Y + int32(i)/cols*ch + cell := draw.Rect(cx+1, cy+1, cx+cw-2, cy+ch-2) + p.Fill(cell, "tag") + p.Border(cell, "border") + tw := p.F.Width(lab) + p.Text(draw.Point{X: cell.Min.X + (cell.Dx()-tw)/2, Y: cell.Min.Y + 2}, "ink", lab) + v := vals[i] + p.On(cell, func() { + if p.stage == 1 { + p.hour = v + p.stage = 2 + p.redraw() + return + } + p.choose(time.Date(p.day.Year(), p.day.Month(), p.day.Day(), + p.hour, v, 0, 0, time.Local)) + }) + } + p.D.Flush() +} |
