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 / macromanage.go
macromanage.go 8.2K
1package ui23import (4 "cmp"5 "fmt"6 "os"7 "os/exec"8 "slices"9 "strconv"10 "strings"1112 tea "charm.land/bubbletea/v2"1314 "src.rest/cat/012/internal/macro"15 "src.rest/cat/012/internal/sheet"16 "src.rest/cat/012/internal/ui/picker"17 "src.rest/cat/012/internal/ui/theme"18)1920// Managing macros, as Sheets' Extensions > Macros > Manage macros: a21// picker lists them with their shortcuts; Enter runs one, F2 renames it,22// F3 changes its shortcut, F4 opens its script in the user's editor, and23// Ctrl+D deletes it. The first row writes a new one. Questions go on the24// context line; the picker opens again after each.2526// macrosHost is what the macro manager acts on. The model implements it.27// The manager stays in package ui: it is a few keys over a picker, and28// what they do (run, rename, edit a script) is the model's macro29// machinery.30type macrosHost interface {31 styles() *theme.Theme32 size() (width, height int)33 book() *sheet.Workbook34 closeOverlay()35 // canEditScripts reports whether this session may open an editor.36 canEditScripts() bool37 renameMacro(mc sheet.Macro)38 setMacroShortcut(mc sheet.Macro)39 editScript(name, src string, isNew bool) tea.Cmd40 macroManageItems() []picker.Item41}4243// macrosPicker is the picker of saved macros with its extra keys.44type macrosPicker struct {45 m macrosHost // the model, through what the manager needs of it46 *picker.Picker47 msg string // feedback on the last action, e.g. a deletion48}4950const writeMacroTitle = "+ Write a macro"5152// openMacros opens the manager with the macro named sel highlighted.53func (m *Model) openMacros(sel string) {54 p := m.newPicker("Macros", "Type a macro's name", 72, m.macroManageItems())55 p.Action = "run"56 for i, pm := range p.Shown() {57 if strings.EqualFold(pm.Item.Title, sel) {58 p.Sel = i59 }60 }61 m.openOverlay(&macrosPicker{m: m, Picker: p})62}6364// macroManageItems lists "Write a macro" and then every macro.65func (m *Model) macroManageItems() []picker.Item {66 var items []picker.Item67 if m.macros.editor {68 items = append(items, picker.Item{69 Title: writeMacroTitle, Name: len(writeMacroTitle), Desc: "Write a new macro script in your editor",70 Pick: func() tea.Cmd {71 m.closeOverlay()72 return m.newMacro()73 },74 })75 }76 for _, mc := range m.book().Macros() {77 lines := strings.Count(strings.TrimRight(mc.Source, "\n"), "\n") + 178 items = append(items, picker.Item{79 Title: mc.Name, Name: len(mc.Name), Detail: strconv.Itoa(lines) + " lines", Key: shortcutLabel(mc.Key),80 Desc: "Run " + mc.Name,81 Pick: func() tea.Cmd {82 m.closeOverlay()83 return m.runMacro(mc)84 },85 })86 }87 return items88}8990// current is the macro highlighted, if any.91func (p *macrosPicker) current(m macrosHost) (sheet.Macro, bool) {92 if p.Picker.Sel >= len(p.Shown()) || p.Shown()[p.Picker.Sel].Item.Title == writeMacroTitle {93 return sheet.Macro{}, false94 }95 return m.book().Macro(p.Shown()[p.Picker.Sel].Item.Title)96}9798func (p *macrosPicker) Key(k tea.KeyPressMsg) tea.Cmd {99 m := p.m100 p.msg = ""101 mc, ok := p.current(m)102 switch k.String() {103 case "f2", "f3", "f4", "ctrl+d":104 if !ok {105 return nil106 }107 default:108 return p.Picker.Key(k)109 }110 switch k.String() {111 case "f2":112 m.closeOverlay()113 m.renameMacro(mc)114 case "f3":115 m.closeOverlay()116 m.setMacroShortcut(mc)117 case "f4":118 if !m.canEditScripts() {119 p.msg = "No editor in this session"120 return nil121 }122 m.closeOverlay()123 return m.editScript(mc.Name, mc.Source, false)124 case "ctrl+d":125 m.book().DeleteMacro(mc.Name)126 p.msg = "Deleted " + mc.Name + "; Ctrl+Z brings it back"127 p.Items = m.macroManageItems()128 sel := p.Picker.Sel129 p.Changed()130 p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)131 }132 return nil133}134135func (p *macrosPicker) Status() (string, string) {136 m := p.m137 pairs := []string{"Enter", "run", "F2", "rename", "F3", "shortcut", "F4", "edit", "Ctrl+D", "delete", "Esc", "close"}138 if !m.canEditScripts() {139 pairs = slices.Delete(pairs, 6, 8)140 }141 if width, _ := m.size(); width < 100 {142 pairs = slices.DeleteFunc(pairs[:len(pairs)-2], func(s string) bool { return s == "shortcut" })143 pairs = slices.Insert(pairs, slices.Index(pairs, "F3")+1, "key")144 }145 keys := m.styles().KeyHints(pairs...)146 if _, ok := p.current(m); !ok {147 keys = m.styles().KeyHints("Enter", "write", "Esc", "close")148 }149 if p.msg != "" {150 return p.msg, keys151 }152 desc, _ := p.Picker.Status()153 return desc, keys154}155156// renameMacro asks for a new name on the context line.157func (m *Model) renameMacro(mc sheet.Macro) {158 m.openText("Rename macro "+mc.Name+":", mc.Name, func(m *Model, name string) tea.Cmd {159 old := mc.Name160 mc.Name = name161 if err := m.book().SaveMacro(old, mc, "rename macro "+old); err != nil {162 m.fail(err.Error())163 return nil164 }165 m.openMacros(name)166 return nil167 })168 m.prompt.indicator = "NAME"169}170171// setMacroShortcut asks for the digit of a macro's shortcut.172func (m *Model) setMacroShortcut(mc sheet.Macro) {173 m.openText("Shortcut for "+mc.Name+": Ctrl+Alt+Shift+ (a digit, or empty for none):", mc.Key, func(m *Model, key string) tea.Cmd {174 if key != "" && !isShortcutDigit(key) {175 m.fail("A shortcut is one digit, 0 to 9, or nothing")176 return nil177 }178 mc.Key = key179 if err := m.book().SaveMacro(mc.Name, mc, "change the shortcut of macro "+mc.Name); err != nil {180 m.fail(err.Error())181 return nil182 }183 m.openMacros(mc.Name)184 return nil185 })186 m.prompt.indicator = "KEY"187}188189// newMacro asks for a name, then opens a new script in the editor.190func (m *Model) newMacro() tea.Cmd {191 m.openText("Name for the new macro:", nextMacroName(m.book()), func(m *Model, name string) tea.Cmd {192 if err := checkNewMacroName(m.book(), name); err != nil {193 m.fail(err.Error())194 return nil195 }196 return m.editScript(strings.TrimSpace(name), newMacroTemplate, true)197 })198 m.prompt.indicator = "NAME"199 return nil200}201202const newMacroTemplate = `# A macro written by hand. It runs from the top as one undo step;203# docs/reference/macro-api.md lists what it can call. For example:204#205# set("A1", "Total")206# set_formula("B1", "SUM(B2:B100)")207# select("B1")208# run("format.bold")209`210211// macroEditedMsg says the editor closed on a macro's script.212type macroEditedMsg struct {213 name, path, orig string214 isNew bool215 err error216}217218// editScript opens src in the user's editor ($VISUAL, $EDITOR or vi),219// through a temporary file, and saves it as the macro name when the220// editor exits. The screen is handed to the editor meanwhile.221func (m *Model) editScript(name, src string, isNew bool) tea.Cmd {222 f, err := os.CreateTemp("", "012-macro-*.star")223 if err == nil {224 _, err = f.WriteString(src)225 if cerr := f.Close(); err == nil {226 err = cerr227 }228 }229 if err != nil {230 m.fail("Couldn't write the script for the editor: " + err.Error())231 return nil232 }233 msg := macroEditedMsg{name: name, path: f.Name(), orig: src, isNew: isNew}234 return tea.ExecProcess(editorCommand(f.Name()), func(err error) tea.Msg {235 msg.err = err236 return msg237 })238}239240// editorCommand runs the user's editor on path.241func editorCommand(path string) *exec.Cmd {242 editor := strings.Fields(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"), "vi"))243 return exec.Command(editor[0], append(editor[1:], path)...)244}245246// macroEdited saves the script the editor wrote, and checks it: a247// mistake is shown with its line and column, and the script is kept so248// it can be fixed.249func (m *Model) macroEdited(msg macroEditedMsg) {250 defer os.Remove(msg.path)251 if msg.err != nil {252 m.fail(fmt.Sprintf("The editor failed: %v; the script is unchanged", msg.err))253 return254 }255 data, err := os.ReadFile(msg.path)256 if err != nil {257 m.fail("Couldn't read the edited script: " + err.Error())258 return259 }260 src := string(data)261 if !msg.isNew && src == msg.orig {262 m.note = "No changes to " + msg.name263 return264 }265 before := len(m.book().Macros())266 mc, old, label := sheet.Macro{Name: msg.name, Source: src}, "", "write macro "+msg.name267 if !msg.isNew {268 cur, ok := m.book().Macro(msg.name)269 if !ok {270 m.fail("The macro " + msg.name + " is gone; the edit wasn't saved")271 return272 }273 mc, old, label = cur, cur.Name, "edit macro "+msg.name274 mc.Source = src275 }276 if err := m.book().SaveMacro(old, mc, label); err != nil {277 m.fail(err.Error())278 return279 }280 if msg.isNew {281 m.madeHere(before)282 }283 if err := macro.Check(msg.name, src); err != nil {284 m.warn = "Saved " + msg.name + ", but it has a mistake at " + err.Error()285 return286 }287 m.note = "Saved " + msg.name288}