- commit
- a2b5f24702c6984d504954d84ee01050251233cb
- parent
- 4c0bf0052b7ecc0da2954a23ec7e6b449c806704
- author
- Dami <dami.etoile@gmail.com>
@@ -0,0 +1,39 @@
1+package main2+3+import (4+ "crypto/rand"5+ "encoding/hex"6+ "os"7+ "path/filepath"8+ "strings"9+)10+11+// machineID identifies this computer to macros: files record the id of12+// the computer their macros were made or trusted on, and 012 asks before13+// running macros from anywhere else. It is a random id kept in the user's14+// configuration directory ($O12_CONFIG_DIR, or 012 under the system's),15+// made on first use; "" when that isn't possible, in which case every16+// file's macros ask once per session.17+func machineID() string {18+ dir := os.Getenv("O12_CONFIG_DIR")19+ if dir == "" {20+ base, err := os.UserConfigDir()21+ if err != nil {22+ return ""23+ }24+ dir = filepath.Join(base, "012")25+ }26+ path := filepath.Join(dir, "machine-id")27+ if data, err := os.ReadFile(path); err == nil {28+ if id := strings.TrimSpace(string(data)); len(id) >= 16 {29+ return id30+ }31+ }32+ b := make([]byte, 16)33+ rand.Read(b)34+ id := hex.EncodeToString(b)35+ if os.MkdirAll(dir, 0o700) != nil || os.WriteFile(path, []byte(id+"\n"), 0o600) != nil {36+ return ""37+ }38+ return id39+}@@ -0,0 +1,22 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+func TestMachineIDIsKept(t *testing.T) {10+ dir := filepath.Join(t.TempDir(), "cfg")11+ t.Setenv("O12_CONFIG_DIR", dir)12+ id := machineID()13+ if len(id) != 32 {14+ t.Fatalf("id %q", id)15+ }16+ if again := machineID(); again != id {17+ t.Errorf("second id %q, first %q", again, id)18+ }19+ if st, err := os.Stat(filepath.Join(dir, "machine-id")); err != nil || st.Mode().Perm() != 0o600 {20+ t.Errorf("file %v %v", st, err)21+ }22+}@@ -79,6 +79,7 @@ func run(args []string) error {
7979 }8080 }8181 m := ui.New(s, name)82+ m.SetMachine(machineID())8283 if importName != "" {8384 m.Import(importName)8485 }@@ -276,14 +276,15 @@ func (r *Run) jump(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value
276276 func (r *Run) enter(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {277277 var v starlark.Value278278 var fill bool279- if err := unpack("enter", args, kwargs, "text", &v, "fill?", &fill); err != nil {279+ var origin string280+ if err := unpack("enter", args, kwargs, "text", &v, "fill?", &fill, "origin?", &origin); err != nil {280281 return nil, err281282 }282283 text, err := input("enter", v)283284 if err != nil {284285 return nil, err285286 }286- return starlark.None, r.host(func(h Host) error { return h.Enter(text, fill) })287+ return starlark.None, r.host(func(h Host) error { return h.Enter(text, fill, origin) })287288 }288289 289290 func (r *Run) pasteText(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {@@ -60,8 +60,10 @@ type Host interface {
6060 // JumpTargets), extending the selection when extend is set.6161 Jump(to string, extend bool) error6262 // Enter stores text in the active cell as if typed and accepted, or63- // in every selected cell, references adjusted, when fill is set.64- Enter(text string, fill bool) error63+ // in every selected cell, references adjusted, when fill is set. With64+ // an origin, a formula is taken as typed there and moved to the65+ // active cell, relative references following, as in a copy.66+ Enter(text string, fill bool, origin string) error6567 // PasteText pastes tab-separated text at the active cell, as pasting6668 // from another program does.6769 PasteText(text string) error@@ -40,12 +40,14 @@ func (f *fakeHost) NumberFormat(string) (string, int, error) { return "curren
4040 func (f *fakeHost) SetNumberFormat(ref, kind string, d int, p string) error {4141 return f.log("format %s %s %d %q", ref, kind, d, p)4242 }43-func (f *fakeHost) Selection() (string, string) { return "B2:C3", "B2" }44-func (f *fakeHost) Select(ref, active string) error { return f.log("select %s %q", ref, active) }45-func (f *fakeHost) Move(c, r int) error { return f.log("move %d %d", c, r) }46-func (f *fakeHost) Extend(c, r int, whole string) error { return f.log("extend %d %d %q", c, r, whole) }47-func (f *fakeHost) Jump(to string, ext bool) error { return f.log("jump %s %v", to, ext) }48-func (f *fakeHost) Enter(text string, fill bool) error { return f.log("enter %q %v", text, fill) }43+func (f *fakeHost) Selection() (string, string) { return "B2:C3", "B2" }44+func (f *fakeHost) Select(ref, active string) error { return f.log("select %s %q", ref, active) }45+func (f *fakeHost) Move(c, r int) error { return f.log("move %d %d", c, r) }46+func (f *fakeHost) Extend(c, r int, whole string) error { return f.log("extend %d %d %q", c, r, whole) }47+func (f *fakeHost) Jump(to string, ext bool) error { return f.log("jump %s %v", to, ext) }48+func (f *fakeHost) Enter(text string, fill bool, origin string) error {49+ return f.log("enter %q %v %q", text, fill, origin)50+}4951 func (f *fakeHost) PasteText(text string) error { return f.log("paste %q", text) }5052 func (f *fakeHost) Sheets() []string { return []string{"Sheet1", "Two"} }5153 func (f *fakeHost) ActiveSheet() string { return "Sheet1" }@@ -73,6 +75,7 @@ func TestSourceRunsAsRecorded(t *testing.T) {
7375 Call("extend", 2, 0).With("whole", "columns"),7476 Call("jump", "down").With("extend", true),7577 Call("enter", "total").With("fill", true),78+ Call("enter", "=A1").With("origin", "B2"),7679 Call("paste_text", "a\tb\n\"c\""),7780 Note("not recorded: sort A1:C9"),7881 Call("run", "column.width").With("answer", "12"),@@ -91,8 +94,8 @@ func TestSourceRunsAsRecorded(t *testing.T) {
9194 t.Fatalf("%v\n%s", err, src)9295 }9396 want := []string{94- `select B2 ""`, `enter "=SUM(A1:A3)" false`, "move 0 1", `extend 2 0 "columns"`, "jump down true",95- `enter "total" true`, `paste "a\tb\n\"c\""`, `run column.width "12"`, "run format.bold",97+ `select B2 ""`, `enter "=SUM(A1:A3)" false ""`, "move 0 1", `extend 2 0 "columns"`, "jump down true",98+ `enter "total" true ""`, `enter "=A1" false "B2"`, `paste "a\tb\n\"c\""`, `run column.width "12"`, "run format.bold",9699 "activate Q3 plan", "width B:C 14", `fill "" 3 0`, "move sheet 2",97100 }98101 if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {@@ -359,6 +359,15 @@ func (w *Workbook) Undo() (Change, bool) { return w.swap(&w.hist.undo, &w.hist.r
359359 // Redo reapplies the last undone step and describes it.360360 func (w *Workbook) Redo() (Change, bool) { return w.swap(&w.hist.redo, &w.hist.undo) }361361 362+// UndoLabel describes the step Undo would revert, e.g. "clear B3:B5",363+// or "" when there is none.364+func (w *Workbook) UndoLabel() string {365+ if top := w.hist.top(); top != nil {366+ return top.label367+ }368+ return ""369+}370+362371 // CanUndo and CanRedo report whether there is a step to undo or redo.363372 func (w *Workbook) CanUndo() bool { return len(w.hist.undo) > 0 }364373 func (w *Workbook) CanRedo() bool { return len(w.hist.redo) > 0 }@@ -26,3 +26,21 @@ func (c *Cell) rewritten(rw formula.Rewriter) *Cell {
2626 }2727 return c.withFormula(n)2828 }29+30+// ShiftEntry returns an entry as if it were typed in one cell and copied31+// dc columns and dr rows away: a formula's relative references move and32+// $absolute ones stay, as in a paste. Other entries come back unchanged.33+// A macro recorded with relative references replays formulas this way.34+func ShiftEntry(input string, dc, dr int) (string, error) {35+ if dc == 0 && dr == 0 || !IsFormulaEntry(input) {36+ return input, nil37+ }38+ n, err := Parse(input)39+ if err != nil {40+ return "", err41+ }42+ if moved, changed := formula.Rewrite(n, formula.Shift(dc, dr)); changed {43+ return formula.Text(moved), nil44+ }45+ return input, nil46+}@@ -41,7 +41,7 @@ func init() {
4141 m.deleteChart(m.targetChart())4242 return nil4343 }},44- &command{id: "chart.select", title: "Select chart", desc: "Select the next chart, to move, resize or delete it with the keyboard",44+ &command{id: "chart.select", macro: macroView, title: "Select chart", desc: "Select the next chart, to move, resize or delete it with the keyboard",4545 enabled: hasCharts, run: func(m *Model) tea.Cmd {4646 i := 04747 if s, ok := m.overlay.(*chartSel); ok {@@ -222,6 +222,8 @@ func parseTSV(s string) [][]string {
222222 // action, or what to do with a copied range.223223 func (m *Model) readyLine() string {224224 switch {225+ case m.warn != "":226+ return m.th.Warning.Render(m.warn)225227 case m.note != "":226228 return m.note227229 case m.copied.marked && m.copied.cut:@@ -6,6 +6,7 @@ import (
66 tea "charm.land/bubbletea/v2"77 "github.com/charmbracelet/x/ansi"88 9+ "github.com/FelineStateMachine/012/internal/macro"910 "github.com/FelineStateMachine/012/internal/sheet"1011 )1112 @@ -74,7 +75,18 @@ func (m *Model) autofit(c int) {
7475 for _, k := range cols {7576 // One column of padding each side, and never narrower than the7677 // header letters.77- m.sheet.SetColWidth(k, clamp(widest[k]+2, len(sheet.ColName(k))+2, 240))78+ w := clamp(widest[k]+2, len(sheet.ColName(k))+2, 240)79+ m.sheet.SetColWidth(k, w)80+ m.record(widthAction(k, k, w))7881 }7982 m.changed = true8083 }84+85+// widthAction records setting columns from..to to width w.86+func widthAction(from, to, w int) macro.Action {87+ cols := sheet.ColName(from)88+ if to != from {89+ cols += ":" + sheet.ColName(to)90+ }91+ return macro.Call("set_width", cols, w)92+}@@ -29,8 +29,30 @@ type command struct {
2929 // checked, when set, makes the command a setting: menus show a ✓3030 // while it is on.3131 checked func(m *Model) bool32+33+ // macro says how recording and scripts treat the command; see34+ // macroUse.35+ macro macroUse3236 }3337 38+// macroUse is how macros treat a command.39+type macroUse int40+41+const (42+ // macroRecord commands change the workbook: a recording calls them43+ // by id, run("format.bold"), and scripts may run them. The default.44+ macroRecord macroUse = iota45+ // macroView commands change only what's shown or selected, or open46+ // something to look through: a recording keeps the selection they47+ // leave, not the command, so replaying doesn't depend on the screen.48+ // Scripts may run them.49+ macroView50+ // macroNever commands are about the session (files, menus, help,51+ // undo, macros themselves): never recorded, and scripts can't run52+ // them.53+ macroNever54+)55+3456 // available reports whether the command can run in m's current state.3557 func (c *command) available(m *Model) bool {3658 return c.enabled == nil || c.enabled(m)@@ -118,13 +140,16 @@ func (m *Model) runCommand(id string) tea.Cmd {
118140 // one span times every one of them.119141 span := telemetry.Start("command", slog.String("id", id))120142 defer span.End()143+ if m.rec != nil {144+ return m.recordingCommand(c) // macrorec.go145+ }121146 return c.run(m)122147 }123148 124149 func init() {125150 register(126- &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit},127- &command{id: "goto", title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {151+ &command{id: "edit", macro: macroView, title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit},152+ &command{id: "goto", macro: macroView, title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {128153 m.openGoto()129154 return nil130155 }},@@ -133,14 +158,14 @@ func init() {
133158 m.changed = true134159 return nil135160 }},136- &command{id: "select.none", title: "Deselect", desc: "Collapse the selection and clear the copy marker", run: func(m *Model) tea.Cmd {161+ &command{id: "select.none", macro: macroView, title: "Deselect", desc: "Collapse the selection and clear the copy marker", run: func(m *Model) tea.Cmd {137162 m.clearSelection()138163 m.copied.clearMark()139164 return nil140165 }},141- &command{id: "select.all", title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},142- &command{id: "select.columns", title: "Select columns", desc: "Select the whole columns of the selection", run: (*Model).selectColumns},143- &command{id: "select.rows", title: "Select rows", desc: "Select the whole rows of the selection", run: (*Model).selectRows},166+ &command{id: "select.all", macro: macroView, title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},167+ &command{id: "select.columns", macro: macroView, title: "Select columns", desc: "Select the whole columns of the selection", run: (*Model).selectColumns},168+ &command{id: "select.rows", macro: macroView, title: "Select rows", desc: "Select the whole rows of the selection", run: (*Model).selectRows},144169 &command{id: "column.width", title: "Column width", desc: "Set the width of the selected columns", run: (*Model).openWidth},145170 &command{id: "column.reset", title: "Reset column width", desc: "Return the selected columns to the default width", run: func(m *Model) tea.Cmd {146171 r := m.selection()@@ -150,13 +175,13 @@ func init() {
150175 m.changed = true151176 return nil152177 }},153- &command{id: "file.new", title: "New", desc: "Start a new, empty sheet", run: func(m *Model) tea.Cmd {178+ &command{id: "file.new", macro: macroNever, title: "New", desc: "Start a new, empty sheet", run: func(m *Model) tea.Cmd {154179 m.reset(sheet.New(), "")155180 return nil156181 }},157- &command{id: "file.save", title: "Save", desc: "Save the sheet", run: (*Model).save},158- &command{id: "file.saveas", title: "Save as", desc: "Save the sheet under a new name", run: (*Model).openSave},159- &command{id: "file.open", title: "Open", desc: "Open a sheet, replacing this one", run: (*Model).openRetrieve},160- &command{id: "quit", title: "Quit", desc: "Close 012", run: (*Model).quit},182+ &command{id: "file.save", macro: macroNever, title: "Save", desc: "Save the sheet", run: (*Model).save},183+ &command{id: "file.saveas", macro: macroNever, title: "Save as", desc: "Save the sheet under a new name", run: (*Model).openSave},184+ &command{id: "file.open", macro: macroNever, title: "Open", desc: "Open a sheet, replacing this one", run: (*Model).openRetrieve},185+ &command{id: "quit", macro: macroNever, title: "Quit", desc: "Close 012", run: (*Model).quit},161186 )162187 }@@ -43,7 +43,7 @@ func concat(lists ...[]menuItem) []menuItem {
4343 }4444 4545 func init() {46- register(&command{id: "menu.context", title: "Cell menu", desc: "Open the right-click menu for the selection", run: func(m *Model) tea.Cmd {46+ register(&command{id: "menu.context", macro: macroNever, title: "Cell menu", desc: "Open the right-click menu for the selection", run: func(m *Model) tea.Cmd {4747 x, y := m.cellPos(m.cur)4848 m.showContextMenu(cellMenu, x, y+1)4949 return nil@@ -41,7 +41,9 @@ func (c *choiceBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
4141 4242 func (c *choiceBar) choose(m *Model, ch choice) tea.Cmd {4343 m.closeOverlay()44- return ch.run(m)44+ cmd := ch.run(m)45+ m.recordAnswer(ch.key, ch.key == "esc")46+ return cmd4547 }4648 4749 // mouse runs a choice when its key chip or label is clicked. A click@@ -18,8 +18,8 @@ import (
1818 1919 func init() {2020 register(21- &command{id: "edit.undo", title: "Undo", desc: "Undo the last change", run: (*Model).undo},22- &command{id: "edit.redo", title: "Redo", desc: "Redo the last undone change", run: (*Model).redo},21+ &command{id: "edit.undo", macro: macroNever, title: "Undo", desc: "Undo the last change", run: (*Model).undo},22+ &command{id: "edit.redo", macro: macroNever, title: "Redo", desc: "Redo the last undone change", run: (*Model).redo},2323 &command{id: "edit.fill_down", title: "Fill down", desc: "Copy the top row of the selection into the rows below it", run: func(m *Model) tea.Cmd {2424 return m.fill(m.sheet.FillDown)2525 }},@@ -107,7 +107,7 @@ func (m *Model) afterHistory(verb, none string, c sheet.Change, ok bool) {
107107 i = m.book().Active()108108 }109109 m.afterSheetsChange(c.Sheet, i)110- if !c.Tabs {110+ if !c.Tabs && !c.Macros {111111 m.selectRect(c.Focus)112112 }113113 }@@ -157,6 +157,7 @@ func (m *Model) fillEntry() bool {
157157 return false158158 }159159 m.cancelEntry()160+ m.recordEntry(input, true)160161 return true161162 }162163 @@ -6,6 +6,7 @@ import (
66 77 tea "charm.land/bubbletea/v2"88 9+ "github.com/FelineStateMachine/012/internal/macro"910 "github.com/FelineStateMachine/012/internal/sheet"1011 )1112 @@ -224,6 +225,7 @@ func (m *Model) commit() bool {
224225 }225226 m.cancelEntry()226227 m.clearSelection()228+ m.recordEntry(input, false)227229 return true228230 }229231 @@ -247,7 +249,9 @@ func (m *Model) cancelEntry() {
247249 func (m *Model) handlePaste(content string) {248250 switch m.mode {249251 case modeReady:252+ m.recordFlush()250253 if m.pasteText(content) {254+ m.record(macro.Call("paste_text", content))251255 return252256 }253257 if content = strings.TrimSpace(content); content != "" {@@ -43,7 +43,8 @@ func (m *Model) openRetrieve() tea.Cmd {
4343 // session rather than the sheet carries over: the window, theme, terminal4444 // state and the JEV connection.4545 func (m *Model) reset(s *sheet.Sheet, filename string) {46- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, charts: chartState{last: -1}}46+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, charts: chartState{last: -1},47+ macros: macroState{machine: m.macros.machine}}4748 if m.jev != nil {4849 s.Book().SetRemote(m.jev.cache)4950 }@@ -3,6 +3,8 @@ package ui
33 import (44 "log/slog"55 6+ "github.com/FelineStateMachine/012/internal/macro"7+68 "github.com/FelineStateMachine/012/internal/sheet"79 "github.com/FelineStateMachine/012/internal/telemetry"810 )@@ -75,6 +77,7 @@ func (m *Model) finishFill() {
7577 if dst == src {7678 return7779 }80+ m.recordFlush()7881 span := telemetry.Start("fill", slog.Int("cells", (dst.To.Row-dst.From.Row+1)*(dst.To.Col-dst.From.Col+1)))7982 got, err := m.sheet.FillSeries(src, dst)8083 span.Fail(err)@@ -84,6 +87,24 @@ func (m *Model) finishFill() {
8487 }8588 m.changed = true8689 m.selectRect(got)90+ m.record(fillAction(m.rec, src, dst))91+}92+93+// fillAction records a fill from src over dst: the range with absolute94+// references, how far with relative ones.95+func fillAction(r *recorder, src, dst sheet.Rect) macro.Action {96+ if r == nil || !r.relative {97+ return macro.Call("fill").With("to", dst.String())98+ }99+ switch {100+ case dst.To.Row > src.To.Row:101+ return macro.Call("fill").With("rows", dst.To.Row-src.To.Row)102+ case dst.From.Row < src.From.Row:103+ return macro.Call("fill").With("rows", dst.From.Row-src.From.Row)104+ case dst.To.Col > src.To.Col:105+ return macro.Call("fill").With("cols", dst.To.Col-src.To.Col)106+ }107+ return macro.Call("fill").With("cols", dst.From.Col-src.From.Col)87108 }88109 89110 // cancelFill stops a fill handle drag without filling.@@ -54,11 +54,11 @@ const findID = "find"
5454 5555 func init() {5656 register(57- &command{id: "edit.find", title: "Find", desc: "Find text in the sheet as you type", run: func(m *Model) tea.Cmd {57+ &command{id: "edit.find", macro: macroView, title: "Find", desc: "Find text in the sheet as you type", run: func(m *Model) tea.Cmd {5858 m.openFind(false)5959 return nil6060 }},61- &command{id: "edit.replace", title: "Find and replace", desc: "Find text and replace it", run: func(m *Model) tea.Cmd {61+ &command{id: "edit.replace", macro: macroView, title: "Find and replace", desc: "Find text and replace it", run: func(m *Model) tea.Cmd {6262 m.openFind(true)6363 return nil6464 }},@@ -16,17 +16,17 @@ import (
1616 1717 func init() {1818 register(19- &command{id: "help", title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {19+ &command{id: "help", macro: macroNever, title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {2020 m.openOverlay(&shortcuts{})2121 return nil2222 }},23- &command{id: "help.functions", title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {23+ &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {2424 p := newPicker(m, "Functions", "Type a function name, e.g. sum or if", 100, functionItems())2525 p.action = "insert"2626 m.openOverlay(p)2727 return nil2828 }},29- &command{id: "help.about", title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {29+ &command{id: "help.about", macro: macroNever, title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {3030 m.openOverlay(&choiceBar{3131 msg: "012 " + version() + ": Lotus 1-2-3 looks, Google Sheets keys.",3232 choices: []choice{@@ -135,6 +135,10 @@ func helpRows() []helpRow {
135135 {keys: []string{"Arrows"}, action: "Pick cells for a formula"},136136 {keys: []string{"Esc"}, action: "Cancel the entry"},137137 }, "edit")138+ group("Macros", []helpRow{139+ {keys: []string{"Ctrl+Alt+Shift+0-9"}, action: "Run the macro with that shortcut"},140+ {keys: []string{"Esc"}, action: "Stop a macro while it runs"},141+ })138142 group("Menus and search", []helpRow{139143 {keys: []string{"Alt+letter"}, action: "Open a menu by its underlined letter"},140144 }, "palette", "menu", "menu.context", "help")@@ -41,7 +41,7 @@ func (m *Model) EnableJEV(client jev.Client, cache *jev.Cache) {
4141 }4242 4343 func init() {44- register(&command{id: "jev.refresh", title: "Ask JEV again",44+ register(&command{id: "jev.refresh", macro: macroNever, title: "Ask JEV again",4545 desc: "Ask the JEV model again for the selected cells, replacing its answers",4646 enabled: func(m *Model) bool { return m.jev != nil },4747 run: func(m *Model) tea.Cmd {@@ -0,0 +1,269 @@
1+package ui2+3+import (4+ "errors"5+ "strconv"6+ "strings"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/macro"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Macros, as Google Sheets' Extensions > Macros, under Data > Macros:15+// record what you do (with absolute or relative references), save it16+// with a name and an optional Ctrl+Alt+Shift+digit shortcut, run it from17+// the menu, the palette or its shortcut, and manage saved macros. Macros18+// are Starlark scripts kept in the .012 file (see docs/macros.md);19+// recorded ones are scripts too, readable and editable. Recording is in20+// macrorec.go, running in macrorun.go, what scripts act on in21+// macrohost.go, and the manager in macromanage.go.22+23+// macroItems is Data > Macros.24+var macroItems = []menuItem{25+ {cmd: "macro.record"}, {cmd: "macro.record_relative"}, {cmd: "macro.stop"}, {cmd: "macro.discard"}, sep,26+ {cmd: "macro.run"}, {cmd: "macro.manage"}, {cmd: "macro.new"},27+}28+29+func init() {30+ idle := func(m *Model) bool { return m.rec == nil && m.macros.run == nil }31+ recording := func(m *Model) bool { return m.rec != nil }32+ register(33+ &command{id: "macro.record", title: "Record macro", macro: macroNever, enabled: idle,34+ desc: "Record what you do as a macro that selects the same cells (absolute references)",35+ run: func(m *Model) tea.Cmd { m.startRecording(false); return nil }},36+ &command{id: "macro.record_relative", title: "Record macro with relative references", macro: macroNever, enabled: idle,37+ desc: "Record what you do as a macro that moves from wherever the active cell is",38+ run: func(m *Model) tea.Cmd { m.startRecording(true); return nil }},39+ &command{id: "macro.stop", title: "Stop and save recording", macro: macroNever, enabled: recording,40+ desc: "Stop recording and save the macro with a name and an optional shortcut",41+ run: (*Model).stopRecording},42+ &command{id: "macro.discard", title: "Discard recording", macro: macroNever, enabled: recording,43+ desc: "Stop recording without saving; what you did stays done",44+ run: func(m *Model) tea.Cmd {45+ m.rec = nil46+ m.note = "Recording discarded"47+ return nil48+ }},49+ &command{id: "macro.run", title: "Run macro", macro: macroNever,50+ enabled: func(m *Model) bool { return idle(m) && len(m.book().Macros()) > 0 },51+ desc: "Pick a saved macro and run it",52+ run: func(m *Model) tea.Cmd {53+ p := newPicker(m, "Run macro", "Type a macro's name", 60, macroRunItems(m))54+ m.openOverlay(p)55+ return nil56+ }},57+ &command{id: "macro.manage", title: "Manage macros", macro: macroNever, enabled: idle,58+ desc: "Rename, delete, give shortcuts to or edit the saved macros",59+ run: func(m *Model) tea.Cmd { m.openMacros(""); return nil }},60+ &command{id: "macro.new", title: "Write a macro", macro: macroNever, enabled: idle,61+ desc: "Write a macro script in your editor ($VISUAL or $EDITOR)",62+ run: (*Model).newMacro},63+ )64+ // Ctrl+Alt+Shift+digit arrives as Ctrl+Alt with the shifted symbol65+ // from terminals without the kitty keyboard protocol (US layout).66+ for i, sym := range "!@#$%^&*()" {67+ d := string("1234567890"[i])68+ keyAliases["ctrl+alt+"+string(sym)] = "ctrl+alt+shift+" + d69+ keyAliases["ctrl+alt+shift+"+string(sym)] = "ctrl+alt+shift+" + d70+ }71+}72+73+// macroShortcut returns the digit of a macro shortcut key.74+func macroShortcut(key string) (string, bool) {75+ d, ok := strings.CutPrefix(key, "ctrl+alt+shift+")76+ return d, ok && len(d) == 1 && d[0] >= '0' && d[0] <= '9'77+}78+79+// shortcutLabel is how a macro's shortcut is shown, e.g. Ctrl+Alt+Shift+1.80+func shortcutLabel(key string) string {81+ if key == "" {82+ return ""83+ }84+ return "Ctrl+Alt+Shift+" + key85+}86+87+// runShortcut runs the macro a Ctrl+Alt+Shift+digit key is for, and88+// reports whether the key is one.89+func (m *Model) runShortcut(key string) (tea.Cmd, bool) {90+ d, ok := macroShortcut(canonicalKey(key))91+ if !ok {92+ return nil, false93+ }94+ mc, found := m.book().MacroForKey(d)95+ if !found {96+ m.note = shortcutLabel(d) + " runs no macro; give one a shortcut in Data > Macros > Manage macros"97+ return nil, true98+ }99+ return m.runMacro(mc), true100+}101+102+// macroRunItems lists the saved macros for the run picker and the103+// palette; picking one runs it.104+func macroRunItems(m *Model) []pickItem {105+ var items []pickItem106+ for _, mc := range m.book().Macros() {107+ items = append(items, pickItem{108+ title: mc.Name, name: len(mc.Name), detail: "Data › Macros", key: shortcutLabel(mc.Key),109+ desc: "Run the macro " + mc.Name, off: m.rec != nil,110+ pick: func(m *Model) tea.Cmd {111+ m.closeOverlay()112+ return m.runMacro(mc)113+ },114+ })115+ }116+ return items117+}118+119+// runMacro runs mc, asking first when this file's macros came from120+// another computer.121+func (m *Model) runMacro(mc sheet.Macro) tea.Cmd {122+ switch {123+ case m.macros.run != nil:124+ return nil125+ case m.rec != nil:126+ m.note = "Stop recording before running a macro"127+ return nil128+ case m.macroTrusted():129+ return m.startMacro(mc)130+ }131+ m.openOverlay(&choiceBar{132+ msg: "This file's macros were made on another computer and can change it. Run them?",133+ warn: true,134+ choices: []choice{135+ {key: "enter", label: "Trust and run", run: func(m *Model) tea.Cmd {136+ m.trustHere()137+ return m.startMacro(mc)138+ }},139+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},140+ },141+ })142+ return nil143+}144+145+// macroTrusted reports whether this file's macros may run without146+// asking: they were made or trusted on this computer, or the user said147+// so this session.148+func (m *Model) macroTrusted() bool {149+ o := m.book().MacroOrigin()150+ return m.macros.trusted || o != "" && o == m.macros.machine151+}152+153+// trustHere trusts the file's macros from now on, on this computer: the154+// origin saved with the file becomes this one.155+func (m *Model) trustHere() {156+ m.macros.trusted = true157+ if m.macros.machine != "" {158+ m.book().SetMacroOrigin(m.macros.machine)159+ }160+}161+162+// SetMachine tells the model which computer it runs on, as an id kept in163+// the user's configuration, so macros saved here run without asking and164+// macros from files made elsewhere ask once.165+func (m *Model) SetMachine(id string) { m.macros.machine = id }166+167+// madeHere is called when the user saves a macro of their own: it trusts168+// the file's macros unless others came from elsewhere untrusted.169+func (m *Model) madeHere(before int) {170+ if before == 0 || m.macroTrusted() {171+ m.trustHere()172+ }173+}174+175+// stopRecording asks for the new macro's name and then its shortcut.176+// Esc at either question goes back to recording.177+func (m *Model) stopRecording() tea.Cmd {178+ r := m.rec179+ r.flush(m)180+ if len(r.actions) == 0 {181+ m.rec = nil182+ m.note = "Nothing was recorded, so there's no macro to save"183+ return nil184+ }185+ m.openText("Save macro as:", nextMacroName(m.book()), func(m *Model, name string) tea.Cmd {186+ if err := checkNewMacroName(m.book(), name); err != nil {187+ m.fail(err.Error() + "; still recording")188+ return nil189+ }190+ m.askShortcut(name)191+ return nil192+ })193+ m.prompt.indicator = "NAME"194+ m.prompt.onCancel = stillRecording195+ return nil196+}197+198+func stillRecording(m *Model) { m.note = "Still recording" }199+200+// checkNewMacroName reports why name can't name a new macro.201+func checkNewMacroName(w *sheet.Workbook, name string) error {202+ if err := sheet.ValidMacroName(name); err != nil {203+ return err204+ }205+ if mc, ok := w.Macro(strings.TrimSpace(name)); ok {206+ return errors.New("There's already a macro named " + mc.Name)207+ }208+ return nil209+}210+211+// askShortcut asks for the digit of the new macro's shortcut, offering212+// the first one free.213+func (m *Model) askShortcut(name string) {214+ m.openText("Shortcut Ctrl+Alt+Shift+ (a digit, or empty for none):", freeShortcut(m.book()), func(m *Model, key string) tea.Cmd {215+ if key != "" && !isShortcutDigit(key) {216+ m.fail("A shortcut is one digit, 0 to 9, or nothing; still recording")217+ return nil218+ }219+ m.saveRecording(name, key)220+ return nil221+ })222+ m.prompt.indicator = "KEY"223+ m.prompt.onCancel = stillRecording224+}225+226+func isShortcutDigit(s string) bool { return len(s) == 1 && s[0] >= '0' && s[0] <= '9' }227+228+// saveRecording saves the recording as a macro and stops recording.229+func (m *Model) saveRecording(name, key string) {230+ r := m.rec231+ before := len(m.book().Macros())232+ mc := sheet.Macro{Name: name, Key: key, Source: macro.Source(recordingHeader(r.relative), r.actions)}233+ if err := m.book().SaveMacro("", mc, "record macro "+strings.TrimSpace(name)); err != nil {234+ m.fail(err.Error() + "; still recording")235+ return236+ }237+ m.rec = nil238+ m.madeHere(before)239+ m.note = "Saved macro " + strings.TrimSpace(name)240+ if key != "" {241+ m.note += "; " + shortcutLabel(key) + " runs it"242+ } else {243+ m.note += "; run it from Data > Macros or the palette"244+ }245+}246+247+// nextMacroName is the first free "Macro N".248+func nextMacroName(w *sheet.Workbook) string {249+ for i := 1; ; i++ {250+ if name := "Macro " + strconv.Itoa(i); !hasMacro(w, name) {251+ return name252+ }253+ }254+}255+256+func hasMacro(w *sheet.Workbook, name string) bool {257+ _, ok := w.Macro(name)258+ return ok259+}260+261+// freeShortcut is the first digit, 1 to 9 then 0, no macro uses.262+func freeShortcut(w *sheet.Workbook) string {263+ for _, d := range "1234567890" {264+ if _, taken := w.MacroForKey(string(d)); !taken {265+ return string(d)266+ }267+ }268+ return ""269+}@@ -0,0 +1,468 @@
1+package ui2+3+import (4+ "bytes"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// record records keys as a macro named name with the shortcut key, from16+// wherever m is, and returns its script.17+func record(t *testing.T, m *Model, relative bool, name, key string, keys ...string) string {18+ t.Helper()19+ id := "macro.record"20+ if relative {21+ id = "macro.record_relative"22+ }23+ send(m, nil) // an idle update, as between keys24+ run(m, m.runCommand(id))25+ if m.rec == nil {26+ t.Fatal("not recording")27+ }28+ if !strings.Contains(line(m, menuLine), "REC") {29+ t.Errorf("no REC chip: %q", line(m, menuLine))30+ }31+ press(t, m, keys...)32+ run(m, m.runCommand("macro.stop"))33+ if m.mode != modePrompt || !strings.HasPrefix(m.prompt.label, "Save macro as") {34+ t.Fatalf("no name prompt: mode %v", m.mode)35+ }36+ m.line.set(name)37+ press(t, m, "<enter>")38+ m.line.set(key)39+ press(t, m, "<enter>")40+ if m.rec != nil {41+ t.Fatalf("still recording: %q %q", m.note, m.errMsg)42+ }43+ mc, ok := m.book().Macro(name)44+ if !ok {45+ t.Fatalf("no macro %s: %q", name, m.errMsg)46+ }47+ return mc.Source48+}49+50+// body is a script without its header comment.51+func body(src string) string {52+ var out []string53+ for _, l := range strings.Split(strings.TrimSpace(src), "\n") {54+ if !strings.HasPrefix(l, "# Recorded") && !strings.HasPrefix(l, "# Edit it") {55+ out = append(out, l)56+ }57+ }58+ return strings.Join(out, "\n")59+}60+61+// fileOf is the workbook as saved, for comparing two of them.62+func fileOf(t *testing.T, m *Model) string {63+ t.Helper()64+ var b bytes.Buffer65+ if err := m.book().Write(&b); err != nil {66+ t.Fatal(err)67+ }68+ return b.String()69+}70+71+// withMacro gives a fresh model the macro from another, trusted.72+func withMacro(t *testing.T, from *Model, name string) *Model {73+ t.Helper()74+ m := newModel()75+ mc, _ := from.book().Macro(name)76+ if err := m.book().SaveMacro("", mc, "add"); err != nil {77+ t.Fatal(err)78+ }79+ m.trustHere()80+ return m81+}82+83+func TestRecordAbsoluteAndReplay(t *testing.T) {84+ m := newModel()85+ src := record(t, m, false, "Header", "1",86+ "<down>", "Rent", "<tab>", "1450", "<enter>",87+ "<right>", "<right>", "=B2*12", "<enter>",88+ "<up>", "<shift+left>", "<ctrl+b>",89+ "<ctrl+home>")90+ want := `select("A2")91+enter("Rent")92+select("B2")93+enter("1450")94+select("C3")95+enter("=B2*12")96+select("B3:C3", active="C3")97+run("format.bold")98+select("A1")`99+ // The selection is recorded when something acts on it, so Enter's100+ // move to A3 and the arrows to C3 are one select.101+ if got := body(src); got != want {102+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)103+ }104+ if !strings.HasPrefix(src, "# Recorded with absolute references") {105+ t.Errorf("header: %q", src)106+ }107+108+ // Replaying on a new sheet from anywhere does the same.109+ r := withMacro(t, m, "Header")110+ press(t, r, "<down>", "<down>", "<right>")111+ press(t, r, "<ctrl+alt+shift+1>")112+ if r.note != "Ran Header" {113+ t.Fatalf("note %q warn %q err %q", r.note, r.warn, r.errMsg)114+ }115+ if fileOf(t, r) != fileOf(t, m) {116+ t.Errorf("replay differs:\n%s\nrecorded:\n%s", fileOf(t, r), fileOf(t, m))117+ }118+ if r.cur != m.cur || r.selection() != m.selection() {119+ t.Errorf("replay left %v, recording %v", r.selection(), m.selection())120+ }121+}122+123+func TestRecordRelativeReplaysFromTheActiveCell(t *testing.T) {124+ m := newModel()125+ press(t, m, "<right>", "<down>") // B2126+ src := record(t, m, true, "Pair", "",127+ "Name", "<tab>", "=LEN(B2)", "<enter>",128+ "<shift+right>", "<ctrl+i>", "<ctrl+down>")129+ want := `enter("Name")130+move(1, 0)131+enter("=LEN(B2)", origin="C2")132+move(-1, 1)133+extend(1, 0)134+run("format.italic")135+jump("down")`136+ if got := body(src); got != want {137+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)138+ }139+140+ r := withMacro(t, m, "Pair")141+ press(t, r, "<down>", "<down>", "<down>", "<right>", "<right>", "<right>") // D4142+ run(r, r.runMacro(mustMacro(t, r, "Pair")))143+ if got := input(r, "D4") + "|" + input(r, "E4"); got != "Name|=LEN(D4)" {144+ t.Errorf("replayed at D4: %q", got)145+ }146+ if c := r.sheet.Cell(addr("E5")); c == nil || !c.Style.Italic {147+ t.Error("E5 isn't italic")148+ }149+ if r.cur != addr("D8192") {150+ t.Errorf("jump down ended at %v", r.cur)151+ }152+}153+154+func mustMacro(t *testing.T, m *Model, name string) sheet.Macro {155+ t.Helper()156+ mc, ok := m.book().Macro(name)157+ if !ok {158+ t.Fatalf("no macro %s", name)159+ }160+ return mc161+}162+163+func TestReplayIsOneUndoStep(t *testing.T) {164+ m := newModel()165+ record(t, m, false, "Three", "2", "1", "<enter>", "2", "<enter>", "=A1+A2", "<enter>", "<shift+up>", "<ctrl+i>")166+ r := withMacro(t, m, "Three")167+ before := r.book().UndoLabel()168+ press(t, r, "<ctrl+alt+shift+2>")169+ if input(r, "A3") != "=A1+A2" || r.sheet.Value(addr("A3")).Num != 3 {170+ t.Fatalf("A3 %q = %v", input(r, "A3"), r.sheet.Value(addr("A3")))171+ }172+ if got := r.book().UndoLabel(); got != "run macro Three" {173+ t.Errorf("undo step %q (before %q)", got, before)174+ }175+ press(t, r, "<ctrl+z>")176+ if r.sheet.Len() != 0 || r.note != "Undid: run macro Three" {177+ t.Errorf("after undo: %d cells, note %q", r.sheet.Len(), r.note)178+ }179+ if r.book().UndoLabel() != before {180+ t.Errorf("more than one step: %q", r.book().UndoLabel())181+ }182+ press(t, r, "<ctrl+y>")183+ if input(r, "A2") != "2" {184+ t.Error("redo")185+ }186+}187+188+func TestRecordingAnswersAndSkips(t *testing.T) {189+ m := newModel()190+ press(t, m, "x", "<enter>")191+ src := record(t, m, false, "Widths", "",192+ "<f10>", // a menu opened and closed records nothing193+ "<esc>",194+ "<ctrl+z>", // undo isn't recorded; the macro says so195+ )196+ run(m, nil)197+ if !strings.Contains(src, "# Not recorded: undid: enter x in a1") && !strings.Contains(src, "# Not recorded: undid") {198+ t.Errorf("recorded:\n%s", src)199+ }200+201+ m = newModel()202+ run(m, m.runCommand("macro.record"))203+ run(m, m.runCommand("column.width"))204+ m.line.set("15")205+ press(t, m, "<enter>")206+ run(m, m.runCommand("macro.stop"))207+ press(t, m, "<enter>", "<enter>")208+ if got := body(mustMacro(t, m, "Macro 1").Source); got != `run("column.width", answer="15")` {209+ t.Errorf("recorded %q", got)210+ }211+ r := withMacro(t, m, "Macro 1")212+ run(r, r.runMacro(mustMacro(t, r, "Macro 1")))213+ if r.sheet.ColWidth(0) != 15 || r.mode != modeReady {214+ t.Errorf("width %d mode %v %q", r.sheet.ColWidth(0), r.mode, r.warn)215+ }216+}217+218+func TestStopWithNothingRecorded(t *testing.T) {219+ m := newModel()220+ run(m, m.runCommand("macro.record"))221+ run(m, m.runCommand("macro.stop"))222+ if m.rec != nil || !strings.Contains(m.note, "Nothing was recorded") {223+ t.Errorf("rec %v note %q", m.rec, m.note)224+ }225+ // Esc on the name keeps recording.226+ run(m, m.runCommand("macro.record"))227+ press(t, m, "a", "<enter>")228+ run(m, m.runCommand("macro.stop"))229+ press(t, m, "<esc>")230+ if m.rec == nil || m.note != "Still recording" {231+ t.Errorf("rec %v note %q", m.rec, m.note)232+ }233+}234+235+// script saves src as a trusted macro and runs it to the end.236+func script(t *testing.T, m *Model, src string) {237+ t.Helper()238+ old := ""239+ if hasMacro(m.book(), "S") {240+ old = "S"241+ }242+ if err := m.book().SaveMacro(old, sheet.Macro{Name: "S", Source: src}, "add"); err != nil {243+ t.Fatal(err)244+ }245+ m.trustHere()246+ run(m, m.runMacro(mustMacro(t, m, "S")))247+}248+249+func TestScriptsReadWhatTheyWrote(t *testing.T) {250+ m := newModel()251+ script(t, m, `252+set("A1:A3", [[1], [2], [3]])253+set_formula("B1:B3", "A1*10")254+total = 0255+for row in get("B1:B3"):256+ total += row[0]257+set("C1", total)258+set("C2", get_formula("B2"))259+number_format("C1", "currency", decimals=0)260+set("C3", get_number_format("C1"))261+add_sheet("Two")262+set("A1", "on " + active_sheet())263+activate_sheet("Sheet1")264+select("A:B", active="B5")265+set("D1", selection() + " " + active_cell())266+print("total", total)267+`)268+ if m.warn != "" {269+ t.Fatal(m.warn)270+ }271+ for a, want := range map[string]string{"C1": "60", "C2": "=A2*10", "C3": "currency", "D1": "A:B B5"} {272+ if got := input(m, a); got != want {273+ t.Errorf("%s = %q, want %q", a, got, want)274+ }275+ }276+ if two := m.book().Lookup("Two"); two == nil || two.Cell(addr("A1")).Input != "on Two" {277+ t.Error("sheet Two")278+ }279+ if m.whole != wholeCols || m.cur != addr("B5") {280+ t.Errorf("selection %v whole %v", m.selection(), m.whole)281+ }282+ if m.note != "S: total 60" {283+ t.Errorf("note %q", m.note)284+ }285+}286+287+func TestScriptErrorsShowWhere(t *testing.T) {288+ m := newModel()289+ script(t, m, "set(\"A1\", 5)\nrun(\"format.bold\")\nget(\"nowhere\")\n")290+ want := "S:3:4: get: not a cell or range: nowhere Ctrl+Z undoes what it did"291+ if m.warn != want {292+ t.Errorf("warn %q\nwant %q", m.warn, want)293+ }294+ if !strings.Contains(line(m, contextLine), "S:3:4: get: not a cell") {295+ t.Errorf("context line %q", line(m, contextLine))296+ }297+ press(t, m, "<ctrl+z>")298+ if m.sheet.Len() != 0 {299+ t.Errorf("undo left cells: note %q A1 %q undo %q", m.note, input(m, "A1"), m.book().UndoLabel())300+ }301+302+ for src, want := range map[string]string{303+ `run("file.save")`: "S:1:4: run: Save (file.save) can't run in a macro",304+ `run("column.width")`: `S:1:4: run: Column width asks "Column width (1-240)": give run("column.width", answer=...)`,305+ `run("no.such")`: `S:1:4: run: no command "no.such"`,306+ `run("data.sort_range")`: "S:1:4: run: Sort range opens a dialog; it can't run in a macro",307+ "move(0, -1)": "S:1:5: move: moving 0, -1 from A1 goes off the sheet",308+ } {309+ m := newModel()310+ script(t, m, src)311+ if !strings.HasPrefix(m.warn, want) {312+ t.Errorf("%s: %q, want %q", src, m.warn, want)313+ }314+ if m.mode != modeReady || m.overlay != nil || m.prompt != nil {315+ t.Errorf("%s left mode %v", src, m.mode)316+ }317+ }318+}319+320+func TestStepLimitStopsARunaway(t *testing.T) {321+ defer func(n uint64) { macroMaxSteps = n }(macroMaxSteps)322+ macroMaxSteps = 50_000323+ m := newModel()324+ script(t, m, "set(\"A1\", 1)\nwhile True:\n pass\n")325+ if !strings.HasPrefix(m.warn, "S:") || !strings.Contains(m.warn, ": stopped after 50") || !strings.HasSuffix(m.warn, "Ctrl+Z undoes what it did") {326+ t.Errorf("warn %q", m.warn)327+ }328+ if m.macros.run != nil || m.indicator() != "READY" {329+ t.Errorf("still running: %q", m.indicator())330+ }331+}332+333+func TestEscStopsARun(t *testing.T) {334+ m := newModel()335+ m.book().SaveMacro("", sheet.Macro{Name: "Spin", Source: "while True:\n move(0, 0)\n"}, "add")336+ m.trustHere()337+ cmd := m.runMacro(mustMacro(t, m, "Spin"))338+ if m.indicator() != "CMD" || !strings.Contains(line(m, contextLine), "Running Spin") {339+ t.Errorf("indicator %q, context %q", m.indicator(), line(m, contextLine))340+ }341+ msg := cmd()342+ press(t, m, "x") // held back while running343+ m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})344+ for range 1000 {345+ if _, done := msg.(macroDoneMsg); done {346+ break347+ }348+ _, next := m.Update(msg)349+ if next == nil {350+ break351+ }352+ msg = next()353+ if b, ok := msg.(tea.BatchMsg); ok {354+ msg = b[len(b)-1]()355+ }356+ }357+ if m.macros.run != nil {358+ m.Update(msg)359+ }360+ if m.macros.run != nil || m.warn != "Stopped Spin with Esc" || m.sheet.Len() != 0 {361+ t.Errorf("run %v warn %q cells %d", m.macros.run, m.warn, m.sheet.Len())362+ }363+}364+365+func TestMacrosFromElsewhereAskOnce(t *testing.T) {366+ w := sheet.NewBook()367+ w.SaveMacro("", sheet.Macro{Name: "Hi", Key: "3", Source: `set("A1", "hi")`}, "add")368+ w.SetMacroOrigin("someone-else")369+ var b bytes.Buffer370+ w.Write(&b)371+ s, err := sheet.Read(&b)372+ if err != nil {373+ t.Fatal(err)374+ }375+ m := New(s, "hi.012")376+ m.SetMachine("this-one")377+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 20})378+ if m.sheet.Len() != 0 {379+ t.Fatal("opening ran a macro")380+ }381+ press(t, m, "<ctrl+alt+#>") // Ctrl+Alt+Shift+3 without the kitty protocol382+ if !strings.Contains(line(m, contextLine), "made on another computer") {383+ t.Fatalf("no trust question: %q", line(m, contextLine))384+ }385+ press(t, m, "<esc>")386+ if m.sheet.Len() != 0 {387+ t.Fatal("ran after cancel")388+ }389+ press(t, m, "<ctrl+alt+shift+3>", "<enter>")390+ if input(m, "A1") != "hi" || m.book().MacroOrigin() != "this-one" {391+ t.Fatalf("A1 %q origin %q", input(m, "A1"), m.book().MacroOrigin())392+ }393+ press(t, m, "<delete>", "<ctrl+alt+shift+3>")394+ if input(m, "A1") != "hi" {395+ t.Error("asked twice")396+ }397+}398+399+func TestPaletteAndPickerRunMacros(t *testing.T) {400+ m := newModel()401+ record(t, m, false, "Stamp", "5", "stamp", "<enter>")402+ press(t, m, "<ctrl+k>", "stamp")403+ if !strings.Contains(screen(m), "Ctrl+Alt+Shift+5") {404+ t.Errorf("palette:\n%s", screen(m))405+ }406+ press(t, m, "<enter>")407+ if m.note != "Ran Stamp" {408+ t.Errorf("note %q", m.note)409+ }410+ run(m, m.runCommand("macro.run"))411+ press(t, m, "<enter>")412+ if m.note != "Ran Stamp" || m.overlay != nil {413+ t.Errorf("picker: note %q", m.note)414+ }415+}416+417+func TestManageMacros(t *testing.T) {418+ m := newModel()419+ record(t, m, false, "One", "1", "1", "<enter>")420+ run(m, m.runCommand("macro.manage"))421+ if !strings.Contains(screen(m), "+ Write a macro") || !strings.Contains(screen(m), "One") {422+ t.Fatalf("manager:\n%s", screen(m))423+ }424+ press(t, m, "<down>", "<f2>")425+ m.line.set("Uno")426+ press(t, m, "<enter>")427+ press(t, m, "<f3>")428+ m.line.set("7")429+ press(t, m, "<enter>")430+ mc := mustMacro(t, m, "Uno")431+ if mc.Key != "7" {432+ t.Errorf("key %q", mc.Key)433+ }434+ press(t, m, "<ctrl+d>")435+ if len(m.book().Macros()) != 0 || !strings.Contains(line(m, m.height-1), "Deleted Uno") {436+ t.Errorf("after delete: %v %q", m.book().Macros(), line(m, m.height-1))437+ }438+ press(t, m, "<esc>", "<ctrl+z>")439+ if _, ok := m.book().Macro("Uno"); !ok {440+ t.Error("undo didn't bring it back")441+ }442+}443+444+func TestEditedScriptsAreSavedAndChecked(t *testing.T) {445+ m := newModel()446+ record(t, m, false, "Ed", "", "a", "<enter>")447+ path := filepath.Join(t.TempDir(), "ed.star")448+ os.WriteFile(path, []byte("set(\"B1\", 2)\nset(\"B2\",\n"), 0o600)449+ m.Update(macroEditedMsg{name: "Ed", path: path, orig: "x"})450+ if !strings.Contains(m.warn, "Saved Ed, but it has a mistake at Ed:3:1:") {451+ t.Errorf("warn %q", m.warn)452+ }453+ if mustMacro(t, m, "Ed").Source != "set(\"B1\", 2)\nset(\"B2\",\n" {454+ t.Error("not saved")455+ }456+ if _, err := os.Stat(path); !os.IsNotExist(err) {457+ t.Error("temp file left")458+ }459+460+ // A new script is trusted, and runs.461+ path = filepath.Join(t.TempDir(), "new.star")462+ os.WriteFile(path, []byte("set(\"C1\", 3)\n"), 0o600)463+ m.Update(macroEditedMsg{name: "New", path: path, orig: newMacroTemplate, isNew: true})464+ run(m, m.runMacro(mustMacro(t, m, "New")))465+ if input(m, "C1") != "3" {466+ t.Errorf("C1 %q warn %q", input(m, "C1"), m.warn)467+ }468+}@@ -0,0 +1,243 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// scriptHost is what a running macro acts on: the model, through the14+// same paths a user's keys take (entries, the selection, commands). Its15+// methods run on the UI goroutine, inside the run's undo step; see16+// macrorun.go.17+type scriptHost struct {18+ m *Model19+}20+21+var _ macro.Host = scriptHost{}22+23+// maxReadCells bounds what get and get_formula return at once.24+const maxReadCells = 1 << 1825+26+// resolve finds the sheet and range a script means: "" for the27+// selection, A1 or A1:B3, whole columns (B:D) or rows (3:5), or a named28+// range, any of them after a sheet name.29+func (h scriptHost) resolve(ref string) (*sheet.Sheet, sheet.Rect, error) {30+ m := h.m31+ if strings.TrimSpace(ref) == "" {32+ return m.sheet, m.selection(), nil33+ }34+ name, rest := sheet.SplitSheet(ref)35+ s := m.sheet36+ if name != "" {37+ if s = m.book().Lookup(name); s == nil {38+ return nil, sheet.Rect{}, fmt.Errorf("there's no sheet named %s", name)39+ }40+ }41+ if r, ok := parseWhole(rest); ok {42+ return s, r, nil43+ }44+ if n, ok := m.sheet.LookupName(rest); ok && name == "" {45+ if n.Gone() {46+ return nil, sheet.Rect{}, fmt.Errorf("the range named %s was deleted", n.Name)47+ }48+ return n.Sheet, n.Range, nil49+ }50+ return nil, sheet.Rect{}, fmt.Errorf("not a cell or range: %s", ref)51+}52+53+// parseWhole parses A1, A1:B3, whole columns B:D and whole rows 3:5.54+func parseWhole(s string) (sheet.Rect, bool) {55+ if r, ok := sheet.ParseRange(s); ok {56+ return r, true57+ }58+ from, to, ok := strings.Cut(strings.TrimSpace(s), ":")59+ if !ok {60+ return sheet.Rect{}, false61+ }62+ if a, ok1 := sheet.ParseCol(strings.ToUpper(from)); ok1 {63+ if b, ok2 := sheet.ParseCol(strings.ToUpper(to)); ok2 {64+ return sheet.NewRect(sheet.Addr{Col: a}, sheet.Addr{Col: b, Row: sheet.MaxRows - 1}), true65+ }66+ }67+ a, err1 := strconv.Atoi(from)68+ b, err2 := strconv.Atoi(to)69+ if err1 != nil || err2 != nil || a < 1 || b < 1 || a > sheet.MaxRows || b > sheet.MaxRows {70+ return sheet.Rect{}, false71+ }72+ return sheet.NewRect(sheet.Addr{Row: a - 1}, sheet.Addr{Col: sheet.MaxCols - 1, Row: b - 1}), true73+}74+75+// wholeRef writes r as a script would: B:D for whole columns, 3:5 for76+// whole rows, otherwise A1 or A1:B3.77+func wholeRef(r sheet.Rect) string {78+ allRows := r.From.Row == 0 && r.To.Row == sheet.MaxRows-179+ allCols := r.From.Col == 0 && r.To.Col == sheet.MaxCols-180+ switch {81+ case allRows && !allCols:82+ return sheet.ColName(r.From.Col) + ":" + sheet.ColName(r.To.Col)83+ case allCols && !allRows:84+ return strconv.Itoa(r.From.Row+1) + ":" + strconv.Itoa(r.To.Row+1)85+ }86+ return r.String()87+}88+89+// readRange is r to read, trimmed to the used range when it's larger90+// than that.91+func readRange(s *sheet.Sheet, r sheet.Rect) (sheet.Rect, error) {92+ if used, ok := s.UsedRange(); ok && (r.To.Col > used.To.Col || r.To.Row > used.To.Row) {93+ r.To = sheet.Addr{Col: max(min(r.To.Col, used.To.Col), r.From.Col), Row: max(min(r.To.Row, used.To.Row), r.From.Row)}94+ }95+ if (r.To.Col-r.From.Col+1)*(r.To.Row-r.From.Row+1) > maxReadCells {96+ return r, fmt.Errorf("%s has more than %d cells to read at once", r, maxReadCells)97+ }98+ return r, nil99+}100+101+// each calls fn for the cells of ref, row by row, to read them.102+func (h scriptHost) each(ref string, fn func(s *sheet.Sheet, a sheet.Addr, row int)) error {103+ s, r, err := h.resolve(ref)104+ if err != nil {105+ return err106+ }107+ if r, err = readRange(s, r); err != nil {108+ return err109+ }110+ for row := r.From.Row; row <= r.To.Row; row++ {111+ for col := r.From.Col; col <= r.To.Col; col++ {112+ fn(s, sheet.Addr{Col: col, Row: row}, row-r.From.Row)113+ }114+ }115+ return nil116+}117+118+func (h scriptHost) Values(ref string) ([][]any, error) {119+ var rows [][]any120+ err := h.each(ref, func(s *sheet.Sheet, a sheet.Addr, row int) {121+ if row == len(rows) {122+ rows = append(rows, nil)123+ }124+ rows[row] = append(rows[row], scriptValue(s.Value(a)))125+ })126+ return rows, err127+}128+129+// scriptValue is a value as scripts see it.130+func scriptValue(v sheet.Value) any {131+ switch v.Kind {132+ case sheet.Number:133+ return v.Num134+ case sheet.Text, sheet.Error:135+ return v.Str136+ case sheet.Bool:137+ return v.Num != 0138+ }139+ return nil140+}141+142+func (h scriptHost) Formulas(ref string) ([][]string, error) {143+ var rows [][]string144+ err := h.each(ref, func(s *sheet.Sheet, a sheet.Addr, row int) {145+ if row == len(rows) {146+ rows = append(rows, nil)147+ }148+ f := ""149+ if c := s.Cell(a); c != nil && c.IsFormula() {150+ f = c.Input151+ }152+ rows[row] = append(rows[row], f)153+ })154+ return rows, err155+}156+157+func (h scriptHost) SetInput(ref, input string) error {158+ s, r, err := h.resolve(ref)159+ if err != nil {160+ return err161+ }162+ if n := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1); n > maxReadCells {163+ return fmt.Errorf("%s has more than %d cells to set at once", ref, maxReadCells)164+ }165+ return s.Batch(sheet.Change{Label: "set " + r.String(), Focus: r}, func() error {166+ for row := r.From.Row; row <= r.To.Row; row++ {167+ for col := r.From.Col; col <= r.To.Col; col++ {168+ if err := s.Set(sheet.Addr{Col: col, Row: row}, input); err != nil {169+ return fmt.Errorf("%s: %w", sheet.Addr{Col: col, Row: row}, err)170+ }171+ }172+ }173+ return nil174+ })175+}176+177+func (h scriptHost) SetInputs(ref string, rows [][]string) error {178+ s, r, err := h.resolve(ref)179+ if err != nil {180+ return err181+ }182+ return s.Batch(sheet.Change{Label: "set " + r.String(), Focus: r}, func() error {183+ for i, row := range rows {184+ for j, input := range row {185+ a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}186+ if !a.Valid() {187+ return fmt.Errorf("the values run off the sheet at row %d, column %d", i+1, j+1)188+ }189+ if err := s.Set(a, input); err != nil {190+ return fmt.Errorf("%s: %w", a, err)191+ }192+ }193+ }194+ return nil195+ })196+}197+198+func (h scriptHost) SetFormula(ref, f string) error {199+ s, r, err := h.resolve(ref)200+ if err != nil {201+ return err202+ }203+ return s.FillEntry(r, r.From, f)204+}205+206+func (h scriptHost) Clear(ref string) error {207+ s, r, err := h.resolve(ref)208+ if err != nil {209+ return err210+ }211+ s.EraseRange(r)212+ return nil213+}214+215+func (h scriptHost) NumberFormat(ref string) (string, int, error) {216+ s, r, err := h.resolve(ref)217+ if err != nil {218+ return "", 0, err219+ }220+ f := s.DisplayFormat(r.From)221+ return f.Kind.String(), f.Decimals, nil222+}223+224+func (h scriptHost) SetNumberFormat(ref, kind string, decimals int, pattern string) error {225+ s, r, err := h.resolve(ref)226+ if err != nil {227+ return err228+ }229+ k, ok := sheet.ParseFormatKind(kind)230+ if !ok {231+ return fmt.Errorf("no number format %q; try number, percent, currency, date, time or custom", kind)232+ }233+ f := sheet.Preset(k)234+ if decimals >= 0 {235+ f.Decimals = min(decimals, sheet.MaxDecimals)236+ }237+ if k == sheet.FmtCustom && pattern == "" {238+ return errors.New("a custom format needs a pattern, e.g. pattern=\"0.0%\"")239+ }240+ f.Pattern = pattern241+ s.SetFormat(r, f)242+ return nil243+}@@ -0,0 +1,311 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// What scripts do to the selection, the sheets and through commands; the13+// cells are in macrohost.go.14+15+func (h scriptHost) Selection() (string, string) {16+ return wholeRef(h.m.selection()), h.m.cur.String()17+}18+19+func (h scriptHost) Select(ref, active string) error {20+ m := h.m21+ s, r, err := h.resolve(ref)22+ if err != nil {23+ return err24+ }25+ m.showSheet(s)26+ m.selectRect(r)27+ if active == "" {28+ return nil29+ }30+ a, ok := sheet.ParseAddr(active)31+ if !ok || !r.Contains(a) {32+ return fmt.Errorf("the active cell %s isn't in %s", active, ref)33+ }34+ m.setActive(r, a)35+ return nil36+}37+38+// setActive makes a, inside the selected range r, the active cell, with39+// the far corner of r as the other end of the selection.40+func (g *grid) setActive(r sheet.Rect, a sheet.Addr) {41+ far := func(v, lo, hi int) int {42+ if v == lo {43+ return hi44+ }45+ return lo46+ }47+ g.cur = a48+ switch g.whole {49+ case wholeCols:50+ g.ext = sheet.Addr{Col: far(a.Col, r.From.Col, r.To.Col), Row: a.Row}51+ case wholeRows:52+ g.ext = sheet.Addr{Col: a.Col, Row: far(a.Row, r.From.Row, r.To.Row)}53+ case wholeNone:54+ g.ext = sheet.Addr{Col: far(a.Col, r.From.Col, r.To.Col), Row: far(a.Row, r.From.Row, r.To.Row)}55+ }56+}57+58+func (h scriptHost) Move(cols, rows int) error {59+ m := h.m60+ to := sheet.Addr{Col: m.cur.Col + cols, Row: m.cur.Row + rows}61+ if !to.Valid() {62+ return fmt.Errorf("moving %d, %d from %s goes off the sheet", cols, rows, m.cur)63+ }64+ m.cur = to65+ m.clearSelection()66+ return nil67+}68+69+func (h scriptHost) Extend(cols, rows int, whole string) error {70+ m := h.m71+ to := sheet.Addr{Col: m.cur.Col + cols, Row: m.cur.Row + rows}72+ if !to.Valid() {73+ return fmt.Errorf("extending %d, %d from %s goes off the sheet", cols, rows, m.cur)74+ }75+ m.selecting, m.ext, m.whole = true, to, wholeNone76+ switch whole {77+ case "columns":78+ m.whole = wholeCols79+ case "rows":80+ m.whole = wholeRows81+ }82+ return nil83+}84+85+// jumpKeys are the keys Jump's targets stand for.86+var jumpKeys = map[string]string{87+ "up": "ctrl+up", "down": "ctrl+down", "left": "ctrl+left", "right": "ctrl+right",88+ "home": "home", "start": "ctrl+home", "end": "ctrl+end",89+}90+91+func (h scriptHost) Jump(to string, extend bool) error {92+ key, ok := jumpKeys[to]93+ if !ok {94+ return fmt.Errorf("can't jump to %q", to)95+ }96+ if extend {97+ key = strings.Replace(key, "+", "+shift+", 1)98+ if !strings.Contains(key, "+") {99+ key = "shift+" + key100+ }101+ }102+ h.m.moveKey(key)103+ return nil104+}105+106+func (h scriptHost) Enter(text string, fill bool, origin string) error {107+ m := h.m108+ if origin != "" {109+ from, ok := sheet.ParseAddr(origin)110+ if !ok {111+ return fmt.Errorf("origin %q isn't a cell", origin)112+ }113+ var err error114+ if text, err = sheet.ShiftEntry(text, m.cur.Col-from.Col, m.cur.Row-from.Row); err != nil {115+ return err116+ }117+ }118+ if fill {119+ return m.sheet.FillEntry(m.selection(), m.cur, text)120+ }121+ if err := m.sheet.Set(m.cur, text); err != nil {122+ return err123+ }124+ m.clearSelection()125+ return nil126+}127+128+func (h scriptHost) PasteText(text string) error {129+ m := h.m130+ if !m.pasteText(text) {131+ if err := m.sheet.Set(m.cur, strings.TrimSpace(text)); err != nil {132+ return err133+ }134+ }135+ if m.mode == modeError {136+ return h.failure()137+ }138+ return nil139+}140+141+// failure turns an error a command reported to the user into the142+// script's error, leaving the screen as it was.143+func (h scriptHost) failure() error {144+ m := h.m145+ msg := m.errMsg146+ m.errMsg, m.mode = "", modeReady147+ return errors.New(msg)148+}149+150+func (h scriptHost) Sheets() []string {151+ var names []string152+ for _, s := range h.m.book().Sheets() {153+ names = append(names, s.Name())154+ }155+ return names156+}157+158+func (h scriptHost) ActiveSheet() string { return h.m.sheet.Name() }159+160+func (h scriptHost) ActivateSheet(name string) error {161+ s := h.m.book().Lookup(name)162+ if s == nil {163+ return fmt.Errorf("there's no sheet named %s", name)164+ }165+ h.m.showSheet(s)166+ return nil167+}168+169+func (h scriptHost) AddSheet(name string) (string, error) {170+ m := h.m171+ s, err := m.book().AddSheet(name, m.book().Index(m.sheet)+1)172+ if err != nil {173+ return "", err174+ }175+ m.showSheet(s)176+ return s.Name(), nil177+}178+179+func (h scriptHost) MoveSheet(pos int) error {180+ m := h.m181+ if pos < 1 || pos > m.book().Len() {182+ return fmt.Errorf("position %d isn't between 1 and %d", pos, m.book().Len())183+ }184+ m.book().MoveSheet(m.sheet, pos-1)185+ return nil186+}187+188+func (h scriptHost) SetWidth(cols string, width int) error {189+ r, ok := parseWhole(cols)190+ if !ok {191+ if c, isCol := sheet.ParseCol(strings.ToUpper(cols)); isCol {192+ r, ok = sheet.Rect{From: sheet.Addr{Col: c}, To: sheet.Addr{Col: c}}, true193+ }194+ }195+ if !ok {196+ return fmt.Errorf("not a column or columns: %s", cols)197+ }198+ if width < 1 || width > 240 {199+ return errors.New("column width must be between 1 and 240")200+ }201+ for c := r.From.Col; c <= r.To.Col; c++ {202+ h.m.sheet.SetColWidth(c, width)203+ }204+ return nil205+}206+207+func (h scriptHost) Fill(to string, rows, cols int) error {208+ m := h.m209+ src := m.selection()210+ dst := src211+ switch {212+ case to != "":213+ s, r, err := h.resolve(to)214+ if err != nil {215+ return err216+ }217+ if s != m.sheet {218+ return errors.New("fill stays on the sheet shown")219+ }220+ dst = r221+ case rows > 0:222+ dst.To.Row += rows223+ case rows < 0:224+ dst.From.Row += rows225+ case cols > 0:226+ dst.To.Col += cols227+ case cols < 0:228+ dst.From.Col += cols229+ }230+ if !dst.From.Valid() || !dst.To.Valid() {231+ return errors.New("the fill goes off the sheet")232+ }233+ got, err := m.sheet.FillSeries(src, dst)234+ if err != nil {235+ return err236+ }237+ m.selectRect(got)238+ return nil239+}240+241+// Run runs a command as if picked from the palette, answering the242+// question it asks, if any.243+func (h scriptHost) Run(id string, answer *string) error {244+ m := h.m245+ c, ok := commands[id]246+ switch {247+ case !ok:248+ return fmt.Errorf("no command %q; the palette (Ctrl+K) lists them, and docs/macros.md how to find ids", id)249+ case c.macro == macroNever:250+ return fmt.Errorf("%s (%s) can't run in a macro", c.title, id)251+ case !c.available(m):252+ return fmt.Errorf("%s can't run now", c.title)253+ }254+ m.macros.cmds = append(m.macros.cmds, m.runCommand(id))255+ return h.answer(c, answer)256+}257+258+// answer answers the question a command left open, or backs out of it259+// and reports that it needed one.260+func (h scriptHost) answer(c *command, answer *string) error {261+ m := h.m262+ switch {263+ case m.mode == modeError:264+ return h.failure()265+ case m.mode == modePrompt && answer != nil:266+ p := m.prompt267+ m.line.clear()268+ m.line.insert(*answer)269+ p.fresh, p.typing = false, p.kind == promptRange270+ m.macros.cmds = append(m.macros.cmds, p.accept(m))271+ case m.mode == modePrompt:272+ label := m.prompt.label273+ m.prompt.cancel(m)274+ return fmt.Errorf("%s asks %q: give run(%q, answer=...)", c.title, strings.TrimSuffix(label, ":"), c.id)275+ case m.overlay != nil:276+ return h.choose(c, answer)277+ }278+ if m.mode == modeError {279+ return h.failure()280+ }281+ if m.mode != modeReady {282+ m.closeOverlay()283+ return fmt.Errorf("%s asks more than one question; it can't run in a macro", c.title)284+ }285+ return nil286+}287+288+// choose answers a choice bar with the key or label of a choice; other289+// overlays (dialogs, pickers) can't be answered by a script.290+func (h scriptHost) choose(c *command, answer *string) error {291+ m := h.m292+ bar, ok := m.overlay.(*choiceBar)293+ if !ok {294+ m.closeOverlay()295+ return fmt.Errorf("%s opens a dialog; it can't run in a macro", c.title)296+ }297+ if answer != nil {298+ i := slices.IndexFunc(bar.choices, func(ch choice) bool {299+ return strings.EqualFold(ch.key, *answer) || strings.EqualFold(ch.label, *answer)300+ })301+ if i >= 0 {302+ m.macros.cmds = append(m.macros.cmds, bar.choose(m, bar.choices[i]))303+ if m.mode == modeError {304+ return h.failure()305+ }306+ return nil307+ }308+ }309+ m.closeOverlay()310+ return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.msg, c.id)311+}@@ -0,0 +1,253 @@
1+package ui2+3+import (4+ "cmp"5+ "fmt"6+ "os"7+ "os/exec"8+ "strconv"9+ "strings"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/macro"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// Managing macros, as Sheets' Extensions > Macros > Manage macros: a18+// picker lists them with their shortcuts; Enter runs one, F2 renames it,19+// F3 changes its shortcut, F4 opens its script in the user's editor, and20+// Ctrl+D deletes it. The first row writes a new one. Questions go on the21+// context line; the picker opens again after each.22+23+// macrosPicker is the picker of saved macros with its extra keys.24+type macrosPicker struct {25+ *picker26+ msg string // feedback on the last action, e.g. a deletion27+}28+29+const writeMacroTitle = "+ Write a macro"30+31+// openMacros opens the manager with the macro named sel highlighted.32+func (m *Model) openMacros(sel string) {33+ p := newPicker(m, "Macros", "Type a macro's name", 72, macroManageItems(m))34+ p.action = "run"35+ for i, pm := range p.shown {36+ if strings.EqualFold(pm.item.title, sel) {37+ p.sel = i38+ }39+ }40+ m.openOverlay(¯osPicker{picker: p})41+}42+43+// macroManageItems lists "Write a macro" and then every macro.44+func macroManageItems(m *Model) []pickItem {45+ items := []pickItem{{46+ title: writeMacroTitle, name: len(writeMacroTitle), desc: "Write a new macro script in your editor",47+ pick: func(m *Model) tea.Cmd {48+ m.closeOverlay()49+ return m.newMacro()50+ },51+ }}52+ for _, mc := range m.book().Macros() {53+ lines := strings.Count(strings.TrimRight(mc.Source, "\n"), "\n") + 154+ items = append(items, pickItem{55+ title: mc.Name, name: len(mc.Name), detail: strconv.Itoa(lines) + " lines", key: shortcutLabel(mc.Key),56+ desc: "Run " + mc.Name + "; F4 shows its script",57+ pick: func(m *Model) tea.Cmd {58+ m.closeOverlay()59+ return m.runMacro(mc)60+ },61+ })62+ }63+ return items64+}65+66+// current is the macro highlighted, if any.67+func (p *macrosPicker) current(m *Model) (sheet.Macro, bool) {68+ if p.picker.sel >= len(p.shown) || p.shown[p.picker.sel].item.title == writeMacroTitle {69+ return sheet.Macro{}, false70+ }71+ return m.book().Macro(p.shown[p.picker.sel].item.title)72+}73+74+func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {75+ p.msg = ""76+ mc, ok := p.current(m)77+ switch k.String() {78+ case "f2", "f3", "f4", "ctrl+d":79+ if !ok {80+ return nil81+ }82+ default:83+ return p.picker.key(m, k)84+ }85+ switch k.String() {86+ case "f2":87+ m.closeOverlay()88+ m.renameMacro(mc)89+ case "f3":90+ m.closeOverlay()91+ m.setMacroShortcut(mc)92+ case "f4":93+ m.closeOverlay()94+ return m.editScript(mc.Name, mc.Source, false)95+ case "ctrl+d":96+ m.book().DeleteMacro(mc.Name)97+ p.msg = "Deleted " + mc.Name + "; Ctrl+Z brings it back"98+ p.items = macroManageItems(m)99+ sel := p.picker.sel100+ p.changed(m)101+ p.picker.sel = max(min(sel, len(p.shown)-1), 0)102+ }103+ return nil104+}105+106+func (p *macrosPicker) status(m *Model) (string, string) {107+ keys := m.th.KeyHints("Enter", "run", "F2", "rename", "F3", "shortcut", "F4", "edit", "Ctrl+D", "delete", "Esc", "close")108+ if m.width < 100 {109+ keys = m.th.KeyHints("Enter", "run", "F2", "rename", "F3", "key", "F4", "edit", "Ctrl+D", "delete")110+ }111+ if _, ok := p.current(m); !ok {112+ keys = m.th.KeyHints("Enter", "write", "Esc", "close")113+ }114+ if p.msg != "" {115+ return p.msg, keys116+ }117+ desc, _ := p.picker.status(m)118+ return desc, keys119+}120+121+// renameMacro asks for a new name on the context line.122+func (m *Model) renameMacro(mc sheet.Macro) {123+ m.openText("Rename macro "+mc.Name+":", mc.Name, func(m *Model, name string) tea.Cmd {124+ old := mc.Name125+ mc.Name = name126+ if err := m.book().SaveMacro(old, mc, "rename macro "+old); err != nil {127+ m.fail(err.Error())128+ return nil129+ }130+ m.openMacros(name)131+ return nil132+ })133+ m.prompt.indicator = "NAME"134+}135+136+// setMacroShortcut asks for the digit of a macro's shortcut.137+func (m *Model) setMacroShortcut(mc sheet.Macro) {138+ m.openText("Shortcut for "+mc.Name+": Ctrl+Alt+Shift+ (a digit, or empty for none):", mc.Key, func(m *Model, key string) tea.Cmd {139+ if key != "" && !isShortcutDigit(key) {140+ m.fail("A shortcut is one digit, 0 to 9, or nothing")141+ return nil142+ }143+ mc.Key = key144+ if err := m.book().SaveMacro(mc.Name, mc, "change the shortcut of macro "+mc.Name); err != nil {145+ m.fail(err.Error())146+ return nil147+ }148+ m.openMacros(mc.Name)149+ return nil150+ })151+ m.prompt.indicator = "KEY"152+}153+154+// newMacro asks for a name, then opens a new script in the editor.155+func (m *Model) newMacro() tea.Cmd {156+ m.openText("Name for the new macro:", nextMacroName(m.book()), func(m *Model, name string) tea.Cmd {157+ if err := checkNewMacroName(m.book(), name); err != nil {158+ m.fail(err.Error())159+ return nil160+ }161+ return m.editScript(strings.TrimSpace(name), newMacroTemplate, true)162+ })163+ m.prompt.indicator = "NAME"164+ return nil165+}166+167+const newMacroTemplate = `# A macro written by hand. It runs from the top as one undo step;168+# docs/macros.md lists what it can call. For example:169+#170+# set("A1", "Total")171+# set_formula("B1", "SUM(B2:B100)")172+# select("B1")173+# run("format.bold")174+`175+176+// macroEditedMsg says the editor closed on a macro's script.177+type macroEditedMsg struct {178+ name, path, orig string179+ isNew bool180+ err error181+}182+183+// editScript opens src in the user's editor ($VISUAL, $EDITOR or vi),184+// through a temporary file, and saves it as the macro name when the185+// editor exits. The screen is handed to the editor meanwhile.186+func (m *Model) editScript(name, src string, isNew bool) tea.Cmd {187+ f, err := os.CreateTemp("", "012-macro-*.star")188+ if err == nil {189+ _, err = f.WriteString(src)190+ if cerr := f.Close(); err == nil {191+ err = cerr192+ }193+ }194+ if err != nil {195+ m.fail("Couldn't write the script for the editor: " + err.Error())196+ return nil197+ }198+ msg := macroEditedMsg{name: name, path: f.Name(), orig: src, isNew: isNew}199+ return tea.ExecProcess(editorCommand(f.Name()), func(err error) tea.Msg {200+ msg.err = err201+ return msg202+ })203+}204+205+// editorCommand runs the user's editor on path.206+func editorCommand(path string) *exec.Cmd {207+ editor := strings.Fields(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"), "vi"))208+ return exec.Command(editor[0], append(editor[1:], path)...)209+}210+211+// macroEdited saves the script the editor wrote, and checks it: a212+// mistake is shown with its line and column, and the script is kept so213+// it can be fixed.214+func (m *Model) macroEdited(msg macroEditedMsg) {215+ defer os.Remove(msg.path)216+ if msg.err != nil {217+ m.fail(fmt.Sprintf("The editor failed: %v; the script is unchanged", msg.err))218+ return219+ }220+ data, err := os.ReadFile(msg.path)221+ if err != nil {222+ m.fail("Couldn't read the edited script: " + err.Error())223+ return224+ }225+ src := string(data)226+ if !msg.isNew && src == msg.orig {227+ m.note = "No changes to " + msg.name228+ return229+ }230+ before := len(m.book().Macros())231+ mc, old, label := sheet.Macro{Name: msg.name, Source: src}, "", "write macro "+msg.name232+ if !msg.isNew {233+ cur, ok := m.book().Macro(msg.name)234+ if !ok {235+ m.fail("The macro " + msg.name + " is gone; the edit wasn't saved")236+ return237+ }238+ mc, old, label = cur, cur.Name, "edit macro "+msg.name239+ mc.Source = src240+ }241+ if err := m.book().SaveMacro(old, mc, label); err != nil {242+ m.fail(err.Error())243+ return244+ }245+ if msg.isNew {246+ m.madeHere(before)247+ }248+ if err := macro.Check(msg.name, src); err != nil {249+ m.warn = "Saved " + msg.name + ", but it has a mistake at " + err.Error()250+ return251+ }252+ m.note = "Saved " + msg.name253+}@@ -0,0 +1,260 @@
1+package ui2+3+import (4+ "cmp"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Recording a macro, as Sheets' Extensions > Macros > Record macro: while14+// recording, what the user does becomes a list of actions (the command15+// log), written out as a script when the recording is saved.16+//17+// What's recorded:18+// - commands that change the workbook, by id, where runCommand runs19+// them, with the answer to the question they ask (a width, a name, a20+// confirmation) once it's given;21+// - entries, when they're stored (enter), and pastes of text;22+// - the fill handle, column borders dragged or fitted, and tabs dragged;23+// - the selection, not the keys or clicks that made it: just before24+// something is recorded, the selection it acts on is, as select() with25+// absolute references or as move() and extend() from the last one with26+// relative references. So pressing Down five times is one move(0, 5),27+// and a replay doesn't depend on the window's size.28+//29+// With relative references, Ctrl+arrows, Home, Ctrl+Home and Ctrl+End are30+// recorded as jump(), since where they land depends on the data. Anything31+// else that changes the workbook (a dialog such as sorting by several32+// columns, the filter picker, dragging a chart, undo) becomes a comment33+// saying it wasn't recorded, so the script never silently differs from34+// what was done.35+36+// recorder is a recording in progress.37+type recorder struct {38+ relative bool39+ actions []macro.Action40+ base selState // the selection the recording has reached41+ pending string // a command waiting for the answer to its question42+ acted bool // something was recorded during this input event43+ depth int // commands running inside a recorded one44+}45+46+// selState is the selection as a recording sees it.47+type selState struct {48+ sheet *sheet.Sheet49+ cur, ext sheet.Addr50+ selecting bool51+ whole wholeKind52+}53+54+func (g *grid) selState() selState {55+ return selState{g.sheet, g.cur, g.ext, g.selecting, g.whole}56+}57+58+func (s selState) rect() sheet.Rect {59+ return (&grid{cur: s.cur, ext: s.ext, selecting: s.selecting, whole: s.whole}).selection()60+}61+62+// startRecording starts recording, from the selection as it is.63+func (m *Model) startRecording(relative bool) {64+ m.rec = &recorder{relative: relative, base: m.selState()}65+ m.note = "Recording a macro: what you do now is saved when you stop"66+}67+68+// add records a, taking the selection after it as the new base.69+func (r *recorder) add(m *Model, a macro.Action) {70+ r.actions = append(r.actions, a)71+ r.base = m.selState()72+ r.acted = true73+}74+75+// flush records the selection, if it moved since the last action.76+func (r *recorder) flush(m *Model) {77+ now := m.selState()78+ if now == r.base {79+ return80+ }81+ r.actions = append(r.actions, r.selection(r.base, now)...)82+ r.base = now83+}84+85+// selection records the move from one selection to another.86+func (r *recorder) selection(from, to selState) []macro.Action {87+ if !r.relative || from.sheet != to.sheet || to.whole == wholeAll {88+ return []macro.Action{absoluteSelect(from, to)}89+ }90+ var out []macro.Action91+ dc, dr := to.cur.Col-from.cur.Col, to.cur.Row-from.cur.Row92+ if dc != 0 || dr != 0 || from.selecting && !to.selecting {93+ out = append(out, macro.Call("move", dc, dr))94+ }95+ if to.selecting && (from.cur != to.cur || from.ext != to.ext || from.whole != to.whole || !from.selecting) {96+ ext := macro.Call("extend", to.ext.Col-to.cur.Col, to.ext.Row-to.cur.Row)97+ switch to.whole {98+ case wholeCols:99+ ext = ext.With("whole", "columns")100+ case wholeRows:101+ ext = ext.With("whole", "rows")102+ }103+ out = append(out, ext)104+ }105+ return out106+}107+108+// absoluteSelect is select() of the selection to, naming its sheet when109+// it's another than from's, and the active cell when it isn't where110+// select() puts it.111+func absoluteSelect(from, to selState) macro.Action {112+ r := to.rect()113+ ref := wholeRef(r)114+ if to.sheet != from.sheet {115+ ref = sheet.QuoteSheet(to.sheet.Name()) + "!" + ref116+ }117+ a := macro.Call("select", ref)118+ if to.whole != wholeNone || to.cur != r.From {119+ a = a.With("active", to.cur.String())120+ }121+ return a122+}123+124+// recordingCommand runs c, recording it if it changes the workbook.125+func (m *Model) recordingCommand(c *command) tea.Cmd {126+ r := m.rec127+ if r.depth > 0 || c.macro != macroRecord {128+ return c.run(m)129+ }130+ r.flush(m)131+ r.depth++132+ cmd := c.run(m)133+ r.depth--134+ _, choosing := m.overlay.(*choiceBar)135+ switch {136+ case m.mode == modePrompt || choosing:137+ r.pending = c.id // recorded once answered138+ case m.overlay != nil:139+ // A dialog or picker: what it does isn't recorded (see observe).140+ case m.mode != modeError:141+ r.add(m, macro.Call("run", c.id))142+ }143+ return cmd144+}145+146+// recordAnswer records the command waiting for the answer to its147+// question, now that it has one. Esc on a choice cancels instead.148+func (m *Model) recordAnswer(answer string, cancelled bool) {149+ r := m.rec150+ if r == nil || r.pending == "" {151+ return152+ }153+ id := r.pending154+ r.pending = ""155+ if !cancelled && m.mode != modeError {156+ r.add(m, macro.Call("run", id).With("answer", answer))157+ }158+}159+160+// recordEntry records an entry stored in the active cell, or in every161+// selected cell with fill (Ctrl+Enter).162+func (m *Model) recordEntry(input string, fill bool) {163+ if m.rec == nil {164+ return165+ }166+ m.rec.flush(m)167+ a := macro.Call("enter", input)168+ if fill {169+ a = a.With("fill", true)170+ }171+ if m.rec.relative && sheet.IsFormulaEntry(input) {172+ a = a.With("origin", m.cur.String()) // references move with the active cell173+ }174+ m.rec.add(m, a)175+}176+177+// recordFlush records the selection an action is about to act on.178+func (m *Model) recordFlush() {179+ if m.rec != nil {180+ m.rec.flush(m)181+ }182+}183+184+// record records a after the selection it acted on (see recordFlush).185+func (m *Model) record(a macro.Action) {186+ if m.rec != nil {187+ m.rec.add(m, a)188+ }189+}190+191+// jumpTargets are the keys recorded as jump() with relative references.192+var jumpTargets = map[string]string{193+ "ctrl+up": "up", "ctrl+down": "down", "ctrl+left": "left", "ctrl+right": "right", "end": "right",194+ "home": "home", "ctrl+home": "start", "ctrl+end": "end",195+}196+197+// recordMove handles a movement key in READY, recording it as jump()198+// when it goes to a place that depends on the data.199+func (m *Model) recordMove(key string) bool {200+ r := m.rec201+ base, extend := extendKey(key)202+ if !extend {203+ base = key204+ }205+ to, jump := jumpTargets[base]206+ if r == nil || !r.relative || !jump {207+ return m.moveKey(key)208+ }209+ r.flush(m)210+ if !m.moveKey(key) {211+ return false212+ }213+ a := macro.Call("jump", to)214+ if extend {215+ a = a.With("extend", true)216+ }217+ r.add(m, a)218+ return true219+}220+221+// observe follows an input event while recording: with relative222+// references a new sheet shown is recorded right away (where its223+// selection starts can't be told later), and a change to the workbook224+// nothing recorded is noted as a comment.225+func (m *Model) observeRecording(before int) {226+ r := m.rec227+ if r == nil || m.mode != modeReady && m.mode != modeError || m.overlay != nil || m.prompt != nil {228+ return229+ }230+ if r.relative && m.sheet != r.base.sheet {231+ r.add(m, macro.Call("activate_sheet", m.sheet.Name()))232+ }233+ if m.sheet.StateID() != before && !r.acted {234+ what := cmp.Or(m.note, m.book().UndoLabel(), "a change")235+ r.actions = append(r.actions, macro.Note("Not recorded: "+strings.ToLower(what[:1])+what[1:]))236+ }237+}238+239+// recordingLine is the context line in READY while recording, when240+// there's nothing else to say.241+func (m *Model) recordingLine() string {242+ if m.rec == nil {243+ return ""244+ }245+ refs := "absolute"246+ if m.rec.relative {247+ refs = "relative"248+ }249+ return m.th.Hint.Render("Recording a macro with " + refs + " references. Stop and save it in Data > Macros.")250+}251+252+// recordingHeader is the comment a recorded script starts with.253+func recordingHeader(relative bool) string {254+ if relative {255+ return "Recorded with relative references: moves count from the active cell.\n" +256+ "Edit it in Data > Macros > Manage macros; docs/macros.md lists what it can call."257+ }258+ return "Recorded with absolute references: cells are named as they were.\n" +259+ "Edit it in Data > Macros > Manage macros; docs/macros.md lists what it can call."260+}@@ -0,0 +1,210 @@
1+package ui2+3+import (4+ "errors"5+ "log/slog"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/macro"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/telemetry"13+)14+15+// 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.21+22+// macroState is the macros' part of the model: a run in progress, and23+// whether this file's macros may run without asking.24+type 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.Cmd29+30+ // 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 bool34+}35+36+// macroRun is a script running.37+type macroRun struct {38+ name string39+ run *macro.Run40+ calls chan func() // from the script: a call to run on the UI goroutine41+ ack chan struct{} // to the script: the call has run42+ done chan macroDone43+ end func() // closes the undo step44+ start time.Time45+ span telemetry.Span46+ print string // the last line the script printed47+}48+49+type macroDone struct {50+ stats macro.Stats51+ err error52+}53+54+// macroCallMsg carries a call from a running script; macroDoneMsg says it55+// finished.56+type (57+ macroCallMsg struct {58+ r *macroRun59+ call func()60+ }61+ macroDoneMsg struct {62+ r *macroRun63+ done macroDone64+ }65+)66+67+// How the UI serves a script's calls: it keeps taking calls within one68+// update for up to sliceBudget, so a busy script isn't held up by a frame69+// per call, and gives the screen back when the script pauses for70+// idleWait (computing on its own) or the slice is used up.71+const (72+ sliceBudget = 12 * time.Millisecond73+ idleWait = 2 * time.Millisecond74+)75+76+// macroMaxSteps bounds every run; tests lower it.77+var macroMaxSteps uint64 = macro.DefaultMaxSteps78+79+// startMacro runs mc's script as one undo step.80+func (m *Model) startMacro(mc sheet.Macro) tea.Cmd {81+ if mc.API > sheet.MacroAPI {82+ m.fail("Macro " + mc.Name + " was written for a newer version of 012")83+ return nil84+ }85+ r := ¯oRun{86+ name: mc.Name, calls: make(chan func()), ack: make(chan struct{}), done: make(chan macroDone, 1),87+ start: time.Now(), span: telemetry.Start("macro"),88+ }89+ r.end = m.book().Begin(sheet.Change{Label: "run macro " + mc.Name, Focus: m.selection(), Sheet: m.sheet})90+ r.run = macro.New(mc.Name, mc.Source, macro.Env{91+ Host: scriptHost{m},92+ Do: func(fn func()) { r.calls <- fn; <-r.ack },93+ Print: func(s string) { r.print = s },94+ MaxSteps: macroMaxSteps,95+ })96+ m.macros.run = r97+ m.note = ""98+ go func() {99+ st, err := r.run.Exec()100+ r.done <- macroDone{st, err}101+ }()102+ return r.wait()103+}104+105+// wait is the command that waits for the script's next call or its end.106+func (r *macroRun) wait() tea.Cmd {107+ return func() tea.Msg {108+ select {109+ case fn := <-r.calls:110+ return macroCallMsg{r, fn}111+ case d := <-r.done:112+ return macroDoneMsg{r, d}113+ }114+ }115+}116+117+// serveMacro runs a script's call, then the calls that follow it for as118+// long as the slice lasts.119+func (m *Model) serveMacro(msg macroCallMsg) tea.Cmd {120+ r := msg.r121+ deadline := time.Now().Add(sliceBudget)122+ m.serveCall(r, msg.call)123+ idle := time.NewTimer(idleWait)124+ defer idle.Stop()125+ for time.Now().Before(deadline) {126+ select {127+ case fn := <-r.calls:128+ m.serveCall(r, fn)129+ idle.Reset(idleWait)130+ case d := <-r.done:131+ return m.finishMacro(r, d)132+ case <-idle.C:133+ return m.macroCmds(r.wait())134+ }135+ }136+ return m.macroCmds(r.wait())137+}138+139+// serveCall runs one call with the values of formulas up to date, and140+// lets the script go on.141+func (m *Model) serveCall(r *macroRun, fn func()) {142+ m.book().Settle()143+ fn()144+ r.ack <- struct{}{}145+}146+147+// macroCmds hands the commands the run's calls returned to Bubble Tea,148+// with next.149+func (m *Model) macroCmds(next tea.Cmd) tea.Cmd {150+ cmds := append(m.macros.cmds, next)151+ m.macros.cmds = nil152+ return tea.Batch(cmds...)153+}154+155+// finishMacro closes the run's undo step and says how it went on the156+// context line: what it printed last, or where it failed.157+func (m *Model) finishMacro(r *macroRun, d macroDone) tea.Cmd {158+ before := m.sheet.StateID()159+ r.end()160+ m.macros.run = nil161+ changed := m.sheet.StateID() != before162+ var me *macro.Error163+ outcome := "ok"164+ switch {165+ case d.err == nil && r.print != "":166+ m.note = r.name + ": " + r.print167+ case d.err == nil:168+ m.note = "Ran " + r.name169+ case errors.As(d.err, &me) && me.Cancel:170+ outcome = "cancelled"171+ m.warn = "Stopped " + r.name + " with Esc"172+ case errors.As(d.err, &me) && me.Limit:173+ outcome = "limit"174+ m.warn = d.err.Error()175+ default:176+ outcome = "error"177+ m.warn = d.err.Error()178+ }179+ if d.err != nil && changed {180+ m.warn += " Ctrl+Z undoes what it did"181+ }182+ r.span.End(slog.Int64("steps", int64(d.stats.Steps)), slog.Int("calls", d.stats.Calls), slog.String("outcome", outcome))183+ return m.macroCmds(nil)184+}185+186+// macroKey handles a key while a macro runs: Esc stops it, nothing else187+// gets through. It reports whether a macro is running.188+func (m *Model) macroKey(k tea.KeyPressMsg) bool {189+ r := m.macros.run190+ if r == nil {191+ return false192+ }193+ if k.String() == "esc" {194+ r.run.Cancel()195+ }196+ return true197+}198+199+// macroRunning reports whether msg is input to hold back while a macro200+// runs, so the script's view of the selection stays its own.201+func (m *Model) macroBusy(msg tea.Msg) bool {202+ if m.macros.run == nil {203+ return false204+ }205+ switch msg.(type) {206+ case tea.MouseMsg, tea.PasteMsg:207+ return true208+ }209+ return false210+}@@ -91,6 +91,7 @@ var menuBar = []menuDef{
9191 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,9292 {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, sep,9393 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,94+ {title: "Macros", items: macroItems}, sep,9495 {cmd: "jev.refresh"},9596 }},9697 {title: "Help", accel: 'h', items: []menuItem{@@ -208,7 +209,7 @@ func barMenuFor(key string) int {
208209 }209210 210211 func init() {211- register(&command{id: "menu", title: "Menu", desc: "Open the menu bar", run: func(m *Model) tea.Cmd {212+ register(&command{id: "menu", macro: macroNever, title: "Menu", desc: "Open the menu bar", run: func(m *Model) tea.Cmd {212213 m.showBarMenu(0)213214 return nil214215 }})@@ -64,6 +64,7 @@ type Model struct {
6464 6565 mode mode6666 note string // feedback on the last action, e.g. "Undid: clear B3"67+ warn string // like note, for something that went wrong, e.g. a macro's error6768 errMsg string // the message ERROR mode shows6869 6970 // Components. Each owns its state and the handling of the input it@@ -82,6 +83,8 @@ type Model struct {
8283 xfer transfer // imports and downloads: transfer.go8384 jev *jevRunner // answers JEV functions; nil without an API key: jev.go8485 term terminal // what the terminal supports: graphics.go86+ rec *recorder // a macro being recorded: macrorec.go87+ macros macroState // a macro running, and trust in the file's macros: macrorun.go8588 8689 keyAt time.Time // when the key the next frame answers was pressed, for telemetry8790 @@ -108,6 +111,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108111 if cmd, ok := m.importing(msg); ok {109112 return m, cmd110113 }114+ if m.macroBusy(msg) {115+ return m, nil116+ }117+ if m.rec != nil {118+ m.rec.acted = false119+ }111120 before, beforeMode := *m.focus(), m.mode112121 state := m.beginUpdate(msg)113122 var cmd tea.Cmd@@ -152,6 +161,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152161 cmd = m.answerJEV(msg)153162 case importedMsg, importTickMsg, exportedMsg:154163 cmd = m.handleTransfer(msg)164+ case macroCallMsg:165+ cmd = m.serveMacro(msg)166+ case macroDoneMsg:167+ cmd = m.finishMacro(msg.r, msg.done)168+ case macroEditedMsg:169+ m.macroEdited(msg)155170 default:156171 cmd = m.term.handle(msg)157172 }@@ -173,7 +188,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
173188 func (m *Model) beginUpdate(msg tea.Msg) int {174189 switch msg.(type) {175190 case tea.KeyPressMsg, tea.MouseClickMsg, tea.PasteMsg:176- m.note = ""191+ m.note, m.warn = "", ""177192 if _, key := msg.(tea.KeyPressMsg); !key {178193 m.trace = nil // keys end a trace in handleKey179194 }@@ -188,6 +203,7 @@ func (m *Model) beginUpdate(msg tea.Msg) int {
188203 // flag follows the undo history, and any edit but a paste clears the copy189204 // marker, as in Sheets.190205 func (m *Model) endUpdate(state int) {206+ m.observeRecording(state)191207 if m.sheet.StateID() != state {192208 m.changed = m.sheet.StateID() != m.saved193209 // An edit, a sort or a filter may hide the active cell's row.@@ -206,7 +222,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
206222 }207223 return nil // the mouse is busy filling208224 }209- if m.traceKey(k) {225+ if m.macroKey(k) || m.traceKey(k) {210226 return nil211227 }212228 switch m.mode {@@ -231,7 +247,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
231247 232248 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {233249 key := k.String()234- if m.moveKey(key) {250+ if m.recordMove(key) {235251 m.entry.tabbing = false236252 return nil237253 }@@ -242,6 +258,9 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
242258 if id, ok := keymap[canonicalKey(key)]; ok {243259 return m.runCommand(id)244260 }261+ if cmd, ok := m.runShortcut(key); ok {262+ return cmd263+ }245264 if text := typed(k); text != "" {246265 m.startEntry(modeEnter, text)247266 }@@ -17,7 +17,7 @@ var named = map[string]tea.Key{
1717 "enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape}, "backspace": {Code: tea.KeyBackspace},1818 "tab": {Code: tea.KeyTab}, "delete": {Code: tea.KeyDelete}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd},1919 "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight},20- "pgdown": {Code: tea.KeyPgDown}, "pgup": {Code: tea.KeyPgUp}, "f1": {Code: tea.KeyF1}, "f2": {Code: tea.KeyF2}, "f5": {Code: tea.KeyF5},20+ "pgdown": {Code: tea.KeyPgDown}, "pgup": {Code: tea.KeyPgUp}, "f1": {Code: tea.KeyF1}, "f2": {Code: tea.KeyF2}, "f3": {Code: tea.KeyF3}, "f4": {Code: tea.KeyF4}, "f5": {Code: tea.KeyF5},2121 "f10": {Code: tea.KeyF10}, "f11": {Code: tea.KeyF11}, "space": {Code: tea.KeySpace},2222 }2323 @@ -333,6 +333,8 @@ func (m *Model) handleRelease() tea.Cmd {
333333 m.finishFill()334334 case dragTab:335335 m.dropTab()336+ case dragResize:337+ m.record(widthAction(m.mouse.resizeCol, m.mouse.resizeCol, m.sheet.ColWidth(m.mouse.resizeCol)))336338 }337339 m.mouse.drag, m.mouse.autoscrolling = dragNone, false338340 if m.selecting && m.whole == wholeNone && m.ext == m.cur {@@ -16,7 +16,7 @@ import (
1616 1717 func init() {1818 register(19- &command{id: "data.named_ranges", title: "Named ranges", desc: "List the named ranges: go to, add, edit or delete them", run: func(m *Model) tea.Cmd {19+ &command{id: "data.named_ranges", macro: macroView, title: "Named ranges", desc: "List the named ranges: go to, add, edit or delete them", run: func(m *Model) tea.Cmd {2020 m.openNames(m.selection())2121 return nil2222 }},@@ -19,7 +19,7 @@ import (
1919 // its shortcut. The function list uses the same picker.2020 2121 func init() {22- register(&command{id: "palette", title: "Search the menus", desc: "Find and run any command by name", run: func(m *Model) tea.Cmd {22+ register(&command{id: "palette", macro: macroNever, title: "Search the menus", desc: "Find and run any command by name", run: func(m *Model) tea.Cmd {2323 m.openOverlay(newPicker(m, "Search the menus", "Type a command, e.g. save or width", 76, paletteItems(m)))2424 return nil2525 }})@@ -58,6 +58,7 @@ func paletteItems(m *Model) []pickItem {
5858 for _, d := range menuBar {5959 walk(visibleItems(d.items), d.title)6060 }61+ items = append(items, macroRunItems(m)...)6162 rest := make([]string, 0, len(commands))6263 for id := range commands {6364 rest = append(rest, id)@@ -90,6 +90,8 @@ func (m *Model) indicator() string {
9090 switch {9191 case m.xfer.job != nil:9292 return "WAIT"93+ case m.macros.run != nil:94+ return "CMD" // 1-2-3's indicator while a macro runs9395 case m.overlay != nil:9496 return m.overlay.indicator()9597 case m.mode == modePrompt:@@ -102,6 +104,9 @@ func (m *Model) indicator() string {
102104 func (m *Model) menuBarLine() string {103105 left := m.menuBarTitles()104106 ind := m.th.Indicator.Render(" " + m.indicator() + " ")107+ if m.rec != nil {108+ ind = m.th.Recording.Render(" REC ") + " " + ind109+ }105110 gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)106111 return left + strings.Repeat(" ", max(gap, 1)) + ind107112 }@@ -151,6 +156,8 @@ func (m *Model) contextLineText() string {
151156 left = m.fillLine()152157 case m.entry.hint != "":153158 left = m.th.Warning.Render(m.entry.hint)159+ case m.macros.run != nil:160+ left, right = "Running "+m.macros.run.name+"…", m.th.KeyHints("Esc", "stop")154161 case m.mode == modeReady && m.trace != nil:155162 left, right = m.trace.line(&m.th, m.width, m.sheet)156163 case m.mode == modeReady:@@ -160,6 +167,9 @@ func (m *Model) contextLineText() string {
160167 if left == "" {161168 left = m.errorLine()162169 }170+ if left == "" {171+ left = m.recordingLine()172+ }163173 case m.mode == modeMenu:164174 switch o := m.overlay.(type) {165175 case *choiceBar:@@ -96,6 +96,7 @@ func (p *prompt) cancel(m *Model) {
9696 }9797 m.quitAfterSave = false // cancelling Save as cancels Save and quit9898 m.closePrompt()99+ m.recordAnswer("", true)99100 }100101 101102 // accept closes the prompt and acts on the answer.@@ -103,7 +104,9 @@ func (p *prompt) accept(m *Model) tea.Cmd {
103104 text := strings.TrimSpace(m.line.text())104105 m.closePrompt()105106 if p.kind != promptRange {106- return p.onText(m, text)107+ cmd := p.onText(m, text)108+ m.recordAnswer(text, false)109+ return cmd107110 }108111 r := m.point.rect()109112 if p.typing {@@ -113,7 +116,9 @@ func (p *prompt) accept(m *Model) tea.Cmd {
113116 return nil114117 }115118 }116- return p.onRange(m, r)119+ cmd := p.onRange(m, r)120+ m.recordAnswer(r.String(), false)121+ return cmd117122 }118123 119124 // pointKey moves or stretches the range being pointed at; typing switches@@ -29,11 +29,11 @@ func init() {
2929 m.note = "Added " + s.Name()3030 return nil3131 }},32- &command{id: "sheet.next", title: "Next sheet", desc: "Show the sheet to the right", enabled: func(m *Model) bool { return m.sheetAt(1) != nil },32+ &command{id: "sheet.next", macro: macroView, title: "Next sheet", desc: "Show the sheet to the right", enabled: func(m *Model) bool { return m.sheetAt(1) != nil },3333 run: func(m *Model) tea.Cmd { return m.stepSheet(1) }},34- &command{id: "sheet.prev", title: "Previous sheet", desc: "Show the sheet to the left", enabled: func(m *Model) bool { return m.sheetAt(-1) != nil },34+ &command{id: "sheet.prev", macro: macroView, title: "Previous sheet", desc: "Show the sheet to the left", enabled: func(m *Model) bool { return m.sheetAt(-1) != nil },3535 run: func(m *Model) tea.Cmd { return m.stepSheet(-1) }},36- &command{id: "sheet.goto", title: "Go to sheet", desc: "Pick a sheet by name", run: func(m *Model) tea.Cmd {36+ &command{id: "sheet.goto", macro: macroView, title: "Go to sheet", desc: "Pick a sheet by name", run: func(m *Model) tea.Cmd {3737 m.openSheetPicker()3838 return nil3939 }},@@ -8,6 +8,7 @@ import (
88 "charm.land/lipgloss/v2"99 "github.com/charmbracelet/x/ansi"1010 11+ "github.com/FelineStateMachine/012/internal/macro"1112 "github.com/FelineStateMachine/012/internal/sheet"1213 "github.com/FelineStateMachine/012/internal/ui/theme"1314 )@@ -232,6 +233,7 @@ func (m *Model) dropTab() {
232233 to := m.mouse.hover.addr.Col233234 m.book().MoveSheet(m.sheet, to)234235 m.note = "Moved " + m.sheet.Name() + " to position " + strconv.Itoa(to+1)236+ m.record(macro.Call("move_sheet", to+1))235237 }236238 }237239 @@ -21,6 +21,7 @@ import (
2121 // lose contrast.2222 type Theme struct {2323 Indicator lipgloss.Style // mode indicator, top right24+ Recording lipgloss.Style // the REC chip beside the mode indicator while a macro is recorded2425 Header lipgloss.Style // column letters, row numbers and the name box2526 HeaderActive lipgloss.Style // header of the focused row and column2627 HeaderSel lipgloss.Style // headers of selected rows and columns@@ -142,6 +143,7 @@ func New(dark bool) Theme {
142143 accent := lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)143144 t := Theme{144145 Indicator: accent.Bold(true),146+ Recording: lipgloss.NewStyle().Background(lipgloss.Red).Foreground(lipgloss.BrightWhite).Bold(true),145147 Header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),146148 HeaderActive: accent.Bold(true),147149 HeaderSel: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),@@ -29,11 +29,11 @@ type trace struct {
2929 3030 func init() {3131 register(32- &command{id: "data.precedents", title: "Trace precedents", desc: "Highlight the cells the formula reads; press again to jump through them", run: func(m *Model) tea.Cmd {32+ &command{id: "data.precedents", macro: macroView, title: "Trace precedents", desc: "Highlight the cells the formula reads; press again to jump through them", run: func(m *Model) tea.Cmd {3333 m.stepTrace(false)3434 return nil3535 }},36- &command{id: "data.dependents", title: "Trace dependents", desc: "Highlight the formulas that read the cell; press again to jump through them", run: func(m *Model) tea.Cmd {36+ &command{id: "data.dependents", macro: macroView, title: "Trace dependents", desc: "Highlight the formulas that read the cell; press again to jump through them", run: func(m *Model) tea.Cmd {3737 m.stepTrace(true)3838 return nil3939 }},@@ -55,7 +55,7 @@ type importTickMsg struct{ id int }
5555 const importTick = 100 * time.Millisecond5656 5757 func init() {58- register(&command{id: "file.import", title: "Import",58+ register(&command{id: "file.import", macro: macroNever, title: "Import",5959 desc: "Import a " + importNouns() + " file, replacing this sheet",6060 run: (*Model).openImport})6161 }