cat / 012
clone

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

Suggest functions and names while typing, name ranges, trace precedents

Dami committed 6d5236e
commit
6d5236ed3d07c396797e3214465faa65f981c093
parent
617fae6651b524f3bc85b160c160f9b27c0dcf31
author
Dami <dami.etoile@gmail.com>
15 files changed, +1174 -19
Ainternal/ui/assist.go389++++++++++++++++++++++++++++++
Ainternal/ui/assist_test.go180++++++++++++++
Minternal/ui/entry.go15+-
Minternal/ui/help_test.go2+-
Minternal/ui/menu.go15+-
Minternal/ui/menubar.go4+-
Minternal/ui/model.go9+
Ainternal/ui/names.go176++++++++++++++
Ainternal/ui/names_test.go132++++++++++
Minternal/ui/overlay.go21+-
Minternal/ui/panel.go24++-
Minternal/ui/theme.go4+
Ainternal/ui/trace.go149+++++++++++
Ainternal/ui/trace_test.go71+++++
Minternal/ui/view.go2+
A internal/ui/assist.go +389 -0
@@ -0,0 +1,389 @@
1+package ui2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+ "unicode"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "012/internal/sheet"13+)14+15+// Formula assistance, as in Sheets: while a function or range name is16+// being typed, a list of matching functions and named ranges drops down17+// from the formula bar at the caret, and while the caret is inside a18+// function's parentheses the context line shows its signature with the19+// current argument marked. The list takes only the keys Sheets gives it20+// (Up/Down, Tab, Enter, Esc) and only while it shows, so arrows after an21+// operator still point at cells.22+23+// assist is the state of the suggestion list.24+type assist struct {25+ active bool // the last key typed or deleted text; moving the caret hides the list26+ sel, top int // highlighted suggestion and first one shown; reset as the word changes27+}28+29+// assistRows is the most suggestions shown at once.30+const assistRows = 831+32+const assistID = "assist"33+34+// suggestion is a function or named range offered for the word at the35+// caret.36+type suggestion struct {37+ name string // e.g. "SUM" or "Sales"38+ detail string // the signature, or the range a name stands for39+ desc string40+ fn bool // a function: accepting it adds "("41+}42+43+// caret describes the formula text before the caret: the word being44+// typed, if it may be a function or name, and the innermost function call45+// the caret is in.46+type caret struct {47+ word string48+ wordStart int49+ fn string // e.g. "SUM", or "" outside any function's parentheses50+ arg int // index of the argument the caret is in51+}52+53+func isWordRune(r rune) bool {54+ return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'55+}56+57+// scanCaret reads a formula up to pos the way the lexer does: strings are58+// skipped, parentheses nest, and commas or semicolons at the innermost59+// level separate arguments.60+func scanCaret(buf []rune, pos int) caret {61+ type frame struct {62+ fn string63+ arg int64+ }65+ var stack []frame66+ var c caret67+ inStr := false68+ start := -1 // start of the word being read69+ prev := "" // the word just before a space or "(" (SUM ( is allowed)70+ for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"71+ r := buf[i]72+ if inStr {73+ inStr = r != '"'74+ continue75+ }76+ if isWordRune(r) {77+ if start < 0 {78+ start = i79+ }80+ continue81+ }82+ if start >= 0 {83+ prev, start = string(buf[start:i]), -184+ }85+ switch r {86+ case ' ':87+ continue // keep prev for "SUM ("88+ case '"':89+ inStr = true90+ case '(':91+ stack = append(stack, frame{fn: strings.ToUpper(strings.TrimPrefix(prev, "@"))})92+ case ')':93+ if len(stack) > 0 {94+ stack = stack[:len(stack)-1]95+ }96+ case ',', ';':97+ if len(stack) > 0 {98+ stack[len(stack)-1].arg++99+ }100+ }101+ prev = ""102+ }103+ if inStr {104+ return caret{}105+ }106+ for i := len(stack) - 1; i >= 0; i-- {107+ if stack[i].fn != "" {108+ c.fn, c.arg = stack[i].fn, stack[i].arg109+ break110+ }111+ }112+ if start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {113+ w := []rune(strings.TrimPrefix(string(buf[start:pos]), "@"))114+ if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {115+ c.word, c.wordStart = string(w), pos-len(w)116+ }117+ }118+ return c119+}120+121+// suggestions lists the functions and named ranges for word: those122+// starting with it first (names before functions), then, from two123+// letters on, those containing it, such as COUNTIFS for "ifs". A word124+// that is already a cell reference only gets names and functions that125+// start with it.126+func (m *Model) suggestions(word string) []suggestion {127+ w := strings.ToUpper(word)128+ var prefix, inner []suggestion129+ add := func(s suggestion, name string) {130+ switch {131+ case strings.HasPrefix(name, w):132+ prefix = append(prefix, s)133+ case len(w) >= 2 && strings.Contains(name, w):134+ inner = append(inner, s)135+ }136+ }137+ for _, n := range m.sheet.Names() {138+ add(suggestion{name: n.Name, detail: n.Ref(), desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))139+ }140+ for _, f := range sheet.Funcs() {141+ add(suggestion{name: f.Name, detail: f.Name + "(" + f.Args + ")", desc: f.Desc, fn: true}, f.Name)142+ }143+ if _, isRef := sheet.ParseAddr(w); isRef {144+ return prefix145+ }146+ return append(prefix, inner...)147+}148+149+// shownSuggestions returns the list for the word at the caret, and where150+// the word starts, or nothing when the list is hidden: outside ENTER and151+// EDIT, in plain text, after the caret moved, or when the word already152+// names a range exactly and nothing else matches.153+func (m *Model) shownSuggestions() ([]suggestion, int) {154+ if (m.mode != modeEnter && m.mode != modeEdit) || !m.isFormula() || !m.assist.active || m.overlay != nil {155+ return nil, 0156+ }157+ c := scanCaret(m.buf, m.bufPos)158+ if c.word == "" {159+ return nil, 0160+ }161+ list := m.suggestions(c.word)162+ if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.word) {163+ return nil, 0164+ }165+ m.assist.sel = min(m.assist.sel, len(list)-1)166+ return list, c.wordStart167+}168+169+// typeKey applies a line-editing key to the entry. Typing or deleting170+// shows suggestions; moving the caret hides them, as in Sheets.171+func (m *Model) typeKey(k tea.KeyPressMsg) {172+ before := string(m.buf)173+ m.lineKey(k)174+ m.assist = assist{active: string(m.buf) != before}175+}176+177+// assistKey handles the keys the suggestion list takes while it shows.178+func (m *Model) assistKey(key string) bool {179+ list, start := m.shownSuggestions()180+ if list == nil {181+ return false182+ }183+ switch key {184+ case "up", "ctrl+p":185+ m.assist.sel = (m.assist.sel - 1 + len(list)) % len(list)186+ case "down", "ctrl+n":187+ m.assist.sel = (m.assist.sel + 1) % len(list)188+ case "tab", "enter":189+ m.acceptSuggestion(list[m.assist.sel], start)190+ case "esc":191+ m.assist.active = false192+ default:193+ return false194+ }195+ return true196+}197+198+// acceptSuggestion replaces the word at the caret with s, adding "(" after199+// a function unless one is already there.200+func (m *Model) acceptSuggestion(s suggestion, start int) {201+ text := []rune(s.name)202+ rest := m.buf[m.bufPos:]203+ if s.fn && (len(rest) == 0 || rest[0] != '(') {204+ text = append(text, '(')205+ }206+ pos := start + len(text)207+ if s.fn && len(rest) > 0 && rest[0] == '(' {208+ pos++ // into the existing parentheses209+ }210+ m.buf = slices.Concat(m.buf[:start], text, rest)211+ m.bufPos = pos212+ m.assist.active = false213+}214+215+// assistBox draws the list under the formula bar, its text aligned with216+// the word being typed: the name, then the signature or range dimmed.217+func (m *Model) assistBox() (box, bool) {218+ list, start := m.shownSuggestions()219+ if list == nil {220+ return box{}, false221+ }222+ rows := min(len(list), assistRows, m.height-gridTop-2)223+ if rows < 1 {224+ return box{}, false225+ }226+ a := &m.assist227+ a.top = clamp(a.top, a.sel-rows+1, a.sel)228+ a.top = clamp(a.top, 0, len(list)-rows)229+ nw, dw := 0, 0230+ for _, s := range list {231+ nw = max(nw, ansi.StringWidth(s.name))232+ dw = max(dw, ansi.StringWidth(s.detail))233+ }234+ inner := min(1+nw+3+dw+1, 64, m.width-2)235+ lines := make([]string, 0, rows)236+ for i := a.top; i < a.top+rows; i++ {237+ s := list[i]238+ base, dim := m.th.menuBar, m.th.muted239+ if i == a.sel {240+ base, dim = m.th.menuSelected, m.th.menuSelected241+ }242+ detail := s.detail243+ if s.fn {244+ detail = s.detail[len(s.name):] // just the arguments: (value1, ...)245+ }246+ row := base.Render(" "+padRight(s.name, nw)+" ") + dim.Render(detail)247+ lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))248+ }249+ footer := ""250+ if len(list) > rows {251+ footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))252+ }253+ // The box's text starts one column in, under the word's first letter.254+ x := formulaBarTextX() + ansi.StringWidth(string(m.buf[:start])) - 2255+ x = clamp(x, 0, max(m.width-inner-2, 0))256+ return box{id: assistID, x: x, y: contextLine + 1, lines: m.frame(inner, "", footer, lines)}, true257+}258+259+// assistMouse lets the mouse hover and click suggestions and scroll the260+// list. It reports false for events outside the list.261+func (m *Model) assistMouse(msg tea.MouseMsg) bool {262+ b, ok := m.assistBox()263+ if !ok {264+ return false265+ }266+ mouse := msg.Mouse()267+ h := compositor([]box{b}).Hit(mouse.X, mouse.Y)268+ if h.Empty() {269+ return false270+ }271+ list, start := m.shownSuggestions()272+ i := m.assist.top + mouse.Y - b.y - 1273+ switch msg.(type) {274+ case tea.MouseWheelMsg:275+ d := 1276+ if mouse.Button == tea.MouseWheelUp {277+ d = -1278+ }279+ m.assist.sel = clamp(m.assist.sel+d, 0, len(list)-1)280+ case tea.MouseMotionMsg:281+ if i >= m.assist.top && i < min(len(list), m.assist.top+b.height()-2) {282+ m.assist.sel = i283+ }284+ case tea.MouseClickMsg:285+ if mouse.Button == tea.MouseLeft && i >= m.assist.top && i < min(len(list), m.assist.top+b.height()-2) {286+ m.acceptSuggestion(list[i], start)287+ }288+ }289+ return true290+}291+292+// assistStatus is the status line while the list shows: what the293+// highlighted suggestion is, and the keys that apply.294+func (m *Model) assistStatus() (desc, keys string, ok bool) {295+ list, _ := m.shownSuggestions()296+ if list == nil {297+ return "", "", false298+ }299+ return list[m.assist.sel].desc, m.keyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true300+}301+302+// inFunction reports whether the caret is inside a known function's303+// parentheses.304+func (m *Model) inFunction() bool {305+ _, ok := sheet.LookupFunc(scanCaret(m.buf, m.bufPos).fn)306+ return ok307+}308+309+// signatureLine puts the signature of the function around the caret on310+// the context line, with what the function does and the keys that apply311+// (hints) as room allows. It reports false outside a function.312+func (m *Model) signatureLine(buf []rune, pos int, hints string) (left, right string, ok bool) {313+ sig, desc := m.signature(buf, pos)314+ if sig == "" {315+ return "", "", false316+ }317+ full := sig + " " + m.th.muted.Render(desc)318+ for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {319+ if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= m.width {320+ return try[0], try[1], true321+ }322+ }323+ return sig, "", true324+}325+326+// signature renders the signature of the function around the caret, e.g.327+// SUM(value1, [value2, ...]), with the current argument marked, and what328+// the function does. It is "" outside a function.329+func (m *Model) signature(buf []rune, pos int) (sig, desc string) {330+ c := scanCaret(buf, pos)331+ f, ok := sheet.LookupFunc(c.fn)332+ if c.fn == "" || !ok {333+ return "", ""334+ }335+ parts := splitArgs(f.Args)336+ cur := argPart(parts, c.arg, f.Max < 0)337+ var b strings.Builder338+ b.WriteString(m.th.key.Render(f.Name) + "(")339+ for i, p := range parts {340+ if i > 0 {341+ b.WriteString(", ")342+ }343+ if i == cur {344+ b.WriteString(m.th.argument.Render(p))345+ } else {346+ b.WriteString(p)347+ }348+ }349+ b.WriteString(")")350+ return b.String(), f.Desc351+}352+353+// splitArgs splits a signature's arguments at the top-level commas, so354+// "[value2, ...]" stays one part.355+func splitArgs(args string) []string {356+ var out []string357+ depth, start := 0, 0358+ for i, r := range args {359+ switch r {360+ case '[':361+ depth++362+ case ']':363+ depth--364+ case ',':365+ if depth == 0 {366+ out = append(out, strings.TrimSpace(args[start:i]))367+ start = i + 1368+ }369+ }370+ }371+ if s := strings.TrimSpace(args[start:]); s != "" {372+ out = append(out, s)373+ }374+ return out375+}376+377+// argPart maps an argument index to the part of the signature that378+// describes it: a repeating part like "[value2, ...]" covers every379+// argument from its position on. It returns -1 past the last argument.380+func argPart(parts []string, arg int, variadic bool) int {381+ rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })382+ switch {383+ case rep >= 0 && variadic && arg >= rep:384+ return rep385+ case arg < len(parts):386+ return arg387+ }388+ return -1389+}
A internal/ui/assist_test.go +180 -0
@@ -0,0 +1,180 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+)9+10+func TestScanCaret(t *testing.T) {11+ for _, tc := range []struct {12+ text string // | marks the caret13+ word string14+ fn string15+ arg int16+ }{17+ {"=SU|", "SU", "", 0},18+ {"=@su|", "su", "", 0},19+ {"=SUM(|", "", "SUM", 0},20+ {"=SUM(A1, B|", "B", "SUM", 1},21+ {"=IF(A1>2, SUM(B1; B2), |", "", "IF", 2},22+ {"=IF(A1, (1+|", "", "IF", 1},23+ {`=IF(A1="a,b(", |`, "", "IF", 1},24+ {`=CONCAT("SU|`, "", "", 0},25+ {"=SUM (1, |", "", "SUM", 1},26+ {"=jev.te|", "jev.te", "", 0},27+ {"=A1+1|", "", "", 0},28+ {"=SU|M(1)", "", "", 0},29+ {"=Sales|", "Sales", "", 0},30+ } {31+ i := strings.Index(tc.text, "|")32+ buf := []rune(strings.Replace(tc.text, "|", "", 1))33+ c := scanCaret(buf, len([]rune(tc.text[:i])))34+ if c.word != tc.word || c.fn != tc.fn || c.arg != tc.arg {35+ t.Errorf("%s: word %q fn %q arg %d", tc.text, c.word, c.fn, c.arg)36+ }37+ }38+}39+40+func TestArgPart(t *testing.T) {41+ for _, tc := range []struct {42+ args string43+ arg int44+ variadic bool45+ want string46+ }{47+ {"value1, [value2, ...]", 0, true, "value1"},48+ {"value1, [value2, ...]", 4, true, "[value2, ...]"},49+ {"condition, value_if_true, [value_if_false]", 2, false, "[value_if_false]"},50+ {"condition, value_if_true, [value_if_false]", 3, false, ""},51+ {"sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", 5, true, "[criteria_range2, criterion2, ...]"},52+ } {53+ parts := splitArgs(tc.args)54+ got := ""55+ if i := argPart(parts, tc.arg, tc.variadic); i >= 0 {56+ got = parts[i]57+ }58+ if got != tc.want {59+ t.Errorf("%s arg %d: %q, want %q", tc.args, tc.arg, got, tc.want)60+ }61+ }62+}63+64+func TestAutocompleteFunctions(t *testing.T) {65+ m := newModel()66+ press(t, m, "=su")67+ list, _ := m.shownSuggestions()68+ if len(list) == 0 || list[0].name != "SUBSTITUTE" && list[0].name != "SUM" {69+ t.Fatalf("suggestions %v", list)70+ }71+ for _, s := range list {72+ if !strings.HasPrefix(s.name, "SU") && !strings.Contains(s.name, "SU") {73+ t.Errorf("unexpected %s", s.name)74+ }75+ }76+ scr := screen(m)77+ if !strings.Contains(scr, "│ SUM ") || !strings.Contains(scr, "(value1, [value2, ...])") {78+ t.Errorf("list not drawn:\n%s", scr)79+ }80+ if st := line(m, m.height-1); !strings.Contains(st, "Tab insert") {81+ t.Errorf("status %q", st)82+ }83+ // Down moves the highlight rather than committing; Tab inserts.84+ for list[m.assist.sel].name != "SUM" {85+ press(t, m, "<down>")86+ }87+ press(t, m, "<tab>")88+ if m.mode != modeEnter || string(m.buf) != "=SUM(" {89+ t.Fatalf("mode %v buf %q", m.mode, string(m.buf))90+ }91+ if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "SUM(value1, [value2, ...])") || !strings.Contains(ctx, "Sum of numbers") {92+ t.Errorf("signature %q", ctx)93+ }94+ press(t, m, "1,")95+ sig, _ := m.signature(m.buf, m.bufPos)96+ if !strings.Contains(sig, m.th.argument.Render("[value2, ...]")) {97+ t.Errorf("second argument not marked: %q", sig)98+ }99+ press(t, m, "2)", "<enter>")100+ if input(m, "A1") != "=SUM(1,2)" || m.sheet.Value(addr("A1")).Num != 3 {101+ t.Errorf("A1 %q", input(m, "A1"))102+ }103+}104+105+func TestAutocompleteKeys(t *testing.T) {106+ m := newModel()107+ // Enter inserts while the list shows, then commits when it doesn't.108+ press(t, m, "=ab", "<enter>")109+ if string(m.buf) != "=ABS(" {110+ t.Fatalf("enter: %q", string(m.buf))111+ }112+ press(t, m, "-4)", "<enter>")113+ if m.sheet.Value(addr("A1")).Num != 4 {114+ t.Fatalf("A1 = %v", m.sheet.Value(addr("A1")))115+ }116+ // Esc hides the list, a second Esc cancels the entry.117+ press(t, m, "=ro")118+ press(t, m, "<esc>")119+ if list, _ := m.shownSuggestions(); list != nil || m.mode != modeEnter {120+ t.Fatalf("esc: %v %v", list, m.mode)121+ }122+ press(t, m, "<esc>")123+ if m.mode != modeReady {124+ t.Fatalf("second esc: %v", m.mode)125+ }126+ // Arrows after an operator still point.127+ press(t, m, "=1+", "<up>")128+ if m.mode != modePoint {129+ t.Errorf("mode %v", m.mode)130+ }131+ press(t, m, "<esc>", "<esc>")132+ // Moving the caret hides the list; a cell reference gets no list.133+ press(t, m, "=co", "<left>")134+ if list, _ := m.shownSuggestions(); list != nil {135+ t.Error("list after moving the caret")136+ }137+ press(t, m, "<esc>", "=B2")138+ if list, _ := m.shownSuggestions(); list != nil {139+ t.Errorf("list for a reference: %v", list)140+ }141+ // Plain text gets no list.142+ press(t, m, "<esc>", "su")143+ if list, _ := m.shownSuggestions(); list != nil {144+ t.Error("list for text")145+ }146+}147+148+func TestAutocompleteNamesAndMouse(t *testing.T) {149+ m := tallModel()150+ m.sheet.DefineName("Sales", rectOf("B1:B3"))151+ press(t, m, "=SUM(sa")152+ list, start := m.shownSuggestions()153+ if list[0].name != "Sales" || list[0].fn {154+ t.Fatalf("names first: %v", list)155+ }156+ // Clicking a suggestion inserts it.157+ b, _ := m.assistBox()158+ send(m, tea.MouseClickMsg{X: b.x + 3, Y: b.y + 1, Button: tea.MouseLeft})159+ if string(m.buf) != "=SUM(Sales" || m.bufPos != start+5 {160+ t.Fatalf("click: %q", string(m.buf))161+ }162+ // Editing inside existing parentheses doesn't double them.163+ m = newModel()164+ press(t, m, "=AB(1)", "<f2>", "<left>", "<left>", "<left>", "<backspace>", "B")165+ press(t, m, "<tab>")166+ if string(m.buf) != "=ABS(1)" || m.bufPos != 5 {167+ t.Errorf("into parens: %q at %d", string(m.buf), m.bufPos)168+ }169+}170+171+func TestSignatureWhilePointing(t *testing.T) {172+ m := newModel()173+ press(t, m, "=JEV.TEST(", "<down>")174+ if m.mode != modePoint {175+ t.Fatalf("mode %v", m.mode)176+ }177+ if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "JEV.TEST(value, question") {178+ t.Errorf("context %q", ctx)179+ }180+}
M internal/ui/entry.go +8 -7
@@ -19,7 +19,7 @@ import (
1919 2020 func (m *Model) startEntry(md mode, text string) {2121 m.mode = md22- m.buf, m.bufPos, m.hint = nil, 0, ""22+ m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}2323 m.insert(text)2424 }2525
@@ -42,7 +42,7 @@ func (m *Model) isFormula() bool {
4242 // in a formula where a reference may follow the caret: then they point.4343 func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {4444 key := k.String()45- if m.commitKey(key) || m.cancelKey(key) {45+ if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) {4646 return nil4747 }4848 if key == "f2" {
@@ -57,7 +57,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
5757 return nil5858 }5959 if m.isFormula() && (key == "left" || key == "right") {60- m.lineKey(k) // move the caret within a formula60+ m.typeKey(k) // move the caret within a formula6161 return nil6262 }6363 if isMoveKey(key) {
@@ -66,14 +66,14 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
6666 }6767 return nil6868 }69- m.lineKey(k)69+ m.typeKey(k)7070 return nil7171 }7272 7373 // editKey handles EDIT mode, where left and right move the caret.7474 func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {7575 key := k.String()76- if m.commitKey(key) || m.cancelKey(key) {76+ if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) {7777 return nil7878 }7979 if m.isFormula() && m.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {
@@ -87,7 +87,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
8787 m.navigate(key, &m.cur)8888 }8989 default:90- m.lineKey(k)90+ m.typeKey(k)9191 }9292 return nil9393 }
@@ -217,6 +217,7 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
217217 if text := typed(k); text != "" {218218 m.resumeEntry(m.point.text())219219 m.insert(text)220+ m.assist = assist{active: true}220221 }221222 return nil222223 }
@@ -251,7 +252,7 @@ func (m *Model) set(a sheet.Addr, input string) error {
251252 252253 func (m *Model) cancelEntry() {253254 m.mode = modeReady254- m.buf, m.bufPos, m.hint = nil, 0, ""255+ m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}255256 }256257 257258 func (m *Model) insert(text string) {
M internal/ui/help_test.go +1 -1
M internal/ui/menu.go +12 -3
M internal/ui/menubar.go +3 -1
M internal/ui/model.go +9 -0
A internal/ui/names.go +176 -0
@@ -0,0 +1,176 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+8+ "012/internal/sheet"9+)10+11+// Named ranges, as Sheets' Data > Named ranges: a picker lists them with12+// their ranges, Enter goes to one, F2 renames it or changes its range,13+// Ctrl+D deletes it, and the first row adds one for the selection.14+// Naming and renaming are asked on the context line; changing the range15+// is pointed at with the arrows, like any range prompt.16+17+func init() {18+ register(19+ &command{id: "data.named_ranges", title: "Named ranges", desc: "List the named ranges: go to, add, edit or delete them", run: func(m *Model) tea.Cmd {20+ m.openNames(m.selection())21+ return nil22+ }},23+ &command{id: "data.define_name", title: "Define named range", desc: "Name the selected range, to use the name in formulas", run: func(m *Model) tea.Cmd {24+ m.defineName(m.selection(), false)25+ return nil26+ }},27+ )28+}29+30+// namesPicker is the picker of named ranges with its extra keys.31+type namesPicker struct {32+ *picker33+ sel sheet.Rect // the selection when the picker opened, for "Add a range"34+ msg string // feedback on the last action, e.g. a deletion35+}36+37+// openNames opens the picker; sel is what "Add a range" names.38+func (m *Model) openNames(sel sheet.Rect) {39+ p := newPicker(m, "Named ranges", "Type a name", 60, namesItems(m, sel))40+ p.action = "go to"41+ m.clearSelection()42+ m.openOverlay(&namesPicker{picker: p, sel: sel})43+}44+45+// namesItems lists "Add a range" and then every named range.46+func namesItems(m *Model, sel sheet.Rect) []pickItem {47+ add := "+ Add a range"48+ items := []pickItem{{49+ title: add, name: len(add), detail: sel.String(), desc: "Name " + sel.String() + ", to use the name in formulas",50+ pick: func(m *Model) tea.Cmd {51+ m.closeOverlay()52+ m.defineName(sel, true)53+ return nil54+ },55+ }}56+ for _, n := range m.sheet.Names() {57+ desc := "Go to " + n.Ref()58+ switch users := m.sheet.NameUsers(n.Name); {59+ case n.Lost:60+ desc = "Its cells were deleted; formulas using it show #REF!"61+ case users == 1:62+ desc += ", used in 1 formula"63+ case users > 1:64+ desc += ", used in " + strconv.Itoa(users) + " formulas"65+ }66+ items = append(items, pickItem{67+ title: n.Name, name: len(n.Name), detail: n.Ref(), desc: desc, off: n.Lost,68+ pick: func(m *Model) tea.Cmd {69+ m.closeOverlay()70+ m.selectRect(n.Range)71+ return nil72+ },73+ })74+ }75+ return items76+}77+78+// current is the named range highlighted in the picker, if any.79+func (p *namesPicker) current(m *Model) (sheet.Name, bool) {80+ if p.picker.sel >= len(p.shown) {81+ return sheet.Name{}, false82+ }83+ return m.sheet.LookupName(p.shown[p.picker.sel].item.title)84+}85+86+func (p *namesPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {87+ p.msg = ""88+ switch k.String() {89+ case "f2":90+ if n, ok := p.current(m); ok {91+ m.closeOverlay()92+ m.editName(n, p.sel)93+ }94+ return nil95+ case "ctrl+d":96+ if n, ok := p.current(m); ok {97+ m.sheet.DeleteName(n.Name)98+ m.changed = true99+ p.msg = "Deleted " + n.Name + "; Ctrl+Z brings it back"100+ p.items = namesItems(m, p.sel)101+ sel := p.picker.sel102+ p.changed(m)103+ p.picker.sel = max(min(sel, len(p.shown)-1), 0)104+ }105+ return nil106+ }107+ return p.picker.key(m, k)108+}109+110+func (p *namesPicker) status(m *Model) (string, string) {111+ keys := m.keyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")112+ if _, ok := p.current(m); !ok {113+ keys = m.keyHints("Enter", "add", "Esc", "close")114+ }115+ if p.msg != "" {116+ return p.msg, keys117+ }118+ desc, _ := p.picker.status(m)119+ return desc, keys120+}121+122+// defineName asks for a name for r on the context line. From the picker123+// (back), the picker opens again afterwards.124+func (m *Model) defineName(r sheet.Rect, back bool) {125+ m.openText("Name for "+r.String()+":", "", func(m *Model, text string) tea.Cmd {126+ if err := m.sheet.DefineName(text, r); err != nil {127+ m.fail(err.Error())128+ return nil129+ }130+ m.changed = true131+ if back {132+ m.openNames(r)133+ return nil134+ }135+ m.selectRect(r)136+ m.note = text + " names " + r.String() + ": use it in formulas, e.g. =SUM(" + text + ")"137+ return nil138+ })139+ m.prompt.indicator = "NAME"140+}141+142+// editName asks for a new name, then for the range, pointed at from the143+// current one; Enter keeps either as it is. The picker opens again after.144+func (m *Model) editName(n sheet.Name, sel sheet.Rect) {145+ m.openText("Rename "+n.Name+":", n.Name, func(m *Model, text string) tea.Cmd {146+ if err := sheet.ValidName(text); err != nil {147+ m.fail(err.Error())148+ return nil149+ }150+ r := n.Range151+ m.openRange("Range for "+text+":", func(m *Model, r sheet.Rect) tea.Cmd {152+ if err := m.sheet.EditName(n.Name, text, r); err != nil {153+ m.fail(err.Error())154+ return nil155+ }156+ m.changed = true157+ m.openNames(sel)158+ return nil159+ })160+ m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}161+ return nil162+ })163+ m.prompt.indicator = "NAME"164+}165+166+// namedSelection is the name of the selected range, if it has one, for167+// the name box, as in Sheets.168+func (m *Model) namedSelection() (string, bool) {169+ r := m.selection()170+ for _, n := range m.sheet.Names() {171+ if !n.Lost && n.Range == r {172+ return n.Name, true173+ }174+ }175+ return "", false176+}
A internal/ui/names_test.go +132 -0
@@ -0,0 +1,132 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "012/internal/sheet"8+)9+10+func rectOf(s string) sheet.Rect {11+ r, _ := sheet.ParseRange(s)12+ return r13+}14+15+func TestDefineNameFromSelection(t *testing.T) {16+ m := newModel()17+ press(t, m, "<right>", "10", "<enter>", "20", "<enter>", "<up>", "<shift+up>")18+ m.runCommand("data.define_name")19+ if l := line(m, contextLine); !strings.HasPrefix(l, "Name for B1:B2:") {20+ t.Fatalf("prompt %q", l)21+ }22+ press(t, m, "Sales", "<enter>")23+ if n, ok := m.sheet.LookupName("sales"); !ok || n.Range != rectOf("B1:B2") {24+ t.Fatalf("name %v %v", n, ok)25+ }26+ if l := line(m, contextLine); !strings.Contains(l, "=SUM(Sales)") {27+ t.Errorf("note %q", l)28+ }29+ // The name box shows the name of the selected range.30+ if l := line(m, formulaLine); !strings.HasPrefix(l, " Sales") {31+ t.Errorf("name box %q", l)32+ }33+ press(t, m, "<esc>", "<down>", "<down>", "=SUM(Sales)", "<enter>")34+ if m.sheet.Value(addr("B3")).Num != 30 {35+ t.Errorf("B3 = %v", m.sheet.Value(addr("B3")))36+ }37+ press(t, m, "<ctrl+z>", "<ctrl+z>")38+ if _, ok := m.sheet.LookupName("Sales"); ok {39+ t.Error("undo kept the name")40+ }41+42+ // A bad name says why.43+ m.runCommand("data.define_name")44+ press(t, m, "A1", "<enter>")45+ if m.mode != modeError || !strings.Contains(m.errMsg, "cell reference") {46+ t.Errorf("mode %v %q", m.mode, m.errMsg)47+ }48+}49+50+func TestNamedRangesPicker(t *testing.T) {51+ m := tallModel()52+ m.Update(teaSize(120, 40))53+ m.sheet.Set(addr("A1"), "=SUM(Costs)+Rate")54+ m.sheet.DefineName("Costs", rectOf("C2:C9"))55+ m.sheet.DefineName("Rate", rectOf("E1"))56+ press(t, m, "<alt+d>")57+ if !strings.Contains(screen(m), "Named ranges") || !strings.Contains(screen(m), "Trace precedents") {58+ t.Fatalf("Data menu:\n%s", screen(m))59+ }60+ press(t, m, "<esc>")61+ m.runCommand("data.named_ranges")62+ scr := screen(m)63+ for _, want := range []string{"+ Add a range", "Costs", "C2:C9", "Rate", "E1"} {64+ if !strings.Contains(scr, want) {65+ t.Errorf("missing %q in\n%s", want, scr)66+ }67+ }68+ press(t, m, "cos")69+ if st := line(m, m.height-1); !strings.Contains(st, "used in 1 formula") || !strings.Contains(st, "F2 edit") {70+ t.Errorf("status %q", st)71+ }72+ press(t, m, "<enter>")73+ if m.mode != modeReady || m.selection() != rectOf("C2:C9") {74+ t.Fatalf("go to: %v %v", m.mode, m.selection())75+ }76+77+ // F2 renames and repoints; formulas follow the new name.78+ m.runCommand("data.named_ranges")79+ press(t, m, "rate", "<f2>")80+ if l := line(m, contextLine); !strings.HasPrefix(l, "Rename Rate: Rate") {81+ t.Fatalf("rename prompt %q", l)82+ }83+ press(t, m, "Tax", "<enter>")84+ if l := line(m, contextLine); !strings.HasPrefix(l, "Range for Tax: E1") {85+ t.Fatalf("range prompt %q", l)86+ }87+ press(t, m, "<shift+down>", "<enter>")88+ if n, _ := m.sheet.LookupName("tax"); n.Range != rectOf("E1:E2") || input(m, "A1") != "=SUM(Costs)+Tax" {89+ t.Fatalf("edited: %v %q", n, input(m, "A1"))90+ }91+ if _, ok := m.overlay.(*namesPicker); !ok {92+ t.Fatal("picker didn't come back")93+ }94+95+ // Ctrl+D deletes; the picker stays open and says how to undo.96+ press(t, m, "tax", "<ctrl+d>")97+ if _, ok := m.sheet.LookupName("Tax"); ok {98+ t.Fatal("not deleted")99+ }100+ if st := line(m, m.height-1); !strings.Contains(st, "Deleted Tax") {101+ t.Errorf("status %q", st)102+ }103+ press(t, m, "<esc>", "<ctrl+z>")104+ if _, ok := m.sheet.LookupName("Tax"); !ok {105+ t.Error("undo didn't restore")106+ }107+108+ // Add a range names the selection the picker opened with.109+ press(t, m, "<ctrl+home>", "<shift+right>")110+ m.runCommand("data.named_ranges")111+ press(t, m, "<enter>", "Top", "<enter>")112+ if n, ok := m.sheet.LookupName("Top"); !ok || n.Range != rectOf("A1:B1") {113+ t.Errorf("added %v %v", n, ok)114+ }115+}116+117+func TestGotoAcceptsNamesAndRanges(t *testing.T) {118+ m := newModel()119+ m.sheet.DefineName("Totals", rectOf("D4:E6"))120+ press(t, m, "<f5>", "totals", "<enter>")121+ if m.selection() != rectOf("D4:E6") {122+ t.Errorf("name: %v", m.selection())123+ }124+ press(t, m, "<f5>", "B2:C3", "<enter>")125+ if m.selection() != rectOf("B2:C3") {126+ t.Errorf("range: %v", m.selection())127+ }128+ press(t, m, "<f5>", "nope", "<enter>")129+ if m.mode != modeError || m.errMsg != "Not a cell, range or named range: nope" {130+ t.Errorf("%v %q", m.mode, m.errMsg)131+ }132+}
M internal/ui/overlay.go +18 -3
M internal/ui/panel.go +20 -4
@@ -29,8 +29,8 @@ func (m *Model) View() tea.View {
2929 }3030 3131 content := strings.Join(lines, "\n")32- if m.overlay != nil {33- content = m.compose(content)32+ if boxes := m.floating(); len(boxes) > 0 {33+ content = m.compose(content, boxes)3434 }3535 v := tea.NewView(content)3636 v.AltScreen = true
@@ -88,6 +88,9 @@ func (m *Model) formulaBar() string {
8888 if m.hasRange() && (m.mode == modeReady || m.mode == modeMenu) {8989 name = m.selection().String()9090 }91+ if n, ok := m.namedSelection(); ok && (m.mode == modeReady || m.mode == modeMenu) {92+ name = ansi.Truncate(n, nameBoxW-1, "…")93+ }9194 box := m.th.header.Render(padRight(" "+name, nameBoxW)) + " "9295 switch m.mode {9396 case modeEnter, modeEdit:
@@ -111,6 +114,8 @@ func (m *Model) contextLineText() string {
111114 strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.muted.Render(" double-click the border to fit")112115 case m.hint != "":113116 left = m.th.warning.Render(m.hint)117+ case m.mode == modeReady && m.trace != nil:118+ left, right = m.traceLine()114119 case m.mode == modeReady:115120 if left = m.readyLine(); left == "" {116121 left = m.jevLine()
@@ -125,7 +130,13 @@ func (m *Model) contextLineText() string {
125130 case m.mode == modePrompt:126131 left, right = m.promptLine()127132 case m.mode == modePoint:128- left = m.keyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")133+ prefix := []rune(m.pointPrefix)134+ var ok bool135+ if left, right, ok = m.signatureLine(prefix, len(prefix), m.keyHints("Shift+arrows", "range", "Esc", "back")); !ok {136+ left = m.keyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")137+ }138+ case (m.mode == modeEnter || m.mode == modeEdit) && m.isFormula() && m.inFunction():139+ left, right, _ = m.signatureLine(m.buf, m.bufPos, m.keyHints("Enter", "accept", "Esc", "cancel"))129140 case m.mode == modeEnter && m.isFormula():130141 left = m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")131142 case m.mode == modeEnter:
@@ -201,9 +212,14 @@ func (m *Model) statusLine() string {
201212 if m.mode == modeError {202213 return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key")203214 }215+ desc, keys, floating := m.assistStatus()204216 if m.overlay != nil {217+ desc, keys, floating = "", "", true218+ desc, keys = m.overlay.status(m)219+ }220+ if floating {205221 // What the highlighted item does and the keys that apply.206- if desc, keys := m.overlay.status(m); desc != "" || keys != "" {222+ if desc != "" || keys != "" {207223 if room := m.width - ansi.StringWidth(keys) - 3; room >= 12 {208224 desc = ansi.Truncate(desc, room, "…")209225 }
M internal/ui/theme.go +4 -0
A internal/ui/trace.go +149 -0
@@ -0,0 +1,149 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "012/internal/sheet"11+)12+13+// Tracing precedents and dependents, after Excel's Ctrl+[ and Ctrl+]: the14+// cells a formula reads, or the formulas that read a cell, light up and15+// the active cell jumps to the first; pressing the key again steps16+// through the rest. Ctrl+[ is Esc to most terminals, so the keys are17+// Alt+, and Alt+. (the < and > keys: back to the inputs, on to the18+// results). The highlight is transient, as a search's is: any other key19+// or click ends it, and Esc returns to the traced cell.20+21+type trace struct {22+ dependents bool23+ origin sheet.Addr24+ targets []sheet.Rect25+ at int // the target the active cell is on26+}27+28+func init() {29+ register(30+ &command{id: "data.precedents", title: "Trace precedents", desc: "Highlight the cells the formula reads; press again to jump through them", run: func(m *Model) tea.Cmd {31+ m.stepTrace(false)32+ return nil33+ }},34+ &command{id: "data.dependents", title: "Trace dependents", desc: "Highlight the formulas that read the cell; press again to jump through them", run: func(m *Model) tea.Cmd {35+ m.stepTrace(true)36+ return nil37+ }},38+ )39+ keymap["alt+,"] = "data.precedents"40+ keymap["alt+."] = "data.dependents"41+}42+43+// stepTrace starts a trace from the active cell, or moves to the next44+// target of the one showing.45+func (m *Model) stepTrace(dependents bool) {46+ t := m.trace47+ if t == nil || t.dependents != dependents {48+ t = &trace{dependents: dependents, origin: m.cur, at: -1}49+ if dependents {50+ for _, a := range m.sheet.Dependents(m.cur) {51+ t.targets = append(t.targets, sheet.Rect{From: a, To: a})52+ }53+ } else {54+ t.targets = m.sheet.Precedents(m.cur)55+ }56+ if len(t.targets) == 0 {57+ m.trace = nil58+ m.note = "No formulas read " + m.cur.String()59+ if !dependents {60+ m.note = m.cur.String() + " has no formula reading other cells"61+ }62+ return63+ }64+ m.trace = t65+ }66+ t.at = (t.at + 1) % len(t.targets)67+ m.clearSelection()68+ m.cur = t.targets[t.at].From69+}70+71+// traceKey ends the trace on any key but the trace commands, and returns72+// to the traced cell on Esc. It reports whether it used the key.73+func (m *Model) traceKey(k tea.KeyPressMsg) bool {74+ if m.trace == nil {75+ return false76+ }77+ id := keymap[canonicalKey(k.String())]78+ if m.mode == modeReady && (id == "data.precedents" || id == "data.dependents") {79+ return false80+ }81+ origin := m.trace.origin82+ m.trace = nil83+ if m.mode == modeReady && k.String() == "esc" {84+ m.cur = origin85+ return true86+ }87+ return false88+}89+90+// traced reports whether a is in a range being traced.91+func (m *Model) traced(a sheet.Addr) bool {92+ if m.trace == nil {93+ return false94+ }95+ for _, r := range m.trace.targets {96+ if r.Contains(a) {97+ return true98+ }99+ }100+ return false101+}102+103+// traceLine is the context line during a trace, e.g. "2 precedents of104+// C7: B3, Sales", the current one emphasized, and the keys.105+func (m *Model) traceLine() (left, right string) {106+ t := m.trace107+ noun, id := "precedent", "data.precedents"108+ if t.dependents {109+ noun, id = "dependent", "data.dependents"110+ }111+ if len(t.targets) != 1 {112+ noun += "s"113+ }114+ right = m.keyHints(shortcut(id), "next", "Esc", "back")115+ left = strconv.Itoa(len(t.targets)) + " " + noun + " of " + t.origin.String() + ": "116+ room := m.width - ansi.StringWidth(right) - 3117+ if room < ansi.StringWidth(left)+12 {118+ right, room = "", m.width119+ }120+ var b strings.Builder121+ b.WriteString(left)122+ for i, r := range t.targets {123+ part := m.rangeLabel(r)124+ if i == t.at {125+ part = m.th.key.Render(part)126+ }127+ if i > 0 {128+ part = ", " + part129+ }130+ more := " +" + strconv.Itoa(len(t.targets)-i) + " more"131+ if ansi.StringWidth(b.String()+part)+len(more) > room && i < len(t.targets)-1 {132+ b.WriteString(m.th.muted.Render(more))133+ break134+ }135+ b.WriteString(part)136+ }137+ return b.String(), right138+}139+140+// rangeLabel names a range the way formulas refer to it: by its name if141+// it has one.142+func (m *Model) rangeLabel(r sheet.Rect) string {143+ for _, n := range m.sheet.Names() {144+ if !n.Lost && n.Range == r && r.From != r.To {145+ return n.Name146+ }147+ }148+ return r.String()149+}
A internal/ui/trace_test.go +71 -0
@@ -0,0 +1,71 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+)9+10+func traceModel(t *testing.T) *Model {11+ m := newModel()12+ for a, in := range map[string]string{"A1": "1", "A2": "2", "A3": "3", "B1": "=A1+SUM(A2:A3)", "C1": "=B1*2", "C2": "=B1"} {13+ m.sheet.Set(addr(a), in)14+ }15+ return m16+}17+18+func TestTracePrecedents(t *testing.T) {19+ m := traceModel(t)20+ m.cur = addr("B1")21+ press(t, m, "<alt+,>")22+ if m.cur != addr("A1") || !m.traced(addr("A3")) || m.traced(addr("B1")) {23+ t.Fatalf("cur %v", m.cur)24+ }25+ if l := line(m, contextLine); !strings.HasPrefix(l, "2 precedents of B1: A1, A2:A3") || !strings.Contains(l, "Alt+, next") {26+ t.Errorf("context %q", l)27+ }28+ press(t, m, "<alt+,>")29+ if m.cur != addr("A2") {30+ t.Errorf("next: %v", m.cur)31+ }32+ press(t, m, "<alt+,>")33+ if m.cur != addr("A1") {34+ t.Errorf("wrap: %v", m.cur)35+ }36+ // Esc returns to the traced cell and ends the trace.37+ press(t, m, "<esc>")38+ if m.cur != addr("B1") || m.trace != nil {39+ t.Errorf("esc: %v %v", m.cur, m.trace)40+ }41+}42+43+func TestTraceDependents(t *testing.T) {44+ m := traceModel(t)45+ m.cur = addr("B1")46+ press(t, m, "<alt+.>")47+ if m.cur != addr("C1") || !m.traced(addr("C2")) {48+ t.Fatalf("cur %v", m.cur)49+ }50+ // Any other key ends the trace and does its own thing.51+ press(t, m, "<down>")52+ if m.trace != nil || m.cur != addr("C2") {53+ t.Errorf("down: %v %v", m.trace, m.cur)54+ }55+ // A click ends it too.56+ m.cur = addr("B1")57+ press(t, m, "<alt+.>")58+ send(m, tea.MouseClickMsg{X: rowHdrW + 1, Y: gridTop + 4, Button: tea.MouseLeft})59+ if m.trace != nil {60+ t.Error("click kept the trace")61+ }62+ m.cur = addr("C1")63+ press(t, m, "<alt+.>")64+ if m.trace != nil || !strings.Contains(line(m, contextLine), "No formulas read C1") {65+ t.Errorf("no dependents: %q", line(m, contextLine))66+ }67+ press(t, m, "<alt+,>", "<alt+,>")68+ if m.cur != addr("B1") || m.trace == nil {69+ t.Errorf("from C1 back: %v", m.cur)70+ }71+}
M internal/ui/view.go +2 -0