cat / 012
clone

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

cat/module-path
012 / internal / ui / macrorun.go
macrorun.go 6.7K
1package ui23import (4 "errors"5 "log/slog"6 "time"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/macro"11 "src.rest/cat/012/internal/sheet"12 "src.rest/cat/012/internal/telemetry"13)1415// Running a macro. The script runs on a goroutine of its own, so a long16// or runaway one never freezes the screen and Esc can stop it; every call17// it makes to the spreadsheet comes back to the UI goroutine as a message18// and runs there, on the model, like a key press would. The whole run is19// one undo step, opened when it starts and closed when it ends. While it20// runs the mode indicator says CMD, as 1-2-3 did, and only Esc is taken.2122// macroState is the macros' part of the model: a run in progress, and23// whether this file's macros may run without asking.24type macroState struct {25 run *macroRun26 // cmds are commands the run's calls returned (copying sets the27 // system clipboard), handed to Bubble Tea after the call.28 cmds []tea.Cmd2930 // machine identifies this computer (see SetMachine); trusted is set31 // once the user has agreed to run macros from a file made elsewhere.32 machine string33 trusted bool3435 // editor allows editing scripts in the user's editor, a program of36 // their own; only the local app does (see AllowEditor).37 editor bool38}3940// macroRun is a script running.41type macroRun struct {42 name string43 run *macro.Run44 calls chan func() // from the script: a call to run on the UI goroutine45 ack chan struct{} // to the script: the call has run46 done chan macroDone47 end func() // closes the undo step48 start time.Time49 span telemetry.Span50 print string // the last line the script printed5152 orphan *time.Timer // see do; used only on the script's goroutine53}5455type macroDone struct {56 stats macro.Stats57 err error58}5960// macroCallMsg carries a call from a running script; macroDoneMsg says it61// finished.62type (63 macroCallMsg struct {64 r *macroRun65 call func()66 }67 macroDoneMsg struct {68 r *macroRun69 done macroDone70 }71)7273// How the UI serves a script's calls: it keeps taking calls within one74// update for up to sliceBudget, so a busy script isn't held up by a frame75// per call, and gives the screen back when the script pauses for76// idleWait (computing on its own) or the slice is used up.77const (78 sliceBudget = 12 * time.Millisecond79 idleWait = 2 * time.Millisecond80)8182// macroMaxSteps bounds every run; tests lower it.83var macroMaxSteps uint64 = macro.DefaultMaxSteps8485// startMacro runs mc's script as one undo step.86func (m *Model) startMacro(mc sheet.Macro) tea.Cmd {87 if mc.API > sheet.MacroAPI {88 m.fail("Macro " + mc.Name + " was written for a newer version of 012")89 return nil90 }91 r := &macroRun{92 name: mc.Name, calls: make(chan func()), ack: make(chan struct{}), done: make(chan macroDone, 1),93 // Under the command that started it, which ends first; its calls94 // are served under it (see serveMacro).95 start: time.Now(), span: m.spans.Parent().Start("macro"),96 }97 r.end = m.book().Begin(sheet.Change{Label: "run macro " + mc.Name, Focus: m.selection(), Sheet: m.sheet})98 r.run = macro.New(mc.Name, mc.Source, macro.Env{99 Host: scriptHost{m},100 Do: r.do,101 Print: func(s string) { r.print = s },102 MaxSteps: macroMaxSteps,103 })104 m.macros.run = r105 m.note = ""106 go func() {107 st, err := r.run.Exec()108 r.done <- macroDone{st, err}109 }()110 return r.wait()111}112113// orphanAfter is how long a script waits for the UI to take a call before114// deciding the program is gone (it quit, or its SSH session dropped) and115// stopping, so its goroutine doesn't outlive the program. A live UI116// always has a wait running, so it takes calls at once.117var orphanAfter = 30 * time.Second118119// do hands fn to the UI goroutine and waits until it has run. It runs on120// the script's goroutine.121func (r *macroRun) do(fn func()) {122 if r.orphan == nil {123 r.orphan = time.NewTimer(orphanAfter)124 } else {125 r.orphan.Reset(orphanAfter)126 }127 select {128 case r.calls <- fn:129 <-r.ack130 case <-r.orphan.C:131 r.run.Cancel() // the call is dropped; the script stops at its next step132 }133}134135// wait is the command that waits for the script's next call or its end.136func (r *macroRun) wait() tea.Cmd {137 return func() tea.Msg {138 select {139 case fn := <-r.calls:140 return macroCallMsg{r, fn}141 case d := <-r.done:142 return macroDoneMsg{r, d}143 }144 }145}146147// serveMacro runs a script's call, then the calls that follow it for as148// long as the slice lasts.149func (m *Model) serveMacro(msg macroCallMsg) tea.Cmd {150 r := msg.r151 defer m.spans.Leave(m.spans.Enter(r.span.Parent()))152 deadline := time.Now().Add(sliceBudget)153 m.serveCall(r, msg.call)154 idle := time.NewTimer(idleWait)155 defer idle.Stop()156 for time.Now().Before(deadline) {157 select {158 case fn := <-r.calls:159 m.serveCall(r, fn)160 idle.Reset(idleWait)161 case d := <-r.done:162 return m.finishMacro(r, d)163 case <-idle.C:164 return m.macroCmds(r.wait())165 }166 }167 return m.macroCmds(r.wait())168}169170// serveCall runs one call with the values of formulas up to date, and171// lets the script go on.172func (m *Model) serveCall(r *macroRun, fn func()) {173 m.book().Settle()174 fn()175 r.ack <- struct{}{}176}177178// macroCmds hands the commands the run's calls returned to Bubble Tea,179// with next.180func (m *Model) macroCmds(next tea.Cmd) tea.Cmd {181 cmds := append(m.macros.cmds, next)182 m.macros.cmds = nil183 return tea.Batch(cmds...)184}185186// finishMacro closes the run's undo step and says how it went on the187// context line: what it printed last, or where it failed.188func (m *Model) finishMacro(r *macroRun, d macroDone) tea.Cmd {189 before := m.sheet.StateID()190 r.end()191 m.macros.run = nil192 changed := m.sheet.StateID() != before193 var me *macro.Error194 outcome := "ok"195 switch {196 case d.err == nil && r.print != "":197 m.note = r.name + ": " + r.print198 case d.err == nil:199 m.note = "Ran " + r.name200 case errors.As(d.err, &me) && me.Cancel:201 outcome = "cancelled"202 m.warn = "Stopped " + r.name + " with Esc"203 case errors.As(d.err, &me) && me.Limit:204 outcome = "limit"205 m.warn = d.err.Error()206 default:207 outcome = "error"208 m.warn = d.err.Error()209 }210 if d.err != nil && changed {211 m.warn += " Ctrl+Z undoes what it did"212 }213 r.span.End(slog.Int64("steps", int64(d.stats.Steps)), slog.Int("calls", d.stats.Calls), slog.String("outcome", outcome))214 return m.macroCmds(nil)215}216217// macroKey handles a key while a macro runs: Esc stops it, nothing else218// gets through. It reports whether a macro is running.219func (m *Model) macroKey(k tea.KeyPressMsg) bool {220 r := m.macros.run221 if r == nil {222 return false223 }224 if k.String() == "esc" {225 r.run.Cancel()226 }227 return true228}229230// macroRunning reports whether msg is input to hold back while a macro231// runs, so the script's view of the selection stays its own.232func (m *Model) macroBusy(msg tea.Msg) bool {233 if m.macros.run == nil {234 return false235 }236 switch msg.(type) {237 case tea.MouseMsg, tea.PasteMsg:238 return true239 }240 return false241}