cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / jev.go
jev.go 5.0K
1package ui23import (4 "context"5 "fmt"6 "log/slog"7 "time"89 tea "charm.land/bubbletea/v2"1011 "src.rest/cat/012/internal/jev"12 "src.rest/cat/012/internal/sheet"13 "src.rest/cat/012/internal/telemetry"14 "src.rest/cat/012/internal/ui/theme"15)1617// JEV functions are answered in the background: the engine queues18// questions in the cache, Update sends them as commands (a few at a time),19// and answers are stored as they arrive and, a frame later, the cells20// that asked them are recalculated together.2122const (23 jevParallel = 824 jevTimeout = 60 * time.Second25)2627type jevRunner struct {28 client jev.Client29 cache *jev.Cache3031 // answered are the questions answered since the last recalculation,32 // which runs gather after the first of them: at most one per frame.33 answered []sheet.RemoteCall34 gather time.Duration35}3637type jevAnswerMsg struct {38 call sheet.RemoteCall39 answer sheet.RemoteAnswer40}4142// jevRecalcMsg recalculates the cells whose questions were answered.43type jevRecalcMsg struct{}4445// EnableJEV turns on JEV functions: the cache answers them, for this46// workbook and those opened later, and the client asks what it lacks.47func (m *Model) EnableJEV(client jev.Client, cache *jev.Cache) {48 m.jev = &jevRunner{client: client, cache: cache, gather: FrameInterval}49 m.sheet.Book().SetRemote(cache)50}5152func init() {53 register(&command{id: "jev.refresh", macro: macroNever, title: "Ask JEV again",54 desc: "Ask the JEV model again for the selected cells, replacing its answers",55 enabled: func(m *Model) bool { return m.jev != nil },56 run: func(m *Model) tea.Cmd {57 r := m.selection()58 var calls []sheet.RemoteCall59 for _, a := range m.sheet.Addrs() {60 if r.Contains(a) {61 calls = append(calls, m.sheet.RemoteCalls(a)...)62 }63 }64 if len(calls) == 0 {65 m.note = "No JEV functions in " + r.String()66 return nil67 }68 m.jev.cache.Forget(calls)69 m.sheet.RecalcVolatile()70 m.note = fmt.Sprintf("Asking JEV again for %d %s", len(calls), plural(len(calls), "question", "questions"))71 return nil72 }})73}7475func plural(n int, one, many string) string {76 if n == 1 {77 return one78 }79 return many80}8182// send starts queued questions, keeping at most jevParallel in flight83// so a filled-down column doesn't open hundreds of connections at once.84// Their spans nest under parent.85func (j *jevRunner) send(parent telemetry.Parent) tea.Cmd {86 if j == nil {87 return nil88 }89 inFlight, queued := j.cache.Busy()90 telemetry.Set("jev_in_flight", int64(inFlight))91 telemetry.Set("jev_queued", int64(queued))92 calls := j.cache.Take(jevParallel - inFlight)93 cmds := make([]tea.Cmd, len(calls))94 for i, call := range calls {95 client := j.client96 cmds[i] = func() tea.Msg {97 ctx, cancel := context.WithTimeout(context.Background(), jevTimeout)98 defer cancel()99 span := parent.Start("jev", slog.String("kind", call.Kind), slog.Int("queued", queued))100 answer := jev.Ask(ctx, client, call)101 outcome := "ok"102 if answer.Failed != "" {103 outcome = "failed"104 }105 span.End(slog.String("outcome", outcome))106 return jevAnswerMsg{call: call, answer: answer}107 }108 }109 return tea.Batch(cmds...)110}111112// answerJEV stores an answer. The first answer since the last113// recalculation schedules the next one, a frame later, so answers that114// arrive together recalculate once.115func (m *Model) answerJEV(msg jevAnswerMsg) tea.Cmd {116 j := m.jev117 if j == nil {118 return nil119 }120 j.cache.Store(msg.call, msg.answer)121 j.answered = append(j.answered, msg.call)122 if len(j.answered) > 1 {123 return nil // already scheduled124 }125 if j.gather <= 0 {126 return func() tea.Msg { return jevRecalcMsg{} }127 }128 return tea.Tick(j.gather, func(time.Time) tea.Msg { return jevRecalcMsg{} })129}130131// recalcAnswered recalculates the cells waiting for the answers stored132// since the last time, telling the user when the last question is133// answered.134func (m *Model) recalcAnswered() tea.Cmd {135 j := m.jev136 if j == nil || len(j.answered) == 0 {137 return nil138 }139 calls := j.answered140 j.answered = nil141 m.sheet.Book().RecalcAnswered(calls)142 if j.busy() == "" {143 return m.term.notify("JEV finished answering in " + m.displayName())144 }145 return nil146}147148// busy describes questions still being answered, for the status line.149func (j *jevRunner) busy() string {150 if j == nil {151 return ""152 }153 inFlight, queued := j.cache.Busy()154 switch {155 case inFlight+queued == 0:156 return ""157 case queued == 0:158 return fmt.Sprintf("JEV answering %d", inFlight)159 }160 return fmt.Sprintf("JEV answering %d, %d waiting", inFlight, queued)161}162163// line explains the JEV answers of calls, the active cell's, for the164// context line.165func (j *jevRunner) line(th *theme.Theme, calls []sheet.RemoteCall) string {166 if len(calls) == 0 {167 return ""168 }169 if j == nil {170 return th.Warning.Render("JEV functions need an API key: File > Settings > JEV API key")171 }172 var parts []string173 for _, c := range calls {174 a, ok := j.cache.Answer(c)175 if !ok {176 parts = append(parts, "JEV: asking…")177 continue178 }179 parts = append(parts, jev.Describe(c, a))180 }181 line := parts[0]182 for _, p := range parts[1:] {183 line += " " + p184 }185 return th.Muted.Render(line)186}