- commit
- 625fe586e7dc87dadad36db5dc705c5bcd1b576f
- parent
- 5a0b9b1b3b50b879e9b49cd12962c9e66c1d763f
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 6 | +- |
| M | docs/ | 2 | +- |
| M | internal/ | 36 | +++++--- |
| A | internal/ | 131 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 109 | +++++++++++++++++++++++++ |
| M | internal/ | 4 | +- |
| M | internal/ | 21 | ++++- |
| M | internal/ | 7 | ++ |
@@ -46,7 +46,11 @@ caret on the problem and says what's wrong on the context line, e.g.
4646 - After an operator or `(`, arrow keys (or a click) pick a cell and insert4747 its reference; Shift+arrows (or a drag) pick a range. Keep typing to go on.4848 - While you type a name, suggestions drop down: named ranges first, then49- functions with their arguments. Tab or Enter inserts `NAME(`.49+ the other sheets, then functions with their arguments. Tab or Enter50+ inserts `NAME(`, or a sheet with its `!`, quoted when it needs to be51+ (`=Su` offers `Summary!`; `=Q3` or `='Q3` offers `'Q3 plan'!`). An arrow52+ after it points into that sheet, as clicking its tab does. Hidden53+ sheets aren't offered, though formulas still read them.5054 - Inside a function's parentheses the context line shows its signature with5155 the current argument marked, e.g. `SUMIF(range, criterion, [sum_range])`.5256 - Alt+, and Alt+. trace precedents and dependents: the cells a formula reads@@ -31,7 +31,7 @@ the app uses, so it never drifts from what the keys do.
3131 | Ctrl+Alt+= / Ctrl+Alt+- | Insert rows above / delete the selected rows (columns when whole columns are selected) |3232 | Ctrl+Enter while typing | Enter the same entry in every selected cell |3333 | F4 while typing a formula | Cycle the reference at the caret through A1, $A$1, A$1, $A1 |34-| Typing a function or range name | Suggestions drop down: Up/Down pick, Tab or Enter insert, Esc hides them; inside a function's parentheses the context line shows its arguments with the current one marked |34+| Typing a function, range or sheet name | Suggestions drop down: Up/Down pick, Tab or Enter insert (a sheet as `Summary!`, then arrows point into it), Esc hides them; inside a function's parentheses the context line shows its arguments with the current one marked |3535 | Alt+, / Alt+. | Trace precedents / dependents: highlight the cells a formula reads, or the formulas that read the cell, and jump to the first; press again for the next, Esc to go back (Excel's Ctrl+[ and Ctrl+], which terminals send as Esc) |3636 | Data > Named ranges | Name ranges for formulas (`=SUM(Sales)`): Enter goes to one, F2 renames or repoints it, Ctrl+D deletes it; Data > Define named range names the selection |3737 | Ctrl+Shift+1 ... 6 | Number, time, date, currency, percent, scientific format |@@ -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@@ -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+}@@ -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+}@@ -72,7 +72,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7272 m.toggleAbsolute()7373 return nil7474 }75- if m.line.isFormula() && m.line.canPoint() && m.startPoint(key) {75+ if m.line.isFormula() && (m.pointAfterSheet(key) || m.line.canPoint() && m.startPoint(key)) {7676 return nil7777 }7878 if m.line.isFormula() && (key == "left" || key == "right") {@@ -95,7 +95,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
9595 if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {9696 return nil9797 }98- if m.line.isFormula() && m.line.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {98+ if m.line.isFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.canPoint() && m.startPoint(key)) {9999 return nil100100 }101101 switch key {@@ -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 ')':@@ -25,6 +25,13 @@ func TestScanCaret(t *testing.T) {
2525 {"=A1+1|", "", "", 0},2626 {"=SU|M(1)", "", "", 0},2727 {"=Sales|", "Sales", "", 0},28+ // Sheet names: quoted ones are a word from the quote on, and29+ // what's inside the quotes doesn't open calls or arguments.30+ {"=SUM('Q3 p|", "'Q3 p", "SUM", 0},31+ {"='|", "'", "", 0},32+ {"=SUM('a(b, c'!A1, |", "", "SUM", 1},33+ {"='Q3 plan'!A|", "A", "", 0},34+ {"=Summary!B|", "B", "", 0},2835 } {2936 i := strings.Index(tc.text, "|")3037 buf := []rune(strings.Replace(tc.text, "|", "", 1))