summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pim/.gitignore2
-rw-r--r--pim/cmd/cal9/event.go164
-rw-r--r--pim/cmd/cal9/invite.go242
-rw-r--r--pim/cmd/cal9/main.go531
-rw-r--r--pim/cmd/cal9/menu.go263
-rw-r--r--pim/cmd/datepick/main.go395
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()
+}