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 / macrorec.go
macrorec.go 9.1K
1package ui23import (4 "cmp"5 "strings"67 tea "charm.land/bubbletea/v2"89 "src.rest/cat/012/internal/macro"10 "src.rest/cat/012/internal/sheet"11 "src.rest/cat/012/internal/ui/choicebar"12 "src.rest/cat/012/internal/ui/picker"13)1415// Recording a macro, as Sheets' Extensions > Macros > Record macro: while16// recording, what the user does becomes a list of actions (the command17// log), written out as a script when the recording is saved.18//19// What's recorded:20// - commands that change the workbook, by id, where runCommand runs21// them, with the answer to the question they ask (a width, a name, a22// confirmation) once it's given;23// - entries, when they're stored (enter), and pastes of text;24// - dialogs (the sort bar, the filter picker, find and replace, the25// chart and pivot editors, the rules panel) once they've acted, as26// the command that opens them answered with the choices made27// (recordDialog), and charts moved or resized;28// - the fill handle, column borders dragged or fitted, and tabs dragged;29// - the selection, not the keys or clicks that made it: just before30// something is recorded, the selection it acts on is, as select() with31// absolute references or as move() and extend() from the last one with32// relative references. So pressing Down five times is one move(0, 5),33// and a replay doesn't depend on the window's size.34//35// With relative references, Ctrl+arrows, Home, Ctrl+Home and Ctrl+End are36// recorded as jump(), since where they land depends on the data. Anything37// else that changes the workbook (undo, the named ranges picker) becomes38// a comment saying it wasn't recorded, so the script never silently39// differs from what was done.4041// recorder is a recording in progress.42type recorder struct {43 relative bool44 actions []macro.Action45 base selState // the selection the recording has reached46 pending string // a command waiting for the answer to its question47 acted bool // something was recorded during this input event48 depth int // commands running inside a recorded one49}5051// selState is the selection as a recording sees it.52type selState struct {53 sheet *sheet.Sheet54 cur, ext sheet.Addr55 selecting bool56 whole wholeKind57}5859// selState is the selection now; the far corner counts only while a60// range is selected.61func (g *grid) selState() selState {62 ext := g.ext63 if !g.selecting {64 ext = g.cur65 }66 return selState{g.sheet, g.cur, ext, g.selecting, g.whole}67}6869func (s selState) rect() sheet.Rect {70 return (&grid{cur: s.cur, ext: s.ext, selecting: s.selecting, whole: s.whole}).corners()71}7273// startRecording starts recording, from the selection as it is.74func (m *Model) startRecording(relative bool) {75 m.rec = &recorder{relative: relative, base: m.selState()}76}7778// add records a, taking the selection after it as the new base.79func (r *recorder) add(m *Model, a macro.Action) {80 r.actions = append(r.actions, a)81 r.base = m.selState()82 r.acted = true83}8485// flush records the selection, if it moved since the last action.86func (r *recorder) flush(m *Model) {87 now := m.selState()88 if now == r.base {89 return90 }91 r.actions = append(r.actions, r.selection(r.base, now)...)92 r.base = now93}9495// selection records the move from one selection to another.96func (r *recorder) selection(from, to selState) []macro.Action {97 if !r.relative || from.sheet != to.sheet || to.whole == wholeAll {98 return []macro.Action{absoluteSelect(from, to)}99 }100 var out []macro.Action101 dc, dr := to.cur.Col-from.cur.Col, to.cur.Row-from.cur.Row102 if dc != 0 || dr != 0 || from.selecting && !to.selecting {103 out = append(out, macro.Call("move", dc, dr))104 }105 if to.selecting && (from.cur != to.cur || from.ext != to.ext || from.whole != to.whole || !from.selecting) {106 ext := macro.Call("extend", to.ext.Col-to.cur.Col, to.ext.Row-to.cur.Row)107 switch to.whole {108 case wholeCols:109 ext = ext.With("whole", "columns")110 case wholeRows:111 ext = ext.With("whole", "rows")112 }113 out = append(out, ext)114 }115 return out116}117118// absoluteSelect is select() of the selection to, naming its sheet when119// it's another than from's, and the active cell when it isn't where120// select() puts it.121func absoluteSelect(from, to selState) macro.Action {122 r := to.rect()123 ref := wholeRef(r)124 if to.sheet != from.sheet {125 ref = sheet.QuoteSheet(to.sheet.Name()) + "!" + ref126 }127 a := macro.Call("select", ref)128 if to.whole != wholeNone || to.cur != r.From {129 a = a.With("active", to.cur.String())130 }131 return a132}133134// recordingCommand runs c, recording it if it changes the workbook.135func (m *Model) recordingCommand(c *command) tea.Cmd {136 r := m.rec137 if r.depth > 0 || c.macro != macroRecord {138 return c.run(m)139 }140 r.flush(m)141 r.depth++142 cmd := c.run(m)143 r.depth--144 _, choosing := m.overlay.(*choicebar.Bar)145 if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {146 choosing = true147 }148 switch {149 case m.mode == modePrompt || choosing:150 r.pending = c.id // recorded once answered151 case m.overlay != nil:152 // A dialog records what it does when it's done (recordDialog).153 case m.mode != modeError:154 r.add(m, macro.Call("run", c.id))155 }156 return cmd157}158159// recordAnswer records the command waiting for the answer to its160// question, now that it has one. Esc on a choice cancels instead.161func (m *Model) recordAnswer(answer string, cancelled bool) {162 r := m.rec163 if r == nil || r.pending == "" {164 return165 }166 id := r.pending167 r.pending = ""168 if !cancelled && m.mode != modeError {169 r.add(m, macro.Call("run", id).With("answer", answer))170 }171}172173// recordDialog records what a dialog did once it has, as its command174// answered with the dialog's choices (see command.answer), which a175// replay makes again. The selection it acted on was recorded when its176// command ran, or by recordFlush when something else opened it: what the177// command did to the selection since (showing a new sheet) is its own.178func (m *Model) recordDialog(id, answer string) {179 r := m.rec180 if r == nil || r.depth > 0 || m.mode == modeError {181 return182 }183 r.add(m, macro.Call("run", id).With("answer", macro.JSON(answer)))184}185186// recordEntry records an entry stored in the active cell, or in every187// selected cell with fill (Ctrl+Enter).188func (m *Model) recordEntry(input string, fill bool) {189 if m.rec == nil {190 return191 }192 m.rec.flush(m)193 a := macro.Call("enter", input)194 if fill {195 a = a.With("fill", true)196 }197 if m.rec.relative && sheet.IsFormulaEntry(input) {198 a = a.With("origin", m.cur.String()) // references move with the active cell199 }200 m.rec.add(m, a)201}202203// recordFlush records the selection an action is about to act on.204func (m *Model) recordFlush() {205 if m.rec != nil {206 m.rec.flush(m)207 }208}209210// record records a after the selection it acted on (see recordFlush).211func (m *Model) record(a macro.Action) {212 if m.rec != nil {213 m.rec.add(m, a)214 }215}216217// jumpTargets are the keys recorded as jump() with relative references.218var jumpTargets = map[string]string{219 "ctrl+up": "up", "ctrl+down": "down", "ctrl+left": "left", "ctrl+right": "right", "end": "right",220 "home": "home", "ctrl+home": "start", "ctrl+end": "end",221}222223// recordMove handles a movement key in READY, recording it as jump()224// when it goes to a place that depends on the data.225func (m *Model) recordMove(key string) bool {226 r := m.rec227 base, extend := extendKey(key)228 if !extend {229 base = key230 }231 to, jump := jumpTargets[base]232 if r == nil || !r.relative || !jump {233 return m.moveKey(key)234 }235 r.flush(m)236 if !m.moveKey(key) {237 return false238 }239 a := macro.Call("jump", to)240 if extend {241 a = a.With("extend", true)242 }243 r.add(m, a)244 return true245}246247// observe follows an input event while recording: with relative248// references a new sheet shown is recorded right away (where its249// selection starts can't be told later), and a change to the workbook250// nothing recorded is noted as a comment. A border being dragged is251// recorded when it's let go.252func (m *Model) observeRecording(before int) {253 r := m.rec254 if r == nil || m.mode != modeReady && m.mode != modeError || m.overlay != nil || m.prompt != nil {255 return256 }257 if r.relative && m.sheet != r.base.sheet {258 r.add(m, macro.Call("activate_sheet", m.sheet.Name()))259 }260 if resizing := m.mouse.drag == dragResize || m.mouse.drag == dragRowResize; m.sheet.StateID() != before && !r.acted && !resizing {261 what := cmp.Or(m.note, m.book().UndoLabel(), "a change")262 r.actions = append(r.actions, macro.Note("Not recorded: "+strings.ToLower(what[:1])+what[1:]))263 }264}265266// recordingLine is the context line in READY while recording, when267// there's nothing else to say.268func (m *Model) recordingLine() string {269 if m.rec == nil {270 return ""271 }272 refs := "absolute"273 if m.rec.relative {274 refs = "relative"275 }276 return m.th.Hint.Render("Recording (" + refs + " references). Stop it in Data > Macros.")277}278279// recordingHeader is the comment a recorded script starts with.280func recordingHeader(relative bool) string {281 if relative {282 return "Recorded with relative references: moves count from the active cell.\n" +283 "Edit it in Data > Macros > Manage macros; docs/reference/macro-api.md lists what it can call."284 }285 return "Recorded with absolute references: cells are named as they were.\n" +286 "Edit it in Data > Macros > Manage macros; docs/reference/macro-api.md lists what it can call."287}