cat / 012
clone

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

Formula suggestions: package suggest behind a seven-method host

Dami committed 9c70055
The suggestion list, its box, keys and mouse, and function signatures
move out of package ui; pointing after a sheet's name stays with the
entry. Tested against a fake host.
commit
9c70055602c065c6885927c7851c443ed2ca44f4
parent
606abb1bdbaa929d9a5c9604d86feebb353b4450
author
Dami <dami.etoile@gmail.com>
kind
refactor risk score 1.8 of 3, Jev's estimate
11 files changed, +571 -351
Minternal/ui/assist.go278+--------------------------
Minternal/ui/assist_test.go28+-
Minternal/ui/assistsheets.go58+-----
Minternal/ui/assistsheets_test.go18+-
Minternal/ui/entry.go13+-
Minternal/ui/hosts.go2+
Minternal/ui/overlay.go4+-
Minternal/ui/panel.go7+-
Ainternal/ui/suggest/sheets.go69+++++++
Ainternal/ui/suggest/suggest.go304++++++++++++++++++++++++++++++
Ainternal/ui/suggest/suggest_test.go141++++++++++++++
M internal/ui/assist.go +13 -265
@@ -1,237 +1,22 @@
11 package ui22 33 import (4- "slices"5- "strconv"6- "strings"7-84 tea "charm.land/bubbletea/v2"9- "github.com/charmbracelet/x/ansi"105 116 "github.com/FelineStateMachine/012/internal/sheet"127 "github.com/FelineStateMachine/012/internal/ui/formula"13- "github.com/FelineStateMachine/012/internal/ui/overlay"14- "github.com/FelineStateMachine/012/internal/ui/theme"8+ "github.com/FelineStateMachine/012/internal/ui/suggest"159 )1610 17-// Formula assistance, as in Sheets: while a function, range or sheet18-// name is being typed, a list of matching functions, named ranges and19-// other sheets (assistsheets.go) drops down from the formula bar at the20-// caret, and while the caret is inside a21-// function's parentheses the context line shows its signature with the22-// current argument marked. The list takes only the keys Sheets gives it23-// (Up/Down, Tab, Enter, Esc) and only while it shows, so arrows after an24-// operator still point at cells.25-26-// assist is the suggestion list: its state, and the keys, mouse, box27-// and status line it takes over while it shows. Like an overlay, it is28-// handed the model, for the entry and the sheet's names.29-type assist struct {30- active bool // the last key typed or deleted text; moving the caret hides the list31- sel, top int // highlighted suggestion and first one shown; reset as the word changes32-}33-34-// assistRows is the most suggestions shown at once.35-const assistRows = 836-37-const assistID = "assist"38-39-// suggestion is a function, named range or sheet offered for the word40-// at the caret.41-type suggestion struct {42- name string // e.g. "SUM", "Sales" or "'Q3 plan'!"43- detail string // the signature, the range a name stands for, or a sheet's cells44- desc string45- fn bool // a function: accepting it adds "("46-}47-48-// suggestions lists the functions, named ranges and sheets for word:49-// those starting with it first (names, then sheets, then functions),50-// then, from two letters on, those containing it, such as COUNTIFS for51-// "ifs". A word that is already a cell reference only gets those that52-// start with it, and a word in quotes ('Q3) only sheets.53-func suggestions(sh *sheet.Sheet, word string) []suggestion {54- if rest, quoted := strings.CutPrefix(word, "'"); quoted {55- return sheetSuggestions(sh, strings.ToUpper(rest))56- }57- w := strings.ToUpper(word)58- var prefix, inner []suggestion59- add := func(s suggestion, name string) {60- switch {61- case strings.HasPrefix(name, w):62- prefix = append(prefix, s)63- case len(w) >= 2 && strings.Contains(name, w):64- inner = append(inner, s)65- }66- }67- for _, n := range sh.Names() {68- add(suggestion{name: n.Name, detail: n.Ref(), desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))69- }70- for _, s := range otherSheets(sh) {71- add(sheetSuggestion(s), strings.ToUpper(s.Name()))72- }73- for _, f := range sheet.Funcs() {74- add(suggestion{name: f.Name, detail: f.Name + "(" + f.Args + ")", desc: f.Desc, fn: true}, f.Name)75- }76- if _, isRef := sheet.ParseAddr(w); isRef {77- return prefix78- }79- return append(prefix, inner...)80-}81-82-// shown returns the list for the word at the caret, and where83-// the word starts, or nothing when the list is hidden: outside ENTER and84-// EDIT, in plain text, after the caret moved, or when the word already85-// names a range exactly and nothing else matches.86-func (a *assist) shown(m *Model) ([]suggestion, int) {87- if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.IsFormula() || !a.active || m.overlay != nil {88- return nil, 089- }90- c := formula.ScanCaret(m.storedFormula(m.line.Buf), m.line.Pos)91- if c.Word == "" {92- return nil, 093- }94- list := suggestions(m.entrySheet(), c.Word)95- if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.Word) {96- return nil, 097- }98- a.sel = min(a.sel, len(list)-1)99- return list, c.WordStart100-}11+// Formula suggestions and signatures are package suggest; here is what12+// they ask of the model.10113 10214 // typeKey applies a line-editing key to the entry. Typing or deleting10315 // shows suggestions; moving the caret hides them, as in Sheets.10416 func (m *Model) typeKey(k tea.KeyPressMsg) {10517 before := m.line.Text()10618 m.line.Key(k)107- m.entry.assist = assist{active: m.line.Text() != before}108-}109-110-// key handles the keys the suggestion list takes while it shows.111-func (a *assist) key(m *Model, key string) bool {112- list, start := a.shown(m)113- if list == nil {114- return false115- }116- switch key {117- case "up", "ctrl+p":118- a.sel = (a.sel - 1 + len(list)) % len(list)119- case "down", "ctrl+n":120- a.sel = (a.sel + 1) % len(list)121- case "tab", "enter":122- a.accept(m, list[a.sel], start)123- case "esc":124- a.active = false125- default:126- return false127- }128- return true129-}130-131-// accept replaces the word at the caret with s, adding "(" after132-// a function unless one is already there.133-func (a *assist) accept(m *Model, s suggestion, start int) {134- text := []rune(s.name)135- rest := m.line.Buf[m.line.Pos:]136- if s.fn && (len(rest) == 0 || rest[0] != '(') {137- text = append(text, '(')138- }139- if strings.HasSuffix(s.name, "!") {140- rest = trimSheetEnd(rest) // the rest of a sheet name typed before141- }142- pos := start + len(text)143- if s.fn && len(rest) > 0 && rest[0] == '(' {144- pos++ // into the existing parentheses145- }146- m.line.Buf = slices.Concat(m.line.Buf[:start], text, rest)147- m.line.Pos = pos148- a.active = false149-}150-151-// box draws the list under the formula bar, its text aligned with152-// the word being typed: the name, then the signature or range dimmed.153-func (a *assist) box(m *Model) (overlay.Box, bool) {154- list, start := a.shown(m)155- if list == nil {156- return overlay.Box{}, false157- }158- rows := min(len(list), assistRows, m.height-gridTop-2)159- if rows < 1 {160- return overlay.Box{}, false161- }162- a.top = clamp(a.top, a.sel-rows+1, a.sel)163- a.top = clamp(a.top, 0, len(list)-rows)164- nw, dw := 0, 0165- for _, s := range list {166- nw = max(nw, ansi.StringWidth(s.name))167- dw = max(dw, ansi.StringWidth(s.detail))168- }169- inner := min(1+nw+3+dw+1, 64, m.width-2)170- lines := make([]string, 0, rows)171- for i := a.top; i < a.top+rows; i++ {172- s := list[i]173- base, dim := m.th.MenuBar, m.th.Muted174- if i == a.sel {175- base, dim = m.th.MenuSelected, m.th.MenuSelected176- }177- detail := s.detail178- if s.fn {179- detail = s.detail[len(s.name):] // just the arguments: (value1, ...)180- }181- row := base.Render(" "+theme.PadRight(s.name, nw)+" ") + dim.Render(detail)182- lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))183- }184- footer := ""185- if len(list) > rows {186- footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))187- }188- // The box's text starts one column in, under the word's first letter.189- x := formulaBarTextX() + ansi.StringWidth(string(m.line.Buf[:start])) - 2190- x = clamp(x, 0, max(m.width-inner-2, 0))191- return overlay.Box{ID: assistID, X: x, Y: contextLine + 1, Lines: m.th.Frame(inner, "", footer, lines)}, true192-}193-194-// mouse lets the mouse hover and click suggestions and scroll the195-// list. It reports false for events outside the list.196-func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {197- b, ok := a.box(m)198- if !ok {199- return false200- }201- mouse := msg.Mouse()202- h := compositor([]overlay.Box{b}).Hit(mouse.X, mouse.Y)203- if h.Empty() {204- return false205- }206- list, start := a.shown(m)207- i := a.top + mouse.Y - b.Y - 1208- switch msg.(type) {209- case tea.MouseWheelMsg:210- d := 1211- if mouse.Button == tea.MouseWheelUp {212- d = -1213- }214- a.sel = clamp(a.sel+d, 0, len(list)-1)215- case tea.MouseMotionMsg:216- if i >= a.top && i < min(len(list), a.top+b.Height()-2) {217- a.sel = i218- }219- case tea.MouseClickMsg:220- if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.Height()-2) {221- a.accept(m, list[i], start)222- }223- }224- return true225-}226-227-// status is the status line while the list shows: what the228-// highlighted suggestion is, and the keys that apply.229-func (a *assist) status(m *Model) (desc, keys string, ok bool) {230- list, _ := a.shown(m)231- if list == nil {232- return "", "", false233- }234- return list[a.sel].desc, m.th.KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true19+ m.entry.assist = suggest.List{Active: m.line.Text() != before}23520 }23621 23722 // inFunction reports whether the caret is inside a known function's
@@ -241,51 +26,14 @@ func (m *Model) inFunction() bool {
24126 return ok24227 }24328 244-// signatureLine puts the signature of the function around the caret on245-// the context line, with what the function does and the keys that apply246-// (hints) as room allows. It reports false outside a function.247-func signatureLine(th *theme.Theme, width int, buf []rune, pos int, sep byte, hints string) (left, right string, ok bool) {248- sig, desc := signature(th, buf, pos, sep)249- if sig == "" {250- return "", "", false251- }252- full := sig + " " + th.Muted.Render(desc)253- for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {254- if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {255- return try[0], try[1], true256- }257- }258- return sig, "", true259-}29+// The suggestions' host.26030 261-// signature renders the signature of the function around the caret, e.g.262-// SUM(value1, [value2, ...]), with the current argument marked, and what263-// the function does. It is "" outside a function. buf is the formula in264-// the syntax it's parsed in (see Model.storedFormula); sep separates the265-// arguments shown, ; in a locale with a decimal comma.266-func signature(th *theme.Theme, buf []rune, pos int, sep byte) (sig, desc string) {267- c := formula.ScanCaret(buf, pos)268- f, ok := sheet.LookupFunc(c.Fn)269- if c.Fn == "" || !ok {270- return "", ""271- }272- parts := formula.SplitArgs(f.Args)273- cur := formula.ArgPart(parts, c.Arg, f.Max < 0)274- var b strings.Builder275- b.WriteString(th.Key.Render(f.Name) + "(")276- for i, p := range parts {277- if i > 0 {278- b.WriteString(string(sep) + " ")279- }280- if sep != ',' {281- p = strings.ReplaceAll(p, ",", string(sep)) // [value2; ...]282- }283- if i == cur {284- b.WriteString(th.Argument.Render(p))285- } else {286- b.WriteString(p)287- }288- }289- b.WriteString(")")290- return b.String(), f.Desc31+// Typing reports whether an entry is being typed in the cell (ENTER or32+// EDIT) with nothing open over it.33+func (h host) Typing() bool {34+ return (h.m.mode == modeEnter || h.m.mode == modeEdit) && h.m.overlay == nil29135 }36+37+func (h host) EntrySheet() *sheet.Sheet { return h.m.entrySheet() }38+func (h host) Stored(buf []rune) []rune { return h.m.storedFormula(buf) }39+func (h host) TextX() int { return formulaBarTextX() }
M internal/ui/assist_test.go +15 -13
@@ -5,18 +5,20 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/ui/suggest"810 )911 1012 func TestAutocompleteFunctions(t *testing.T) {1113 m := newModel()1214 press(t, m, "=su")13- list, _ := m.entry.assist.shown(m)14- if len(list) == 0 || list[0].name != "SUBSTITUTE" && list[0].name != "SUM" {15+ list, _ := m.entry.assist.Shown(m.host())16+ if len(list) == 0 || list[0].Name != "SUBSTITUTE" && list[0].Name != "SUM" {1517 t.Fatalf("suggestions %v", list)1618 }1719 for _, s := range list {18- if !strings.HasPrefix(s.name, "SU") && !strings.Contains(s.name, "SU") {19- t.Errorf("unexpected %s", s.name)20+ if !strings.HasPrefix(s.Name, "SU") && !strings.Contains(s.Name, "SU") {21+ t.Errorf("unexpected %s", s.Name)2022 }2123 }2224 scr := screen(m)
@@ -27,7 +29,7 @@ func TestAutocompleteFunctions(t *testing.T) {
2729 t.Errorf("status %q", st)2830 }2931 // Down moves the highlight rather than committing; Tab inserts.30- for list[m.entry.assist.sel].name != "SUM" {32+ for list[m.entry.assist.Sel()].Name != "SUM" {3133 press(t, m, "<down>")3234 }3335 press(t, m, "<tab>")
@@ -38,7 +40,7 @@ func TestAutocompleteFunctions(t *testing.T) {
3840 t.Errorf("signature %q", ctx)3941 }4042 press(t, m, "1,")41- sig, _ := signature(&m.th, m.line.Buf, m.line.Pos, ',')43+ sig, _ := suggest.Signature(&m.th, m.line.Buf, m.line.Pos, ',')4244 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {4345 t.Errorf("second argument not marked: %q", sig)4446 }
@@ -62,7 +64,7 @@ func TestAutocompleteKeys(t *testing.T) {
6264 // Esc hides the list, a second Esc cancels the entry.6365 press(t, m, "=ro")6466 press(t, m, "<esc>")65- if list, _ := m.entry.assist.shown(m); list != nil || m.mode != modeEnter {67+ if list, _ := m.entry.assist.Shown(m.host()); list != nil || m.mode != modeEnter {6668 t.Fatalf("esc: %v %v", list, m.mode)6769 }6870 press(t, m, "<esc>")
@@ -77,16 +79,16 @@ func TestAutocompleteKeys(t *testing.T) {
7779 press(t, m, "<esc>", "<esc>")7880 // Moving the caret hides the list; a cell reference gets no list.7981 press(t, m, "=co", "<left>")80- if list, _ := m.entry.assist.shown(m); list != nil {82+ if list, _ := m.entry.assist.Shown(m.host()); list != nil {8183 t.Error("list after moving the caret")8284 }8385 press(t, m, "<esc>", "=B2")84- if list, _ := m.entry.assist.shown(m); list != nil {86+ if list, _ := m.entry.assist.Shown(m.host()); list != nil {8587 t.Errorf("list for a reference: %v", list)8688 }8789 // Plain text gets no list.8890 press(t, m, "<esc>", "su")89- if list, _ := m.entry.assist.shown(m); list != nil {91+ if list, _ := m.entry.assist.Shown(m.host()); list != nil {9092 t.Error("list for text")9193 }9294 }
@@ -95,12 +97,12 @@ func TestAutocompleteNamesAndMouse(t *testing.T) {
9597 m := tallModel()9698 m.sheet.DefineName("Sales", rectOf("B1:B3"))9799 press(t, m, "=SUM(sa")98- list, start := m.entry.assist.shown(m)99- if list[0].name != "Sales" || list[0].fn {100+ list, start := m.entry.assist.Shown(m.host())101+ if list[0].Name != "Sales" || list[0].Fn {100102 t.Fatalf("names first: %v", list)101103 }102104 // Clicking a suggestion inserts it.103- b, _ := m.entry.assist.box(m)105+ b, _ := m.entry.assist.Box(m.host())104106 send(m, tea.MouseClickMsg{X: b.X + 3, Y: b.Y + 1, Button: tea.MouseLeft})105107 if m.line.Text() != "=SUM(Sales" || m.line.Pos != start+5 {106108 t.Fatalf("click: %q", m.line.Text())
M internal/ui/assistsheets.go +4 -54
@@ -1,64 +1,14 @@
11 package ui22 33 import (4- "slices"5- "strings"6-74 "github.com/FelineStateMachine/012/internal/sheet"85 "github.com/FelineStateMachine/012/internal/ui/lineedit"96 )107 11-// Sheet names in formula suggestions: typing =Su offers Summary! with the12-// functions and named ranges, and ='Q3 offers 'Q3 plan'!, quoted as a13-// reference needs; the sheet the formula is on isn't, as its cells need14-// no name, and neither are hidden sheets. Accepting one inserts the sheet15-// and its "!"; an arrow then points into that sheet, as clicking its tab16-// would, so the reference comes out as Summary!B3.17-18-// sheetSuggestion is the suggestion for sheet s.19-func sheetSuggestion(s *sheet.Sheet) suggestion {20- detail, desc := "sheet, empty", "Sheet "+s.Name()+", empty"21- if used, ok := s.UsedRange(); ok {22- detail = "sheet, " + used.String()23- desc = "Sheet " + s.Name() + ": " + used.String() + ", " + cellCount(s.Len())24- }25- return suggestion{name: sheet.QuoteSheet(s.Name()) + "!", detail: detail, desc: desc}26-}27-28-// otherSheets are the sheets offered in formulas typed on sh: the29-// visible ones but sh, whose cells need no sheet name.30-func otherSheets(sh *sheet.Sheet) []*sheet.Sheet {31- return slices.DeleteFunc(sh.Book().Visible(), func(s *sheet.Sheet) bool { return s == sh })32-}33-34-// sheetSuggestions lists the other visible sheets for w, a quoted name typed35-// so far in upper case: those starting with it, then from two letters36-// on, those containing it.37-func sheetSuggestions(sh *sheet.Sheet, w string) []suggestion {38- var prefix, inner []suggestion39- for _, s := range otherSheets(sh) {40- name := strings.ToUpper(s.Name())41- switch {42- case strings.HasPrefix(name, w):43- prefix = append(prefix, sheetSuggestion(s))44- case len(w) >= 2 && strings.Contains(name, w):45- inner = append(inner, sheetSuggestion(s))46- }47- }48- return append(prefix, inner...)49-}50-51-// trimSheetEnd drops the end of a sheet reference ("!" or "'!") that52-// follows the caret, which an accepted sheet brings with it.53-func trimSheetEnd(rest []rune) []rune {54- switch {55- case len(rest) > 0 && rest[0] == '!':56- return rest[1:]57- case len(rest) > 1 && rest[0] == '\'' && rest[1] == '!':58- return rest[2:]59- }60- return rest61-}8+// Pointing after a sheet's name: once a suggestion (package suggest) or9+// typing has put Summary! before the caret, an arrow points into that10+// sheet, as clicking its tab would, so the reference comes out as11+// Summary!B3.6212 6313 // sheetBeforeCaret returns the sheet named just before the caret, as in6414 // "=SUM(Summary!" or "='Q3 plan'!", and where its name starts.
M internal/ui/assistsheets_test.go +10 -8
@@ -3,6 +3,8 @@ package ui
33 import (44 "strings"55 "testing"6+7+ "github.com/FelineStateMachine/012/internal/ui/suggest"68 )79 810 // sheetsModel has Sheet1 (shown), Summary with 7 in B3, and Q3 plan with
@@ -19,10 +21,10 @@ func sheetsModel(t *testing.T) *Model {
1921 return m2022 }2123 22-func names(list []suggestion) []string {24+func names(list []suggest.Suggestion) []string {2325 var out []string2426 for _, s := range list {25- out = append(out, s.name)27+ out = append(out, s.Name)2628 }2729 return out2830 }
@@ -30,7 +32,7 @@ func names(list []suggestion) []string {
3032 func TestAutocompleteSheets(t *testing.T) {3133 m := sheetsModel(t)3234 press(t, m, "=su")33- list, _ := m.entry.assist.shown(m)35+ list, _ := m.entry.assist.Shown(m.host())3436 got := names(list)3537 if len(got) < 2 || got[0] != "Summary!" || !strings.HasPrefix(got[1], "SU") {3638 t.Fatalf("suggestions %v", got)
@@ -55,8 +57,8 @@ func TestAutocompleteSheets(t *testing.T) {
5557 // inside quotes.5658 for _, typed := range []string{"=q3", "='q3 p", "='pl"} {5759 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+ list, start := m.entry.assist.Shown(m.host())61+ if len(list) == 0 || list[0].Name != "'Q3 plan'!" || start != 1 {6062 t.Errorf("%s: %v from %d", typed, names(list), start)6163 }6264 }
@@ -81,7 +83,7 @@ func TestAutocompleteSheets(t *testing.T) {
8183 func TestAutocompleteSheetsEdges(t *testing.T) {8284 m := sheetsModel(t)8385 press(t, m, "=she")84- if list, _ := m.entry.assist.shown(m); len(list) > 0 && list[0].name == "Sheet1!" {86+ if list, _ := m.entry.assist.Shown(m.host()); len(list) > 0 && list[0].Name == "Sheet1!" {8587 t.Errorf("the formula's own sheet offered: %v", names(list))8688 }8789 press(t, m, "et1!", "<right>")
@@ -92,7 +94,7 @@ func TestAutocompleteSheetsEdges(t *testing.T) {
9294 9395 m.book().HideSheet(m.book().Lookup("Summary"))9496 press(t, m, "=summ")95- if list, _ := m.entry.assist.shown(m); len(list) > 0 && list[0].name == "Summary!" {97+ if list, _ := m.entry.assist.Shown(m.host()); len(list) > 0 && list[0].Name == "Summary!" {9698 t.Errorf("hidden sheet offered: %v", names(list))9799 }98100 press(t, m, "<esc>")
@@ -100,7 +102,7 @@ func TestAutocompleteSheetsEdges(t *testing.T) {
100102 // Accepting inside an existing reference replaces the name only.101103 m.line.Set("='Q3 p'!A1")102104 m.line.Pos = len("='Q3 p")103- m.entry.assist.active = true105+ m.entry.assist.Active = true104106 m.mode = modeEnter105107 press(t, m, "<tab>")106108 if m.line.Text() != "='Q3 plan'!A1" || m.line.Pos != len("='Q3 plan'!") {
M internal/ui/entry.go +7 -6
@@ -9,6 +9,7 @@ import (
99 "github.com/FelineStateMachine/012/internal/macro"1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/suggest"1213 )1314 1415 // Entry follows Google Sheets: typing replaces the cell (ENTER), Enter or
@@ -30,7 +31,7 @@ type entry struct {
3031 // tabs.go.3132 home *sheet.Sheet3233 33- assist assist // function and name suggestions: assist.go34+ assist suggest.List // function and name suggestions: package suggest3435 3536 // tabStart remembers where a run of Tab-committed entries began, so3637 // Enter returns to that column on the next row, as in Sheets.
@@ -51,7 +52,7 @@ func (m *Model) startEntry(md mode, text string) {
5152 m.mode = md5253 m.entry.home = nil5354 m.line.Clear()54- m.entry.hint, m.entry.assist = "", assist{}55+ m.entry.hint, m.entry.assist = "", suggest.List{}5556 m.line.Insert(text)5657 }5758
@@ -68,7 +69,7 @@ func (m *Model) startEdit() tea.Cmd {
6869 // in a formula where a reference may follow the caret: then they point.6970 func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {7071 key := k.String()71- if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {72+ if m.entry.assist.Key(m.host(), key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {7273 return nil7374 }7475 if key == "f2" {
@@ -99,7 +100,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
99100 // editKey handles EDIT mode, where left and right move the caret.100101 func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {101102 key := k.String()102- if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {103+ if m.entry.assist.Key(m.host(), key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {103104 return nil104105 }105106 if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.canPoint() && m.startPoint(key)) {
@@ -213,7 +214,7 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
213214 if text := typed(k); text != "" {214215 m.resumeEntry(m.pointRef())215216 m.line.Insert(text)216- m.entry.assist = assist{active: true}217+ m.entry.assist = suggest.List{Active: true}217218 }218219 return nil219220 }
@@ -260,7 +261,7 @@ func (m *Model) cancelEntry() {
260261 m.returnHome()261262 m.mode = modeReady262263 m.line.Clear()263- m.entry.hint, m.entry.assist = "", assist{}264+ m.entry.hint, m.entry.assist = "", suggest.List{}264265 }265266 266267 func (m *Model) handlePaste(content string) {
M internal/ui/hosts.go +2 -0
M internal/ui/overlay.go +2 -2
M internal/ui/panel.go +4 -3
@@ -16,6 +16,7 @@ import (
1616 "github.com/FelineStateMachine/012/internal/sheet"1717 "github.com/FelineStateMachine/012/internal/telemetry"1818 "github.com/FelineStateMachine/012/internal/ui/overlay"19+ "github.com/FelineStateMachine/012/internal/ui/suggest"1920 "github.com/FelineStateMachine/012/internal/ui/tabstrip"2021 "github.com/FelineStateMachine/012/internal/ui/theme"2122 )
@@ -248,11 +249,11 @@ func (m *Model) contextLineText() string {
248249 case m.mode == modePoint:249250 prefix := []rune(m.entry.prefix)250251 var ok bool251- if left, right, ok = signatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {252+ if left, right, ok = suggest.SignatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {252253 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")253254 }254255 case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inFunction():255- left, right, _ = signatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))256+ left, right, _ = suggest.SignatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))256257 case m.mode == modeEnter && m.line.IsFormula():257258 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")258259 case m.mode == modeEnter:
@@ -316,7 +317,7 @@ func (m *Model) statusLayout() (string, []tabstrip.Span) {
316317 // floatingStatus is what the highlighted item of the open overlay or the317318 // formula suggestions does, and the keys that apply.318319 func (m *Model) floatingStatus() (string, bool) {319- desc, keys, floating := m.entry.assist.status(m)320+ desc, keys, floating := m.entry.assist.Status(m.host())320321 if m.overlay != nil {321322 desc, keys = m.overlay.Status()322323 floating = true
A internal/ui/suggest/sheets.go +69 -0
@@ -0,0 +1,69 @@
1+package suggest2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Sheet names in formula suggestions: typing =Su offers Summary! with the12+// functions and named ranges, and ='Q3 offers 'Q3 plan'!, quoted as a13+// reference needs; the sheet the formula is on isn't, as its cells need14+// no name, and neither are hidden sheets. Accepting one inserts the sheet15+// and its "!"; an arrow then points into that sheet, as clicking its tab16+// would, so the reference comes out as Summary!B3.17+18+// sheetSuggestion is the suggestion for sheet s.19+func sheetSuggestion(s *sheet.Sheet) Suggestion {20+ detail, desc := "sheet, empty", "Sheet "+s.Name()+", empty"21+ if used, ok := s.UsedRange(); ok {22+ detail = "sheet, " + used.String()23+ desc = "Sheet " + s.Name() + ": " + used.String() + ", " + cellCount(s.Len())24+ }25+ return Suggestion{Name: sheet.QuoteSheet(s.Name()) + "!", Detail: detail, Desc: desc}26+}27+28+// cellCount is n cells in words: "1 cell", "3 cells".29+func cellCount(n int) string {30+ if n == 1 {31+ return "1 cell"32+ }33+ return strconv.Itoa(n) + " cells"34+}35+36+// otherSheets are the sheets offered in formulas typed on sh: the37+// visible ones but sh, whose cells need no sheet name.38+func otherSheets(sh *sheet.Sheet) []*sheet.Sheet {39+ return slices.DeleteFunc(sh.Book().Visible(), func(s *sheet.Sheet) bool { return s == sh })40+}41+42+// sheetSuggestions lists the other visible sheets for w, a quoted name43+// typed so far in upper case: those starting with it, then from two44+// letters on, those containing it.45+func sheetSuggestions(sh *sheet.Sheet, w string) []Suggestion {46+ var prefix, inner []Suggestion47+ for _, s := range otherSheets(sh) {48+ name := strings.ToUpper(s.Name())49+ switch {50+ case strings.HasPrefix(name, w):51+ prefix = append(prefix, sheetSuggestion(s))52+ case len(w) >= 2 && strings.Contains(name, w):53+ inner = append(inner, sheetSuggestion(s))54+ }55+ }56+ return append(prefix, inner...)57+}58+59+// trimSheetEnd drops the end of a sheet reference ("!" or "'!") that60+// follows the caret, which an accepted sheet brings with it.61+func trimSheetEnd(rest []rune) []rune {62+ switch {63+ case len(rest) > 0 && rest[0] == '!':64+ return rest[1:]65+ case len(rest) > 1 && rest[0] == '\'' && rest[1] == '!':66+ return rest[2:]67+ }68+ return rest69+}
A internal/ui/suggest/suggest.go +304 -0
@@ -0,0 +1,304 @@
1+// Package suggest is formula assistance, as in Sheets: while a function,2+// range or sheet name is being typed, a list of matching functions,3+// named ranges and other sheets drops down from the formula bar at the4+// caret, and while the caret is inside a function's parentheses the5+// context line shows its signature with the current argument marked.6+// The list takes only the keys Sheets gives it (Up/Down, Tab, Enter,7+// Esc) and only while it shows, so arrows after an operator still point8+// at cells. It knows the UI only through Host.9+package suggest10+11+import (12+ "slices"13+ "strconv"14+ "strings"15+16+ tea "charm.land/bubbletea/v2"17+ "github.com/charmbracelet/x/ansi"18+19+ "github.com/FelineStateMachine/012/internal/sheet"20+ "github.com/FelineStateMachine/012/internal/ui/formula"21+ "github.com/FelineStateMachine/012/internal/ui/lineedit"22+ "github.com/FelineStateMachine/012/internal/ui/overlay"23+ "github.com/FelineStateMachine/012/internal/ui/theme"24+)25+26+// Host is what the suggestions need of the UI.27+type Host interface {28+ Theme() *theme.Theme29+ Size() (width, height int)30+ // Line is the edit line the entry is typed in.31+ Line() *lineedit.Line32+ // Typing reports whether a cell's entry is being typed with nothing33+ // open over it, when the list may show.34+ Typing() bool35+ // EntrySheet is the sheet the entry is on: its named ranges and the36+ // other sheets are offered.37+ EntrySheet() *sheet.Sheet38+ // Stored is typed text as the formula parser reads it, whatever the39+ // workbook's locale.40+ Stored(buf []rune) []rune41+ // TextX is the screen column the formula bar's text starts at.42+ TextX() int43+}44+45+// List is the suggestion list's state; the zero List is hidden.46+type List struct {47+ // Active is set when the last key typed or deleted text; moving the48+ // caret hides the list.49+ Active bool50+ sel, top int // highlighted suggestion and first one shown51+}52+53+// Sel is the highlighted suggestion.54+func (l *List) Sel() int { return l.sel }55+56+// Rows is the most suggestions shown at once.57+const Rows = 858+59+// ID identifies the list's box.60+const ID = "assist"61+62+// Suggestion is a function, named range or sheet offered for the word63+// at the caret.64+type Suggestion struct {65+ Name string // e.g. "SUM", "Sales" or "'Q3 plan'!"66+ Detail string // the signature, the range a name stands for, or a sheet's cells67+ Desc string68+ Fn bool // a function: accepting it adds "("69+}70+71+// For lists the functions, named ranges and sheets for word, typed on72+// sh: those starting with it first (names, then sheets, then73+// functions), then, from two letters on, those containing it, such as74+// COUNTIFS for "ifs". A word that is already a cell reference only gets75+// those that start with it, and a word in quotes ('Q3) only sheets.76+func For(sh *sheet.Sheet, word string) []Suggestion {77+ if rest, quoted := strings.CutPrefix(word, "'"); quoted {78+ return sheetSuggestions(sh, strings.ToUpper(rest))79+ }80+ w := strings.ToUpper(word)81+ var prefix, inner []Suggestion82+ add := func(s Suggestion, name string) {83+ switch {84+ case strings.HasPrefix(name, w):85+ prefix = append(prefix, s)86+ case len(w) >= 2 && strings.Contains(name, w):87+ inner = append(inner, s)88+ }89+ }90+ for _, n := range sh.Names() {91+ add(Suggestion{Name: n.Name, Detail: n.Ref(), Desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))92+ }93+ for _, s := range otherSheets(sh) {94+ add(sheetSuggestion(s), strings.ToUpper(s.Name()))95+ }96+ for _, f := range sheet.Funcs() {97+ add(Suggestion{Name: f.Name, Detail: f.Name + "(" + f.Args + ")", Desc: f.Desc, Fn: true}, f.Name)98+ }99+ if _, isRef := sheet.ParseAddr(w); isRef {100+ return prefix101+ }102+ return append(prefix, inner...)103+}104+105+// Shown returns the list for the word at the caret, and where the word106+// starts, or nothing when the list is hidden: outside an entry, in107+// plain text, after the caret moved, or when the word already names a108+// range exactly and nothing else matches.109+func (l *List) Shown(h Host) ([]Suggestion, int) {110+ line := h.Line()111+ if !h.Typing() || !line.IsFormula() || !l.Active {112+ return nil, 0113+ }114+ c := formula.ScanCaret(h.Stored(line.Buf), line.Pos)115+ if c.Word == "" {116+ return nil, 0117+ }118+ list := For(h.EntrySheet(), c.Word)119+ if len(list) == 0 || len(list) == 1 && !list[0].Fn && strings.EqualFold(list[0].Name, c.Word) {120+ return nil, 0121+ }122+ l.sel = min(l.sel, len(list)-1)123+ return list, c.WordStart124+}125+126+// Key handles the keys the list takes while it shows, and reports127+// whether it took key.128+func (l *List) Key(h Host, key string) bool {129+ list, start := l.Shown(h)130+ if list == nil {131+ return false132+ }133+ switch key {134+ case "up", "ctrl+p":135+ l.sel = (l.sel - 1 + len(list)) % len(list)136+ case "down", "ctrl+n":137+ l.sel = (l.sel + 1) % len(list)138+ case "tab", "enter":139+ l.accept(h, list[l.sel], start)140+ case "esc":141+ l.Active = false142+ default:143+ return false144+ }145+ return true146+}147+148+// accept replaces the word at the caret with s, adding "(" after a149+// function unless one is already there.150+func (l *List) accept(h Host, s Suggestion, start int) {151+ line := h.Line()152+ text := []rune(s.Name)153+ rest := line.Buf[line.Pos:]154+ if s.Fn && (len(rest) == 0 || rest[0] != '(') {155+ text = append(text, '(')156+ }157+ if strings.HasSuffix(s.Name, "!") {158+ rest = trimSheetEnd(rest) // the rest of a sheet name typed before159+ }160+ pos := start + len(text)161+ if s.Fn && len(rest) > 0 && rest[0] == '(' {162+ pos++ // into the existing parentheses163+ }164+ line.Buf = slices.Concat(line.Buf[:start], text, rest)165+ line.Pos = pos166+ l.Active = false167+}168+169+// Box draws the list under the formula bar, its text aligned with the170+// word being typed: the name, then the signature or range dimmed.171+func (l *List) Box(h Host) (overlay.Box, bool) {172+ list, start := l.Shown(h)173+ if list == nil {174+ return overlay.Box{}, false175+ }176+ th := h.Theme()177+ width, height := h.Size()178+ rows := min(len(list), Rows, height-overlay.GridTop-2)179+ if rows < 1 {180+ return overlay.Box{}, false181+ }182+ l.top = min(max(l.top, l.sel-rows+1), l.sel)183+ l.top = min(max(l.top, 0), len(list)-rows)184+ nw, dw := 0, 0185+ for _, s := range list {186+ nw = max(nw, ansi.StringWidth(s.Name))187+ dw = max(dw, ansi.StringWidth(s.Detail))188+ }189+ inner := min(1+nw+3+dw+1, 64, width-2)190+ lines := make([]string, 0, rows)191+ for i := l.top; i < l.top+rows; i++ {192+ s := list[i]193+ base, dim := th.MenuBar, th.Muted194+ if i == l.sel {195+ base, dim = th.MenuSelected, th.MenuSelected196+ }197+ detail := s.Detail198+ if s.Fn {199+ detail = s.Detail[len(s.Name):] // just the arguments: (value1, ...)200+ }201+ row := base.Render(" "+theme.PadRight(s.Name, nw)+" ") + dim.Render(detail)202+ lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))203+ }204+ footer := ""205+ if len(list) > rows {206+ footer = strconv.Itoa(l.sel+1) + " of " + strconv.Itoa(len(list))207+ }208+ // The box's text starts one column in, under the word's first letter.209+ x := h.TextX() + ansi.StringWidth(string(h.Line().Buf[:start])) - 2210+ x = min(max(x, 0), max(width-inner-2, 0))211+ return overlay.Box{ID: ID, X: x, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "", footer, lines)}, true212+}213+214+// Mouse lets the mouse hover and click suggestions and scroll the list.215+// It reports false for events outside the list.216+func (l *List) Mouse(h Host, msg tea.MouseMsg) bool {217+ b, ok := l.Box(h)218+ if !ok {219+ return false220+ }221+ mouse := msg.Mouse()222+ if mouse.X < b.X || mouse.X >= b.X+b.Width() || mouse.Y < b.Y || mouse.Y >= b.Y+b.Height() {223+ return false224+ }225+ list, start := l.Shown(h)226+ i := l.top + mouse.Y - b.Y - 1227+ onRow := i >= l.top && i < min(len(list), l.top+b.Height()-2)228+ switch msg.(type) {229+ case tea.MouseWheelMsg:230+ d := 1231+ if mouse.Button == tea.MouseWheelUp {232+ d = -1233+ }234+ l.sel = min(max(l.sel+d, 0), len(list)-1)235+ case tea.MouseMotionMsg:236+ if onRow {237+ l.sel = i238+ }239+ case tea.MouseClickMsg:240+ if mouse.Button == tea.MouseLeft && onRow {241+ l.accept(h, list[i], start)242+ }243+ }244+ return true245+}246+247+// Status is the status line while the list shows: what the highlighted248+// suggestion is, and the keys that apply.249+func (l *List) Status(h Host) (desc, keys string, ok bool) {250+ list, _ := l.Shown(h)251+ if list == nil {252+ return "", "", false253+ }254+ return list[l.sel].Desc, h.Theme().KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true255+}256+257+// SignatureLine puts the signature of the function around the caret on258+// the context line, with what the function does and the keys that apply259+// (hints) as room allows. It reports false outside a function.260+func SignatureLine(th *theme.Theme, width int, buf []rune, pos int, sep byte, hints string) (left, right string, ok bool) {261+ sig, desc := Signature(th, buf, pos, sep)262+ if sig == "" {263+ return "", "", false264+ }265+ full := sig + " " + th.Muted.Render(desc)266+ for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {267+ if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {268+ return try[0], try[1], true269+ }270+ }271+ return sig, "", true272+}273+274+// Signature renders the signature of the function around the caret, e.g.275+// SUM(value1, [value2, ...]), with the current argument marked, and what276+// the function does. It is "" outside a function. buf is the formula in277+// the syntax it's parsed in (see Host.Stored); sep separates the278+// arguments shown, ; in a locale with a decimal comma.279+func Signature(th *theme.Theme, buf []rune, pos int, sep byte) (sig, desc string) {280+ c := formula.ScanCaret(buf, pos)281+ f, ok := sheet.LookupFunc(c.Fn)282+ if c.Fn == "" || !ok {283+ return "", ""284+ }285+ parts := formula.SplitArgs(f.Args)286+ cur := formula.ArgPart(parts, c.Arg, f.Max < 0)287+ var b strings.Builder288+ b.WriteString(th.Key.Render(f.Name) + "(")289+ for i, p := range parts {290+ if i > 0 {291+ b.WriteString(string(sep) + " ")292+ }293+ if sep != ',' {294+ p = strings.ReplaceAll(p, ",", string(sep)) // [value2; ...]295+ }296+ if i == cur {297+ b.WriteString(th.Argument.Render(p))298+ } else {299+ b.WriteString(p)300+ }301+ }302+ b.WriteString(")")303+ return b.String(), f.Desc304+}
A internal/ui/suggest/suggest_test.go +141 -0
@@ -0,0 +1,141 @@
1+package suggest2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/lineedit"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+// fakeHost is an entry being typed on Sheet1 of a book that also has17+// Summary and a range named Sales.18+type fakeHost struct {19+ th theme.Theme20+ line lineedit.Line21+ s *sheet.Sheet22+ typing bool23+}24+25+func newHost(t *testing.T) *fakeHost {26+ t.Helper()27+ s := sheet.New()28+ if _, err := s.Book().AddSheet("Summary", 1); err != nil {29+ t.Fatal(err)30+ }31+ if err := s.DefineName("Sales", sheet.Rect{To: sheet.Addr{Row: 4}}); err != nil {32+ t.Fatal(err)33+ }34+ return &fakeHost{th: theme.New(true), s: s, typing: true}35+}36+37+func (h *fakeHost) Theme() *theme.Theme { return &h.th }38+func (h *fakeHost) Size() (int, int) { return 80, 24 }39+func (h *fakeHost) Line() *lineedit.Line { return &h.line }40+func (h *fakeHost) Typing() bool { return h.typing }41+func (h *fakeHost) EntrySheet() *sheet.Sheet { return h.s }42+func (h *fakeHost) Stored(buf []rune) []rune { return buf }43+func (h *fakeHost) TextX() int { return 12 }44+45+func names(list []Suggestion) []string {46+ var out []string47+ for _, s := range list {48+ out = append(out, s.Name)49+ }50+ return out51+}52+53+func TestOffersNamesSheetsThenFunctions(t *testing.T) {54+ h := newHost(t)55+ got := names(For(h.s, "s"))56+ if len(got) < 3 || got[0] != "Sales" || got[1] != "Summary!" || got[2] != "SEARCH" && !strings.HasPrefix(got[2], "S") {57+ t.Fatalf("for s: %v", got[:min(len(got), 5)])58+ }59+ if got := names(For(h.s, "'su")); len(got) != 1 || got[0] != "Summary!" {60+ t.Fatalf("a quoted word offers only sheets: %v", got)61+ }62+ if got := names(For(h.s, "ifs")); !strings.Contains(strings.Join(got, " "), "COUNTIFS") {63+ t.Fatalf("for ifs: %v", got)64+ }65+}66+67+func TestKeysMoveAndAccept(t *testing.T) {68+ h := newHost(t)69+ var l List70+ h.line.Set("=su")71+ if list, _ := l.Shown(h); list != nil {72+ t.Fatal("the list shows before a key typed text")73+ }74+ l.Active = true75+ list, start := l.Shown(h)76+ if len(list) == 0 || start != 1 {77+ t.Fatalf("list %v from %d", names(list), start)78+ }79+ for list[l.Sel()].Name != "SUM" {80+ l.Key(h, "down")81+ }82+ if !l.Key(h, "tab") || h.line.Text() != "=SUM(" || h.line.Pos != 5 || l.Active {83+ t.Fatalf("accepted %q at %d, active %v", h.line.Text(), h.line.Pos, l.Active)84+ }85+ if l.Key(h, "down") {86+ t.Fatal("a hidden list took a key")87+ }88+ h.line.Set("=Summ!A1")89+ h.line.Pos = len("=Summ")90+ l.Active = true91+ l.Key(h, "enter")92+ if h.line.Text() != "=Summary!A1" {93+ t.Fatalf("a sheet replaced its name as %q", h.line.Text())94+ }95+ h.typing, l.Active = false, true96+ h.line.Set("=su")97+ if list, _ := l.Shown(h); list != nil {98+ t.Fatal("the list shows while something is open over the entry")99+ }100+}101+102+func TestBoxAndMouse(t *testing.T) {103+ h := newHost(t)104+ l := List{Active: true}105+ h.line.Set("=co")106+ b, ok := l.Box(h)107+ if !ok || b.Y != overlay.ContextLine+1 || b.X != 12+1-2 || b.Height() != Rows+2 {108+ t.Fatalf("box at %d,%d, %d high", b.X, b.Y, b.Height())109+ }110+ if !strings.Contains(ansi.Strip(strings.Join(b.Lines, "\n")), "of ") {111+ t.Error("a long list says where it is")112+ }113+ if l.Mouse(h, tea.MouseClickMsg{X: 70, Y: 20, Button: tea.MouseLeft}) {114+ t.Error("a click outside the list was taken")115+ }116+ l.Mouse(h, tea.MouseMotionMsg{X: b.X + 2, Y: b.Y + 3})117+ if l.Sel() != 2 {118+ t.Errorf("hovering the third row highlighted %d", l.Sel())119+ }120+ list, _ := l.Shown(h)121+ want := list[2].Name122+ l.Mouse(h, tea.MouseClickMsg{X: b.X + 2, Y: b.Y + 3, Button: tea.MouseLeft})123+ if !strings.HasPrefix(h.line.Text(), "="+want) {124+ t.Errorf("clicking inserted %q, want %s", h.line.Text(), want)125+ }126+ if desc, _, ok := (&List{Active: true}).Status(h); ok || desc != "" {127+ t.Error("status with the list hidden")128+ }129+}130+131+func TestSignatureMarksTheArgument(t *testing.T) {132+ th := theme.New(true)133+ buf := []rune("=SUM(1,2")134+ sig, desc := Signature(&th, buf, len(buf), ';')135+ if !strings.Contains(sig, th.Argument.Render("[value2; ...]")) || desc == "" {136+ t.Fatalf("signature %q, %q", sig, desc)137+ }138+ if left, _, ok := SignatureLine(&th, 20, []rune("=1+2"), 4, ',', ""); ok || left != "" {139+ t.Fatalf("outside a function: %q", left)140+ }141+}