- commit
- 11d6dfd478eba30fbf396d23a74abf920981dc64
- parent
- a2b5f24702c6984d504954d84ee01050251233cb
- author
- Dami <dami.etoile@gmail.com>
| M | cmd/ | 1 | + |
| M | e2e/ | 8 | ++ |
| A | e2e/ | 100 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 13 | +++- |
| M | internal/ | 17 | +++++ |
| M | internal/ | 31 | +++++++--- |
| M | internal/ | 1 | - |
| M | internal/ | 4 | + |
| M | internal/ | 2 | +- |
@@ -80,6 +80,7 @@ func run(args []string) error {
8080 }8181 m := ui.New(s, name)8282 m.SetMachine(machineID())83+ m.AllowEditor()8384 if importName != "" {8485 m.Import(importName)8586 }@@ -67,6 +67,10 @@ type options struct {
6767 cols, rows uint16 // defaults to 100x306868 light bool // use the light reference palette6969 env []string // extra environment, e.g. a fake JEV endpoint70+ // config is where 012 keeps its machine id for macros71+ // (O12_CONFIG_DIR); by default inside dir, so sessions sharing a72+ // directory are one computer and nothing touches the real one's.73+ config string7074 jev bool // answer JEV functions with a fake service (screens)7175 // graphics turns on the kitty graphics protocol, so charts become7276 // images; off by default, so screens show the text charts every@@ -123,6 +127,10 @@ func startWith(t *testing.T, o options, args ...string) *session {
123127 // Tests never reach the real JEV service: the key is cleared unless a124128 // test points JEV at a fake server through o.env.125129 s.cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "TYPESAFE_API_KEY=", "TYPESAFE_BASE_URL=")130+ if o.config == "" {131+ o.config = filepath.Join(o.dir, ".config")132+ }133+ s.cmd.Env = append(s.cmd.Env, "O12_CONFIG_DIR="+o.config)126134 s.cmd.Env = append(s.cmd.Env, o.env...)127135 s.pty, err = pty.StartWithSize(s.cmd, &pty.Winsize{Cols: s.cols, Rows: s.rows})128136 if err != nil {@@ -0,0 +1,100 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+// recordTotals records a macro with relative references at A1: a label,11+// a formula to its right, and bold on both. It's saved as Totals with12+// the shortcut Ctrl+Alt+Shift+1.13+func recordTotals(s *session) {14+ s.keys("<ctrl+k>", "relative references")15+ s.waitFor("Record macro with relative references")16+ s.keys("<enter>")17+ s.waitFor(" REC ")18+ s.waitFor("Recording a macro with relative references")19+ s.keys("Total", "<tab>", "=SUM(2,3)", "<enter>")20+ s.keys("<up>", "<shift+right>", "<ctrl+b>")21+ s.waitFor("Bold on for A1:B1")22+ s.keys("<ctrl+k>", "stop and save", "<enter>")23+ s.waitFor("Save macro as: Macro 1")24+ s.keys("Totals", "<enter>")25+ s.waitFor("Shortcut Ctrl+Alt+Shift+ (a digit, or empty for none): 1")26+ s.keys("<enter>")27+ s.waitFor("Saved macro Totals; Ctrl+Alt+Shift+1 runs it")28+}29+30+func TestMacroRecordAndReplay(t *testing.T) {31+ dir := t.TempDir()32+ s := start(t, dir, "book.012")33+ recordTotals(s)34+ if strings.Contains(s.line(0), "REC") {35+ t.Errorf("still shows REC: %q", s.line(0))36+ }37+38+ // The shortcut replays it from wherever the active cell is.39+ s.keys("<f5>", "D5", "<enter>")40+ s.waitForBar("D5", "")41+ s.keys("<ctrl+alt+shift+1>")42+ s.waitFor("Ran Totals")43+ s.keys("<right>")44+ s.waitForBar("E5", "=SUM(2,3)")45+46+ // One Ctrl+Z undoes the whole run.47+ s.keys("<ctrl+z>")48+ s.waitFor("Undid: run macro Totals")49+ s.waitForBar("D5", "")50+51+ // Saved in the file, as a script.52+ s.keys("<ctrl+s>")53+ s.eventually("saved", func() bool {54+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))55+ return err == nil && strings.Contains(string(data), `"macros": [`) &&56+ strings.Contains(string(data), `{"name": "Totals", "key": "1", "api": 1, "source": "# Recorded with relative references`) &&57+ strings.Contains(string(data), `enter(\"=SUM(2,3)\", origin=\"B1\")`)58+ })59+ s.keys("<ctrl+q>")60+ s.waitExit()61+62+ // Opening the file runs nothing; on this computer the macro runs63+ // without asking.64+ s = start(t, dir, "book.012")65+ s.waitForBar("A1", "Total")66+ s.keys("<f5>", "A3", "<enter>")67+ s.waitForBar("A3", "")68+ s.keys("<ctrl+k>", "totals")69+ s.waitFor("Ctrl+Alt+Shift+1")70+ s.keys("<enter>")71+ s.waitFor("Ran Totals")72+ s.waitForBar("A3:B3", "Total")73+}74+75+func TestMacroFromAnotherComputerAsks(t *testing.T) {76+ dir := t.TempDir()77+ s := start(t, dir, "book.012")78+ recordTotals(s)79+ s.keys("<ctrl+s>")80+ s.eventually("saved", func() bool {81+ data, err := os.ReadFile(filepath.Join(dir, "book.012"))82+ return err == nil && strings.Contains(string(data), "macroOrigin")83+ })84+ s.keys("<ctrl+q>")85+ s.waitExit()86+87+ s = startWith(t, options{dir: dir, config: t.TempDir()}, "book.012")88+ s.waitForBar("A1", "Total")89+ s.keys("<f5>", "C3", "<enter>", "<ctrl+alt+shift+1>")90+ s.waitFor("This file's macros were made on another computer")91+ s.keys("<esc>")92+ s.waitForBar("C3", "")93+ s.keys("<ctrl+alt+shift+1>")94+ s.waitFor("made on another computer")95+ s.keys("<enter>")96+ s.waitForBar("C3:D3", "Total") // the macro ends selecting the pair97+ // Trusted now: no second question.98+ s.keys("<down>", "<ctrl+alt+shift+1>")99+ s.waitForBar("C4:D4", "Total")100+}@@ -44,7 +44,7 @@ func (m *Model) openRetrieve() tea.Cmd {
4444 // state and the JEV connection.4545 func (m *Model) reset(s *sheet.Sheet, filename string) {4646 *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}}47+ macros: macroState{machine: m.macros.machine, editor: m.macros.editor}}4848 if m.jev != nil {4949 s.Book().SetRemote(m.jev.cache)5050 }@@ -57,9 +57,10 @@ func init() {
5757 &command{id: "macro.manage", title: "Manage macros", macro: macroNever, enabled: idle,5858 desc: "Rename, delete, give shortcuts to or edit the saved macros",5959 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},60+ &command{id: "macro.new", title: "Write a macro", macro: macroNever,61+ enabled: func(m *Model) bool { return idle(m) && m.macros.editor },62+ desc: "Write a macro script in your editor ($VISUAL or $EDITOR)",63+ run: (*Model).newMacro},6364 )6465 // Ctrl+Alt+Shift+digit arrives as Ctrl+Alt with the shifted symbol6566 // from terminals without the kitty keyboard protocol (US layout).@@ -159,6 +160,12 @@ func (m *Model) trustHere() {
159160 }160161 }161162 163+// AllowEditor lets the user edit macro scripts in their own editor164+// ($VISUAL or $EDITOR), which runs as a program of theirs with the165+// screen handed over. Only the local app allows it: a session served166+// over SSH must not start programs on the server.167+func (m *Model) AllowEditor() { m.macros.editor = true }168+162169 // SetMachine tells the model which computer it runs on, as an id kept in163170 // the user's configuration, so macros saved here run without asking and164171 // macros from files made elsewhere ask once.@@ -416,6 +416,7 @@ func TestPaletteAndPickerRunMacros(t *testing.T) {
416416 417417 func TestManageMacros(t *testing.T) {418418 m := newModel()419+ m.AllowEditor()419420 record(t, m, false, "One", "1", "1", "<enter>")420421 run(m, m.runCommand("macro.manage"))421422 if !strings.Contains(screen(m), "+ Write a macro") || !strings.Contains(screen(m), "One") {@@ -441,8 +442,24 @@ func TestManageMacros(t *testing.T) {
441442 }442443 }443444 445+func TestWithoutAnEditorScriptsAreReadOnly(t *testing.T) {446+ m := newModel()447+ record(t, m, false, "One", "", "1", "<enter>")448+ if commands["macro.new"].available(m) {449+ t.Error("Write a macro is available without an editor")450+ }451+ run(m, m.runCommand("macro.manage"))452+ if strings.Contains(screen(m), "+ Write a macro") {453+ t.Error("the manager offers to write a macro")454+ }455+ if cmd := m.overlay.key(m, tea.KeyPressMsg{Code: tea.KeyF4}); cmd != nil || !strings.Contains(line(m, m.height-1), "No editor in this session") {456+ t.Errorf("F4: %v %q", cmd, line(m, m.height-1))457+ }458+}459+444460 func TestEditedScriptsAreSavedAndChecked(t *testing.T) {445461 m := newModel()462+ m.AllowEditor()446463 record(t, m, false, "Ed", "", "a", "<enter>")447464 path := filepath.Join(t.TempDir(), "ed.star")448465 os.WriteFile(path, []byte("set(\"B1\", 2)\nset(\"B2\",\n"), 0o600)@@ -2,6 +2,7 @@ package ui
22 33 import (44 "cmp"5+ "slices"56 "fmt"67 "os"78 "os/exec"@@ -42,13 +43,16 @@ func (m *Model) openMacros(sel string) {
4243 4344 // macroManageItems lists "Write a macro" and then every macro.4445 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- }}46+ var items []pickItem47+ if m.macros.editor {48+ items = append(items, pickItem{49+ title: writeMacroTitle, name: len(writeMacroTitle), desc: "Write a new macro script in your editor",50+ pick: func(m *Model) tea.Cmd {51+ m.closeOverlay()52+ return m.newMacro()53+ },54+ })55+ }5256 for _, mc := range m.book().Macros() {5357 lines := strings.Count(strings.TrimRight(mc.Source, "\n"), "\n") + 15458 items = append(items, pickItem{@@ -90,6 +94,10 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
9094 m.closeOverlay()9195 m.setMacroShortcut(mc)9296 case "f4":97+ if !m.macros.editor {98+ p.msg = "No editor in this session"99+ return nil100+ }93101 m.closeOverlay()94102 return m.editScript(mc.Name, mc.Source, false)95103 case "ctrl+d":@@ -104,10 +112,15 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
104112 }105113 106114 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")115+ pairs := []string{"Enter", "run", "F2", "rename", "F3", "shortcut", "F4", "edit", "Ctrl+D", "delete", "Esc", "close"}116+ if !m.macros.editor {117+ pairs = slices.Delete(pairs, 6, 8)118+ }108119 if m.width < 100 {109- keys = m.th.KeyHints("Enter", "run", "F2", "rename", "F3", "key", "F4", "edit", "Ctrl+D", "delete")120+ pairs = slices.DeleteFunc(pairs[:len(pairs)-2], func(s string) bool { return s == "shortcut" })121+ pairs = slices.Insert(pairs, slices.Index(pairs, "F3")+1, "key")110122 }123+ keys := m.th.KeyHints(pairs...)111124 if _, ok := p.current(m); !ok {112125 keys = m.th.KeyHints("Enter", "write", "Esc", "close")113126 }@@ -62,7 +62,6 @@ func (s selState) rect() sheet.Rect {
6262 // startRecording starts recording, from the selection as it is.6363 func (m *Model) startRecording(relative bool) {6464 m.rec = &recorder{relative: relative, base: m.selState()}65- m.note = "Recording a macro: what you do now is saved when you stop"6665 }6766 6867 // add records a, taking the selection after it as the new base.@@ -31,6 +31,10 @@ type macroState struct {
3131 // once the user has agreed to run macros from a file made elsewhere.3232 machine string3333 trusted bool34+35+ // editor allows editing scripts in the user's editor, a program of36+ // their own; only the local app does (see AllowEditor).37+ editor bool3438 }3539 3640 // macroRun is a script running.@@ -143,7 +143,7 @@ func New(dark bool) Theme {
143143 accent := lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)144144 t := Theme{145145 Indicator: accent.Bold(true),146- Recording: lipgloss.NewStyle().Background(lipgloss.Red).Foreground(lipgloss.BrightWhite).Bold(true),146+ Recording: lipgloss.NewStyle().Background(lipgloss.Red).Foreground(selFg).Bold(true),147147 Header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),148148 HeaderActive: accent.Bold(true),149149 HeaderSel: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),