cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / macrohostnav.go
macrohostnav.go 10.1K
1package ui23import (4 "errors"5 "fmt"6 "log/slog"7 "strconv"8 "strings"910 "src.rest/cat/012/internal/sheet"11 "src.rest/cat/012/internal/ui/choicebar"12 "src.rest/cat/012/internal/ui/picker"13)1415// What scripts do to the selection, the sheets and through commands; the16// cells are in macrohost.go.1718func (h scriptHost) Selection() (string, string) {19 return wholeRef(h.m.selection()), h.m.cur.String()20}2122func (h scriptHost) Select(ref, active string) error {23 m := h.m24 s, r, err := h.resolve(ref)25 if err != nil {26 return err27 }28 if s.Hidden() {29 return errors.New(hiddenMsg(s))30 }31 m.showSheet(s)32 m.selectRect(r)33 if active == "" {34 return nil35 }36 a, ok := sheet.ParseAddr(active)37 if !ok || !r.Contains(a) {38 return fmt.Errorf("the active cell %s isn't in %s", active, ref)39 }40 m.setActive(r, a)41 return nil42}4344// setActive makes a, inside the selected range r, the active cell, with45// the far corner of r as the other end of the selection.46func (g *grid) setActive(r sheet.Rect, a sheet.Addr) {47 far := func(v, lo, hi int) int {48 if v == lo {49 return hi50 }51 return lo52 }53 g.cur = a54 switch g.whole {55 case wholeCols:56 g.ext = sheet.Addr{Col: far(a.Col, r.From.Col, r.To.Col), Row: a.Row}57 case wholeRows:58 g.ext = sheet.Addr{Col: a.Col, Row: far(a.Row, r.From.Row, r.To.Row)}59 case wholeNone:60 g.ext = sheet.Addr{Col: far(a.Col, r.From.Col, r.To.Col), Row: far(a.Row, r.From.Row, r.To.Row)}61 }62}6364func (h scriptHost) Move(cols, rows int) error {65 m := h.m66 to := sheet.Addr{Col: m.cur.Col + cols, Row: m.cur.Row + rows}67 if !to.Valid() {68 return fmt.Errorf("moving %d, %d from %s goes off the sheet", cols, rows, m.cur)69 }70 m.cur = to71 m.clearSelection()72 return nil73}7475func (h scriptHost) Extend(cols, rows int, whole string) error {76 m := h.m77 to := sheet.Addr{Col: m.cur.Col + cols, Row: m.cur.Row + rows}78 if !to.Valid() {79 return fmt.Errorf("extending %d, %d from %s goes off the sheet", cols, rows, m.cur)80 }81 m.selecting, m.ext, m.whole = true, to, wholeNone82 switch whole {83 case "columns":84 m.whole = wholeCols85 case "rows":86 m.whole = wholeRows87 }88 return nil89}9091// jumpKeys are the keys Jump's targets stand for.92var jumpKeys = map[string]string{93 "up": "ctrl+up", "down": "ctrl+down", "left": "ctrl+left", "right": "ctrl+right",94 "home": "home", "start": "ctrl+home", "end": "ctrl+end",95}9697func (h scriptHost) Jump(to string, extend bool) error {98 key, ok := jumpKeys[to]99 if !ok {100 return fmt.Errorf("can't jump to %q", to)101 }102 if extend {103 key = strings.Replace(key, "+", "+shift+", 1)104 if !strings.Contains(key, "+") {105 key = "shift+" + key106 }107 }108 h.m.moveKey(key)109 return nil110}111112func (h scriptHost) Enter(text string, fill bool, origin string) error {113 m := h.m114 if origin != "" {115 from, ok := sheet.ParseAddr(origin)116 if !ok {117 return fmt.Errorf("origin %q isn't a cell", origin)118 }119 var err error120 if text, err = sheet.ShiftEntry(text, m.cur.Col-from.Col, m.cur.Row-from.Row); err != nil {121 return err122 }123 }124 if fill {125 r := m.selection()126 m.writeChecked("Fill", func() (sheet.Rect, error) { return r, m.sheet.FillEntry(r, m.cur, text) })127 return h.failed()128 }129 if bad := m.sheet.CheckEntry(m.cur, text); bad != nil && bad.Reject {130 return bad131 }132 if err := m.sheet.Set(m.cur, text); err != nil {133 return err134 }135 m.clearSelection()136 return nil137}138139func (h scriptHost) PasteText(text string) error {140 m := h.m141 if !m.pasteText(text) {142 entry := strings.TrimSpace(text)143 if bad := m.sheet.CheckEntry(m.cur, entry); bad != nil && bad.Reject {144 return fmt.Errorf("Paste undone: %w", bad)145 }146 if err := m.sheet.Set(m.cur, entry); err != nil {147 return err148 }149 }150 return h.failed()151}152153// failed is the script's error for what a command just reported to the154// user in ERROR mode, if it did.155func (h scriptHost) failed() error {156 if h.m.mode == modeError {157 return h.failure()158 }159 return nil160}161162// failure turns an error a command reported to the user into the163// script's error, leaving the screen as it was.164func (h scriptHost) failure() error {165 m := h.m166 msg := m.errMsg167 m.errMsg, m.mode = "", modeReady168 return errors.New(msg)169}170171func (h scriptHost) Sheets() []string {172 var names []string173 for _, s := range h.m.book().Sheets() {174 names = append(names, s.Name())175 }176 return names177}178179func (h scriptHost) ActiveSheet() string { return h.m.sheet.Name() }180181func (h scriptHost) ActivateSheet(name string) error {182 s := h.m.book().Lookup(name)183 if s == nil {184 return fmt.Errorf("there's no sheet named %s", name)185 }186 if s.Hidden() {187 return errors.New(hiddenMsg(s))188 }189 h.m.showSheet(s)190 return nil191}192193func (h scriptHost) AddSheet(name string) (string, error) {194 m := h.m195 s, err := m.book().AddSheet(name, m.book().Index(m.sheet)+1)196 if err != nil {197 return "", err198 }199 m.showSheet(s)200 return s.Name(), nil201}202203func (h scriptHost) MoveSheet(pos int) error {204 m := h.m205 if pos < 1 || pos > m.book().Len() {206 return fmt.Errorf("position %d isn't between 1 and %d", pos, m.book().Len())207 }208 m.book().MoveSheet(m.sheet, pos-1)209 return nil210}211212func (h scriptHost) SetWidth(cols string, width int) error {213 r, ok := parseWhole(cols)214 if !ok {215 if c, isCol := sheet.ParseCol(strings.ToUpper(cols)); isCol {216 r, ok = sheet.Rect{From: sheet.Addr{Col: c}, To: sheet.Addr{Col: c}}, true217 }218 }219 if !ok {220 return fmt.Errorf("not a column or columns: %s", cols)221 }222 if width < 1 || width > 240 {223 return errors.New("column width must be between 1 and 240")224 }225 for c := r.From.Col; c <= r.To.Col; c++ {226 h.m.sheet.SetColWidth(c, width)227 }228 return nil229}230231func (h scriptHost) SetHeight(rows string, height int) error {232 r, ok := parseWhole(rows)233 if !ok {234 if n, err := strconv.Atoi(strings.TrimSpace(rows)); err == nil && n >= 1 && n <= sheet.MaxRows {235 r, ok = sheet.Rect{From: sheet.Addr{Row: n - 1}, To: sheet.Addr{Row: n - 1}}, true236 }237 }238 if !ok {239 return fmt.Errorf("not a row or rows: %s", rows)240 }241 if height < 0 || height > sheet.MaxRowHeight {242 return fmt.Errorf("row height must be between 1 and %d lines, or 0 to fit", sheet.MaxRowHeight)243 }244 h.m.sheet.SetRowHeight(r.From.Row, r.To.Row, height)245 return nil246}247248func (h scriptHost) Fill(to string, rows, cols int) error {249 m := h.m250 src := m.selection()251 dst := src252 switch {253 case to != "":254 s, r, err := h.resolve(to)255 if err != nil {256 return err257 }258 if s != m.sheet {259 return errors.New("fill stays on the sheet shown")260 }261 dst = r262 case rows > 0:263 dst.To.Row += rows264 case rows < 0:265 dst.From.Row += rows266 case cols > 0:267 dst.To.Col += cols268 case cols < 0:269 dst.From.Col += cols270 }271 if !dst.From.Valid() || !dst.To.Valid() {272 return errors.New("the fill goes off the sheet")273 }274 got, ok := m.writeChecked("Fill", func() (sheet.Rect, error) { return m.sheet.FillSeries(src, dst) })275 if !ok {276 return h.failed()277 }278 m.selectRect(got)279 return nil280}281282// Run runs a command as if picked from the palette, answering the283// question it asks, if any.284func (h scriptHost) Run(id string, answer *string) error {285 m := h.m286 c, ok := commands[id]287 switch {288 case !ok:289 return fmt.Errorf("no command %q; the palette (Ctrl+K) lists them, and docs/reference/macro-api.md how to find ids", id)290 case c.macro == macroNever:291 return fmt.Errorf("%s (%s) can't run in a macro", c.title, id)292 case c.answer != nil && answer != nil:293 return h.answered(c, *answer)294 case !c.available(m):295 return fmt.Errorf("%s can't run now", c.title)296 }297 m.macros.cmds = append(m.macros.cmds, m.runCommand(id))298 return h.answer(c, answer)299}300301// answered runs a command that opens a dialog with the dialog's302// choices, as a recording writes them.303func (h scriptHost) answered(c *command, text string) error {304 m := h.m305 span := m.spans.Start("command", slog.String("id", c.id)) // as runCommand times commands306 cmd, err := c.answer(m, text)307 span.End()308 m.macros.cmds = append(m.macros.cmds, cmd)309 if err != nil {310 if m.overlay != nil {311 m.closeOverlay()312 }313 return fmt.Errorf("%s: %w", c.title, err)314 }315 return h.answer(c, nil)316}317318// answer answers the question a command left open, or backs out of it319// and reports that it needed one.320func (h scriptHost) answer(c *command, answer *string) error {321 m := h.m322 switch {323 case m.mode == modeError:324 return h.failure()325 case m.mode == modePrompt && answer != nil:326 p := m.prompt327 m.line.Clear()328 m.line.Insert(*answer)329 p.fresh, p.typing = false, p.kind == promptRange330 m.macros.cmds = append(m.macros.cmds, p.accept(m))331 case m.mode == modePrompt:332 label := m.prompt.label333 m.prompt.cancel(m)334 return fmt.Errorf("%s asks %q: give run(%q, answer=...)", c.title, strings.TrimSuffix(label, ":"), c.id)335 case m.overlay != nil:336 return h.choose(c, answer)337 }338 if m.mode == modeError {339 return h.failure()340 }341 if m.mode != modeReady {342 m.closeOverlay()343 return fmt.Errorf("%s asks more than one question; it can't run in a macro", c.title)344 }345 return nil346}347348// choose answers a choice bar with the key or label of a choice; other349// overlays (dialogs, pickers) can't be answered by a script.350func (h scriptHost) choose(c *command, answer *string) error {351 m := h.m352 if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {353 return h.pickAnswer(c, p, answer)354 }355 bar, ok := m.overlay.(*choicebar.Bar)356 switch {357 case !ok && c.answer != nil:358 m.closeOverlay()359 return fmt.Errorf("%s opens a dialog: give run(%q, answer={...}) with its choices, as a recording writes them", c.title, c.id)360 case !ok:361 m.closeOverlay()362 return fmt.Errorf("%s opens a dialog; it can't run in a macro", c.title)363 }364 if answer != nil {365 if i := bar.Find(*answer); i >= 0 {366 m.macros.cmds = append(m.macros.cmds, bar.Choose(i))367 if m.mode == modeError {368 return h.failure()369 }370 return nil371 }372 }373 m.closeOverlay()374 return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.Msg, c.id)375}376377// pickAnswer answers a picker that asks a command's question with the378// title of one of its items.379func (h scriptHost) pickAnswer(c *command, p *picker.Picker, answer *string) error {380 m := h.m381 if answer != nil {382 if cmd, ok := p.Answer(*answer); ok {383 m.macros.cmds = append(m.macros.cmds, cmd)384 if m.mode == modeError {385 return h.failure()386 }387 return nil388 }389 }390 m.closeOverlay()391 var titles []string392 for _, it := range p.Items {393 titles = append(titles, it.Title)394 }395 return fmt.Errorf("%s asks for one of %s: give run(%q, answer=...)", c.title, strings.Join(titles, ", "), c.id)396}