cat / 012
clone

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

Suggest sheet names in formula autocomplete, pointing into the sheet after

Dami committed 625fe58
commit
625fe586e7dc87dadad36db5dc705c5bcd1b576f
parent
5a0b9b1b3b50b879e9b49cd12962c9e66c1d763f
author
Dami <dami.etoile@gmail.com>
8 files changed, +295 -21
Mdocs/formulas.md6+-
Mdocs/keys.md2+-
Minternal/ui/assist.go36+++++---
Ainternal/ui/assistsheets.go131++++++++++++++++++++++++++++++
Ainternal/ui/assistsheets_test.go109+++++++++++++++++++++++++
Minternal/ui/entry.go4+-
Minternal/ui/formula/formula.go21++++-
Minternal/ui/formula/formula_test.go7++
M docs/formulas.md +5 -1
M docs/keys.md +1 -1
M internal/ui/assist.go +23 -13
@@ -13,9 +13,10 @@ import (
1313 "github.com/FelineStateMachine/012/internal/ui/theme"1414 )1515 16-// Formula assistance, as in Sheets: while a function or range name is17-// being typed, a list of matching functions and named ranges drops down18-// from the formula bar at the caret, and while the caret is inside a16+// Formula assistance, as in Sheets: while a function, range or sheet17+// name is being typed, a list of matching functions, named ranges and18+// other sheets (assistsheets.go) drops down from the formula bar at the19+// caret, and while the caret is inside a1920 // function's parentheses the context line shows its signature with the2021 // current argument marked. The list takes only the keys Sheets gives it2122 // (Up/Down, Tab, Enter, Esc) and only while it shows, so arrows after an
@@ -34,21 +35,24 @@ const assistRows = 8
3435 3536 const assistID = "assist"3637 37-// suggestion is a function or named range offered for the word at the38-// caret.38+// suggestion is a function, named range or sheet offered for the word39+// at the caret.3940 type suggestion struct {40- name string // e.g. "SUM" or "Sales"41- detail string // the signature, or the range a name stands for41+ name string // e.g. "SUM", "Sales" or "'Q3 plan'!"42+ detail string // the signature, the range a name stands for, or a sheet's cells4243 desc string4344 fn bool // a function: accepting it adds "("4445 }4546 46-// suggestions lists the functions and named ranges for word: those47-// starting with it first (names before functions), then, from two48-// letters on, those containing it, such as COUNTIFS for "ifs". A word49-// that is already a cell reference only gets names and functions that50-// start with it.47+// suggestions lists the functions, named ranges and sheets for word:48+// those starting with it first (names, then sheets, then functions),49+// then, from two letters on, those containing it, such as COUNTIFS for50+// "ifs". A word that is already a cell reference only gets those that51+// start with it, and a word in quotes ('Q3) only sheets.5152 func suggestions(sh *sheet.Sheet, word string) []suggestion {53+ if rest, quoted := strings.CutPrefix(word, "'"); quoted {54+ return sheetSuggestions(sh, strings.ToUpper(rest))55+ }5256 w := strings.ToUpper(word)5357 var prefix, inner []suggestion5458 add := func(s suggestion, name string) {
@@ -62,6 +66,9 @@ func suggestions(sh *sheet.Sheet, word string) []suggestion {
6266 for _, n := range sh.Names() {6367 add(suggestion{name: n.Name, detail: n.Ref(), desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))6468 }69+ for _, s := range otherSheets(sh) {70+ add(sheetSuggestion(s), strings.ToUpper(s.Name()))71+ }6572 for _, f := range sheet.Funcs() {6673 add(suggestion{name: f.Name, detail: f.Name + "(" + f.Args + ")", desc: f.Desc, fn: true}, f.Name)6774 }
@@ -83,7 +90,7 @@ func (a *assist) shown(m *Model) ([]suggestion, int) {
8390 if c.Word == "" {8491 return nil, 08592 }86- list := suggestions(m.sheet, c.Word)93+ list := suggestions(m.entrySheet(), c.Word)8794 if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.Word) {8895 return nil, 08996 }
@@ -128,6 +135,9 @@ func (a *assist) accept(m *Model, s suggestion, start int) {
128135 if s.fn && (len(rest) == 0 || rest[0] != '(') {129136 text = append(text, '(')130137 }138+ if strings.HasSuffix(s.name, "!") {139+ rest = trimSheetEnd(rest) // the rest of a sheet name typed before140+ }131141 pos := start + len(text)132142 if s.fn && len(rest) > 0 && rest[0] == '(' {133143 pos++ // into the existing parentheses
A internal/ui/assistsheets.go +131 -0
@@ -0,0 +1,131 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Sheet names in formula suggestions: typing =Su offers Summary! with the11+// functions and named ranges, and ='Q3 offers 'Q3 plan'!, quoted as a12+// reference needs; the sheet the formula is on isn't, as its cells need13+// no name, and neither are hidden sheets. Accepting one inserts the sheet14+// and its "!"; an arrow then points into that sheet, as clicking its tab15+// would, so the reference comes out as Summary!B3.16+17+// sheetSuggestion is the suggestion for sheet s.18+func sheetSuggestion(s *sheet.Sheet) suggestion {19+ detail, desc := "sheet, empty", "Sheet "+s.Name()+", empty"20+ if used, ok := s.UsedRange(); ok {21+ detail = "sheet, " + used.String()22+ desc = "Sheet " + s.Name() + ": " + used.String() + ", " + cellCount(s.Len())23+ }24+ return suggestion{name: sheet.QuoteSheet(s.Name()) + "!", detail: detail, desc: desc}25+}26+27+// otherSheets are the sheets offered in formulas typed on sh: the28+// visible ones but sh, whose cells need no sheet name.29+func otherSheets(sh *sheet.Sheet) []*sheet.Sheet {30+ return slices.DeleteFunc(sh.Book().Visible(), func(s *sheet.Sheet) bool { return s == sh })31+}32+33+// sheetSuggestions lists the other visible sheets for w, a quoted name typed34+// so far in upper case: those starting with it, then from two letters35+// on, those containing it.36+func sheetSuggestions(sh *sheet.Sheet, w string) []suggestion {37+ var prefix, inner []suggestion38+ for _, s := range otherSheets(sh) {39+ name := strings.ToUpper(s.Name())40+ switch {41+ case strings.HasPrefix(name, w):42+ prefix = append(prefix, sheetSuggestion(s))43+ case len(w) >= 2 && strings.Contains(name, w):44+ inner = append(inner, sheetSuggestion(s))45+ }46+ }47+ return append(prefix, inner...)48+}49+50+// trimSheetEnd drops the end of a sheet reference ("!" or "'!") that51+// follows the caret, which an accepted sheet brings with it.52+func trimSheetEnd(rest []rune) []rune {53+ switch {54+ case len(rest) > 0 && rest[0] == '!':55+ return rest[1:]56+ case len(rest) > 1 && rest[0] == '\'' && rest[1] == '!':57+ return rest[2:]58+ }59+ return rest60+}61+62+// sheetBeforeCaret returns the sheet named just before the caret, as in63+// "=SUM(Summary!" or "='Q3 plan'!", and where its name starts.64+func (l *lineEdit) sheetBeforeCaret(book *sheet.Workbook) (*sheet.Sheet, int, bool) {65+ head := l.buf[:l.pos]66+ end := len(head) - 1 // the "!"67+ if end < 2 || head[end] != '!' {68+ return nil, 0, false69+ }70+ start := end71+ if head[end-1] == '\'' {72+ start = -173+ for i := end - 2; i >= 1; i-- {74+ if head[i] != '\'' {75+ continue76+ }77+ if head[i-1] == '\'' {78+ i-- // '' inside a quoted name79+ continue80+ }81+ start = i82+ break83+ }84+ } else {85+ for start > 1 && isNameRune(head[start-1]) {86+ start--87+ }88+ }89+ if start < 1 || start >= end {90+ return nil, 0, false91+ }92+ name, _ := sheet.SplitSheet(string(head[start:]))93+ s := book.Lookup(name)94+ return s, start, s != nil95+}96+97+func isNameRune(r rune) bool {98+ return r == '_' || r == '.' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r > 0x7f99+}100+101+// pointAfterSheet starts pointing with key when the caret follows a102+// sheet reference such as "Summary!": on that sheet, the name typed103+// giving way to the reference pointed at (Summary!B3), as if its tab had104+// been clicked. It reports whether it did.105+func (m *Model) pointAfterSheet(key string) bool {106+ if _, extend := extendKey(key); !extend && (!isMoveKey(key) || key == "tab" || key == "shift+tab") {107+ return false108+ }109+ s, start, ok := m.line.sheetBeforeCaret(m.book())110+ if !ok || s.Hidden() {111+ return false112+ }113+ if s == m.sheet && !m.away() {114+ return m.startPoint(key) // its own sheet: the name stays as typed115+ }116+ m.line.buf = append(m.line.buf[:start:start], m.line.buf[m.line.pos:]...)117+ m.line.pos = start118+ if s == m.sheet {119+ return m.startPoint(key)120+ }121+ m.pointInto(s)122+ if m.mode != modePoint {123+ return true124+ }125+ if base, extend := extendKey(key); extend {126+ m.point.anchor, m.point.anchored = m.point.at, true127+ key = base128+ }129+ m.navigate(key, &m.point.at)130+ return true131+}
A internal/ui/assistsheets_test.go +109 -0
@@ -0,0 +1,109 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+)7+8+// sheetsModel has Sheet1 (shown), Summary with 7 in B3, and Q3 plan with9+// 5 in A2.10+func sheetsModel(t *testing.T) *Model {11+ t.Helper()12+ m := newModel()13+ book := m.book()14+ sum, _ := book.AddSheet("Summary", 1)15+ sum.Set(addr("B3"), "7")16+ plan, _ := book.AddSheet("Q3 plan", 2)17+ plan.Set(addr("A2"), "5")18+ book.ClearHistory()19+ return m20+}21+22+func names(list []suggestion) []string {23+ var out []string24+ for _, s := range list {25+ out = append(out, s.name)26+ }27+ return out28+}29+30+func TestAutocompleteSheets(t *testing.T) {31+ m := sheetsModel(t)32+ press(t, m, "=su")33+ list, _ := m.entry.assist.shown(m)34+ got := names(list)35+ if len(got) < 2 || got[0] != "Summary!" || !strings.HasPrefix(got[1], "SU") {36+ t.Fatalf("suggestions %v", got)37+ }38+ if scr := screen(m); !strings.Contains(scr, "│ Summary! sheet, A1:B3") {39+ t.Errorf("list:\n%s", scr)40+ }41+ if st := status(m); !strings.Contains(st, "Sheet Summary: A1:B3, 1 cell") {42+ t.Errorf("status %q", st)43+ }44+ press(t, m, "<tab>")45+ if m.line.text() != "=Summary!" || m.mode != modeEnter {46+ t.Fatalf("accepted: %q, mode %v", m.line.text(), m.mode)47+ }48+ // Typing the address works as ever.49+ press(t, m, "B3*2", "<enter>")50+ if m.sheet.Value(addr("A1")).Num != 14 {51+ t.Errorf("A1 = %v (%q)", m.sheet.Value(addr("A1")), input(m, "A1"))52+ }53+54+ // A name that needs quotes is offered quoted, from its start or55+ // inside quotes.56+ for _, typed := range []string{"=q3", "='q3 p", "='pl"} {57+ press(t, m, "<esc>", "<esc>", typed)58+ list, start := m.entry.assist.shown(m)59+ if len(list) == 0 || list[0].name != "'Q3 plan'!" || start != 1 {60+ t.Errorf("%s: %v from %d", typed, names(list), start)61+ }62+ }63+ press(t, m, "<tab>")64+ if m.line.text() != "='Q3 plan'!" {65+ t.Fatalf("quoted: %q", m.line.text())66+ }67+ // An arrow then points into the sheet, as clicking its tab would.68+ press(t, m, "<down>")69+ if m.mode != modePoint || m.sheet.Name() != "Q3 plan" || m.pointRef() != "'Q3 plan'!A2" {70+ t.Fatalf("pointing: mode %v on %s, %q", m.mode, m.sheet.Name(), m.pointRef())71+ }72+ press(t, m, "+1", "<enter>")73+ if m.sheet.Name() != "Sheet1" || input(m, "A2") != "='Q3 plan'!A2+1" || m.sheet.Value(addr("A2")).Num != 6 {74+ t.Errorf("stored on %s: %q", m.sheet.Name(), input(m, "A2"))75+ }76+}77+78+// The entry's own sheet isn't offered, and pointing after its name typed79+// keeps the name; hidden sheets aren't offered, and a quote closing the80+// name isn't doubled.81+func TestAutocompleteSheetsEdges(t *testing.T) {82+ m := sheetsModel(t)83+ press(t, m, "=she")84+ if list, _ := m.entry.assist.shown(m); len(list) > 0 && list[0].name == "Sheet1!" {85+ t.Errorf("the formula's own sheet offered: %v", names(list))86+ }87+ press(t, m, "et1!", "<right>")88+ if m.mode != modePoint || m.sheet.Name() != "Sheet1" || m.pointRef() != "B1" {89+ t.Fatalf("own sheet pointing: %v %s %q", m.mode, m.sheet.Name(), m.pointRef())90+ }91+ press(t, m, "<esc>", "<esc>")92+93+ m.book().HideSheet(m.book().Lookup("Summary"))94+ press(t, m, "=summ")95+ if list, _ := m.entry.assist.shown(m); len(list) > 0 && list[0].name == "Summary!" {96+ t.Errorf("hidden sheet offered: %v", names(list))97+ }98+ press(t, m, "<esc>")99+100+ // Accepting inside an existing reference replaces the name only.101+ m.line.set("='Q3 p'!A1")102+ m.line.pos = len("='Q3 p")103+ m.entry.assist.active = true104+ m.mode = modeEnter105+ press(t, m, "<tab>")106+ if m.line.text() != "='Q3 plan'!A1" || m.line.pos != len("='Q3 plan'!") {107+ t.Errorf("replaced: %q at %d", m.line.text(), m.line.pos)108+ }109+}
M internal/ui/entry.go +2 -2
M internal/ui/formula/formula.go +17 -4
@@ -115,8 +115,9 @@ func (s refScan) corners(pos int) ([][2]int, bool) {
115115 }116116 117117 // Caret describes the formula text before the caret: the word being118-// typed, if it may be a function or name, and the innermost function call119-// the caret is in.118+// typed, if it may be a function, name or sheet, and the innermost119+// function call the caret is in. A sheet name being typed in quotes is a120+// word starting with the quote, e.g. "'Q3 p".120121 type Caret struct {121122 Word string122123 WordStart int
@@ -132,7 +133,7 @@ func isWordRune(r rune) bool {
132133 // skipped, parentheses nest, and commas or semicolons at the innermost133134 // level separate arguments.134135 func ScanCaret(buf []rune, pos int) Caret {135- s := caretScan{start: -1}136+ s := caretScan{start: -1, quote: -1}136137 for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"137138 s.step(buf, i)138139 }
@@ -140,13 +141,16 @@ func ScanCaret(buf []rune, pos int) Caret {
140141 return Caret{}141142 }142143 var c Caret144+ if s.quote >= 0 {145+ c.Word, c.WordStart = string(buf[s.quote:pos]), s.quote146+ }143147 for i := len(s.stack) - 1; i >= 0; i-- {144148 if s.stack[i].fn != "" {145149 c.Fn, c.Arg = s.stack[i].fn, s.stack[i].arg146150 break147151 }148152 }149- if s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {153+ if s.quote < 0 && s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {150154 w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))151155 if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {152156 c.Word, c.WordStart = string(w), pos-len(w)
@@ -159,6 +163,7 @@ func ScanCaret(buf []rune, pos int) Caret {
159163 type caretScan struct {160164 stack []callFrame // the calls open at this point, innermost last161165 inStr bool166+ quote int // start of a quoted sheet name being read, or -1162167 start int // start of the word being read, or -1163168 prev string // the word just before a space or "(" (SUM ( is allowed)164169 }
@@ -176,6 +181,12 @@ func (s *caretScan) step(buf []rune, i int) {
176181 s.inStr = r != '"'177182 return178183 }184+ if s.quote >= 0 {185+ if r == '\'' {186+ s.quote = -1187+ }188+ return189+ }179190 if isWordRune(r) {180191 if s.start < 0 {181192 s.start = i
@@ -190,6 +201,8 @@ func (s *caretScan) step(buf []rune, i int) {
190201 return // keep prev for "SUM ("191202 case '"':192203 s.inStr = true204+ case '\'':205+ s.quote = i193206 case '(':194207 s.stack = append(s.stack, callFrame{fn: strings.ToUpper(strings.TrimPrefix(s.prev, "@"))})195208 case ')':
M internal/ui/formula/formula_test.go +7 -0