1package ui23import (4 "errors"5 "strconv"6 "strings"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/ui/picker"13)1415// Macros, as Google Sheets' Extensions > Macros, under Data > Macros:16// record what you do (with absolute or relative references), save it17// with a name and an optional Ctrl+Alt+Shift+digit shortcut, run it from18// the menu, the palette or its shortcut, and manage saved macros. Macros19// are Starlark scripts kept in the .012 file (see docs/reference/macro-api.md);20// recorded ones are scripts too, readable and editable. Recording is in21// macrorec.go, running in macrorun.go, what scripts act on in22// macrohost.go, and the manager in macromanage.go.2324// macroItems is Data > Macros.25var macroItems = []menuItem{26 {cmd: "macro.record"}, {cmd: "macro.record_relative"}, {cmd: "macro.stop"}, {cmd: "macro.discard"}, sep,27 {cmd: "macro.run"}, {cmd: "macro.manage"}, {cmd: "macro.new"},28}2930func init() {31 idle := func(m *Model) bool { return m.rec == nil && m.macros.run == nil }32 recording := func(m *Model) bool { return m.rec != nil }33 register(34 &command{id: "macro.record", title: "Record macro", macro: macroNever, enabled: idle,35 desc: "Record what you do as a macro that selects the same cells (absolute references)",36 run: func(m *Model) tea.Cmd { m.startRecording(false); return nil }},37 &command{id: "macro.record_relative", title: "Record macro with relative references", macro: macroNever, enabled: idle,38 desc: "Record what you do as a macro that moves from wherever the active cell is",39 run: func(m *Model) tea.Cmd { m.startRecording(true); return nil }},40 &command{id: "macro.stop", title: "Stop and save recording", macro: macroNever, enabled: recording,41 desc: "Stop recording and save the macro with a name and an optional shortcut",42 run: (*Model).stopRecording},43 &command{id: "macro.discard", title: "Discard recording", macro: macroNever, enabled: recording,44 desc: "Stop recording without saving; what you did stays done",45 run: func(m *Model) tea.Cmd {46 m.rec = nil47 m.note = "Recording discarded"48 return nil49 }},50 &command{id: "macro.run", title: "Run macro", macro: macroNever,51 enabled: func(m *Model) bool { return idle(m) && len(m.book().Macros()) > 0 },52 desc: "Pick a saved macro and run it",53 run: func(m *Model) tea.Cmd {54 p := m.newPicker("Run macro", "Type a macro's name", 60, macroRunItems(m))55 m.openOverlay(p)56 return nil57 }},58 &command{id: "macro.manage", title: "Manage macros", macro: macroNever, enabled: idle,59 desc: "Rename, delete, give shortcuts to or edit the saved macros",60 run: func(m *Model) tea.Cmd { m.openMacros(""); return nil }},61 &command{id: "macro.new", title: "Write a macro", macro: macroNever,62 enabled: func(m *Model) bool { return idle(m) && m.macros.editor },63 desc: "Write a macro script in your editor ($VISUAL or $EDITOR)",64 run: (*Model).newMacro},65 )66 // Ctrl+Alt+Shift+digit arrives as Ctrl+Alt with the shifted symbol67 // from terminals without the kitty keyboard protocol (US layout).68 for i, sym := range "!@#$%^&*()" {69 d := string("1234567890"[i])70 keyAliases["ctrl+alt+"+string(sym)] = "ctrl+alt+shift+" + d71 keyAliases["ctrl+alt+shift+"+string(sym)] = "ctrl+alt+shift+" + d72 }73}7475// macroShortcut returns the digit of a macro shortcut key.76func macroShortcut(key string) (string, bool) {77 d, ok := strings.CutPrefix(key, "ctrl+alt+shift+")78 return d, ok && len(d) == 1 && d[0] >= '0' && d[0] <= '9'79}8081// shortcutLabel is how a macro's shortcut is shown, e.g. Ctrl+Alt+Shift+1.82func shortcutLabel(key string) string {83 if key == "" {84 return ""85 }86 return "Ctrl+Alt+Shift+" + key87}8889// runShortcut runs the macro a Ctrl+Alt+Shift+digit key is for, and90// reports whether the key is one.91func (m *Model) runShortcut(key string) (tea.Cmd, bool) {92 d, ok := macroShortcut(canonicalKey(key))93 if !ok {94 return nil, false95 }96 mc, found := m.book().MacroForKey(d)97 if !found {98 m.note = shortcutLabel(d) + " runs no macro; give one a shortcut in Data > Macros > Manage macros"99 return nil, true100 }101 return m.runMacro(mc), true102}103104// macroRunItems lists the saved macros for the run picker and the105// palette; picking one runs it.106func macroRunItems(m *Model) []picker.Item {107 var items []picker.Item108 for _, mc := range m.book().Macros() {109 items = append(items, picker.Item{110 Title: mc.Name, Name: len(mc.Name), Detail: "Data › Macros", Key: shortcutLabel(mc.Key),111 Desc: "Run the macro " + mc.Name, Off: m.rec != nil,112 Pick: func() tea.Cmd {113 m.closeOverlay()114 return m.runMacro(mc)115 },116 })117 }118 return items119}120121// runMacro runs mc, asking first when this file's macros came from122// another computer.123func (m *Model) runMacro(mc sheet.Macro) tea.Cmd {124 switch {125 case m.macros.run != nil:126 return nil127 case m.rec != nil:128 m.note = "Stop recording before running a macro"129 return nil130 case m.macroTrusted():131 return m.startMacro(mc)132 }133 m.ask(question{134 msg: "Trust this file's macros?",135 desc: "They were made on another computer and can change the file. Trusting covers all of them from now on.",136 warn: true,137 choices: []choice{138 {key: "enter", label: "Run", run: func(m *Model) tea.Cmd {139 m.trustHere()140 return m.startMacro(mc)141 }},142 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},143 },144 })145 return nil146}147148// macroTrusted reports whether this file's macros may run without149// asking: they were made or trusted on this computer, or the user said150// so this session.151func (m *Model) macroTrusted() bool {152 o := m.book().MacroOrigin()153 return m.macros.trusted || o != "" && o == m.macros.machine154}155156// trustHere trusts the file's macros from now on, on this computer: the157// origin saved with the file becomes this one.158func (m *Model) trustHere() {159 m.macros.trusted = true160 if m.macros.machine != "" {161 m.book().SetMacroOrigin(m.macros.machine)162 }163}164165// AllowEditor lets the user edit macro scripts in their own editor166// ($VISUAL or $EDITOR), which runs as a program of theirs with the167// screen handed over. Only the local app allows it: a session served168// over SSH must not start programs on the server.169func (m *Model) AllowEditor() { m.macros.editor = true }170171// SetMachine tells the model which computer it runs on, as an id kept in172// the user's configuration, so macros saved here run without asking and173// macros from files made elsewhere ask once.174func (m *Model) SetMachine(id string) { m.macros.machine = id }175176// madeHere is called when the user saves a macro of their own: it trusts177// the file's macros unless others came from elsewhere untrusted.178func (m *Model) madeHere(before int) {179 if before == 0 || m.macroTrusted() {180 m.trustHere()181 }182}183184// stopRecording asks for the new macro's name and then its shortcut.185// Esc at either question goes back to recording.186func (m *Model) stopRecording() tea.Cmd {187 r := m.rec188 r.flush(m)189 if len(r.actions) == 0 {190 m.rec = nil191 m.note = "Nothing was recorded, so there's no macro to save"192 return nil193 }194 m.openText("Save macro as:", nextMacroName(m.book()), func(m *Model, name string) tea.Cmd {195 if err := checkNewMacroName(m.book(), name); err != nil {196 m.fail(err.Error() + "; still recording")197 return nil198 }199 m.askShortcut(name)200 return nil201 })202 m.prompt.indicator = "NAME"203 m.prompt.onCancel = m.stillRecording204 return nil205}206207func (m *Model) stillRecording() { m.note = "Still recording" }208209// checkNewMacroName reports why name can't name a new macro.210func checkNewMacroName(w *sheet.Workbook, name string) error {211 if err := sheet.ValidMacroName(name); err != nil {212 return err213 }214 if mc, ok := w.Macro(strings.TrimSpace(name)); ok {215 return errors.New("There's already a macro named " + mc.Name)216 }217 return nil218}219220// askShortcut asks for the digit of the new macro's shortcut, offering221// the first one free.222func (m *Model) askShortcut(name string) {223 m.openText("Shortcut Ctrl+Alt+Shift+ (a digit, or empty for none):", freeShortcut(m.book()), func(m *Model, key string) tea.Cmd {224 if key != "" && !isShortcutDigit(key) {225 m.fail("A shortcut is one digit, 0 to 9, or nothing; still recording")226 return nil227 }228 m.saveRecording(name, key)229 return nil230 })231 m.prompt.indicator = "KEY"232 m.prompt.onCancel = m.stillRecording233}234235func isShortcutDigit(s string) bool { return len(s) == 1 && s[0] >= '0' && s[0] <= '9' }236237// saveRecording saves the recording as a macro and stops recording.238func (m *Model) saveRecording(name, key string) {239 r := m.rec240 before := len(m.book().Macros())241 mc := sheet.Macro{Name: name, Key: key, Source: macro.Source(recordingHeader(r.relative), r.actions)}242 if err := m.book().SaveMacro("", mc, "record macro "+strings.TrimSpace(name)); err != nil {243 m.fail(err.Error() + "; still recording")244 return245 }246 m.rec = nil247 m.madeHere(before)248 m.note = "Saved macro " + strings.TrimSpace(name)249 if key != "" {250 m.note += "; " + shortcutLabel(key) + " runs it"251 } else {252 m.note += "; run it from Data > Macros or the palette"253 }254}255256// nextMacroName is the first free "Macro N".257func nextMacroName(w *sheet.Workbook) string {258 for i := 1; ; i++ {259 if name := "Macro " + strconv.Itoa(i); !hasMacro(w, name) {260 return name261 }262 }263}264265func hasMacro(w *sheet.Workbook, name string) bool {266 _, ok := w.Macro(name)267 return ok268}269270// freeShortcut is the first digit, 1 to 9 then 0, no macro uses.271func freeShortcut(w *sheet.Workbook) string {272 for _, d := range "1234567890" {273 if _, taken := w.MacroForKey(string(d)); !taken {274 return string(d)275 }276 }277 return ""278}