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 }