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.9package suggest1011import (12 "slices"13 "strconv"14 "strings"1516 tea "charm.land/bubbletea/v2"17 "github.com/charmbracelet/x/ansi"1819 "src.rest/cat/012/internal/sheet"20 "src.rest/cat/012/internal/ui/formula"21 "src.rest/cat/012/internal/ui/lineedit"22 "src.rest/cat/012/internal/ui/overlay"23 "src.rest/cat/012/internal/ui/theme"24)2526// Host is what the suggestions need of the UI.27type 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}4445// List is the suggestion list's state; the zero List is hidden.46type 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}5253// Sel is the highlighted suggestion.54func (l *List) Sel() int { return l.sel }5556// Rows is the most suggestions shown at once.57const Rows = 85859// ID identifies the list's box.60const ID = "assist"6162// Suggestion is a function, named range or sheet offered for the word63// at the caret.64type 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 Column bool // a table's column or item: accepting it adds "]"70}7172// For lists the functions, named ranges, tables and sheets for word,73// typed on sh: those starting with it first (names, then tables, then74// sheets, then functions), then, from two letters on, those containing it, such as75// COUNTIFS for "ifs". A word that is already a cell reference only gets76// those that start with it, and a word in quotes ('Q3) only sheets.77func For(sh *sheet.Sheet, word string) []Suggestion {78 if rest, quoted := strings.CutPrefix(word, "'"); quoted {79 return sheetSuggestions(sh, strings.ToUpper(rest))80 }81 w := strings.ToUpper(word)82 var prefix, inner []Suggestion83 add := func(s Suggestion, name string) {84 switch {85 case strings.HasPrefix(name, w):86 prefix = append(prefix, s)87 case len(w) >= 2 && strings.Contains(name, w):88 inner = append(inner, s)89 }90 }91 for _, n := range sh.Names() {92 add(Suggestion{Name: n.Name, Detail: n.Ref(), Desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))93 }94 for _, t := range sh.Book().TableInfos() {95 add(tableSuggestion(sh, t), strings.ToUpper(t.Name))96 }97 for _, s := range otherSheets(sh) {98 add(sheetSuggestion(s), strings.ToUpper(s.Name()))99 }100 for _, f := range sheet.Funcs() {101 add(Suggestion{Name: f.Name, Detail: f.Name + "(" + f.Args + ")", Desc: f.Desc, Fn: true}, f.Name)102 }103 if _, isRef := sheet.ParseAddr(w); isRef {104 return prefix105 }106 return append(prefix, inner...)107}108109// Shown returns the list for the word at the caret, and where the word110// starts, or nothing when the list is hidden: outside an entry, in111// plain text, after the caret moved, or when the word already names a112// range exactly and nothing else matches.113func (l *List) Shown(h Host) ([]Suggestion, int) {114 line := h.Line()115 if !h.Typing() || !line.IsFormula() || !l.Active {116 return nil, 0117 }118 c := formula.ScanCaret(h.Stored(line.Buf), line.Pos)119 var list []Suggestion120 switch {121 case c.Table != "":122 if word, at := strings.CutPrefix(c.Word, "@"); at {123 c.Word, c.WordStart = word, c.WordStart+1124 }125 list = columnSuggestions(h.EntrySheet(), c.Table, c.Word)126 case c.Word == "":127 return nil, 0128 default:129 list = For(h.EntrySheet(), c.Word)130 }131 if len(list) == 0 || len(list) == 1 && !list[0].Fn && strings.EqualFold(list[0].Name, c.Word) {132 return nil, 0133 }134 l.sel = min(l.sel, len(list)-1)135 return list, c.WordStart136}137138// Key handles the keys the list takes while it shows, and reports139// whether it took key.140func (l *List) Key(h Host, key string) bool {141 list, start := l.Shown(h)142 if list == nil {143 return false144 }145 switch key {146 case "up", "ctrl+p":147 l.sel = (l.sel - 1 + len(list)) % len(list)148 case "down", "ctrl+n":149 l.sel = (l.sel + 1) % len(list)150 case "tab", "enter":151 l.accept(h, list[l.sel], start)152 case "esc":153 l.Active = false154 default:155 return false156 }157 return true158}159160// accept replaces the word at the caret with s, adding "(" after a161// function unless one is already there.162func (l *List) accept(h Host, s Suggestion, start int) {163 line := h.Line()164 text := []rune(s.Name)165 rest := line.Buf[line.Pos:]166 if s.Fn && (len(rest) == 0 || rest[0] != '(') {167 text = append(text, '(')168 }169 if s.Column && (len(rest) == 0 || rest[0] != ']') {170 text = append(text, ']')171 }172 if strings.HasSuffix(s.Name, "!") {173 rest = trimSheetEnd(rest) // the rest of a sheet name typed before174 }175 pos := start + len(text)176 if s.Fn && len(rest) > 0 && rest[0] == '(' {177 pos++ // into the existing parentheses178 }179 line.Buf = slices.Concat(line.Buf[:start], text, rest)180 line.Pos = pos181 l.Active = false182}183184// Box draws the list under the formula bar, its text aligned with the185// word being typed: the name, then the signature or range dimmed.186func (l *List) Box(h Host) (overlay.Box, bool) {187 list, start := l.Shown(h)188 if list == nil {189 return overlay.Box{}, false190 }191 th := h.Theme()192 width, height := h.Size()193 rows := min(len(list), Rows, height-overlay.GridTop-2)194 if rows < 1 {195 return overlay.Box{}, false196 }197 l.top = min(max(l.top, l.sel-rows+1), l.sel)198 l.top = min(max(l.top, 0), len(list)-rows)199 nw, dw := 0, 0200 for _, s := range list {201 nw = max(nw, ansi.StringWidth(s.Name))202 dw = max(dw, ansi.StringWidth(s.Detail))203 }204 inner := min(1+nw+3+dw+1, 64, width-2)205 lines := make([]string, 0, rows)206 for i := l.top; i < l.top+rows; i++ {207 s := list[i]208 base, dim := th.MenuBar, th.Muted209 if i == l.sel {210 base, dim = th.MenuSelected, th.MenuSelected211 }212 detail := s.Detail213 if s.Fn {214 detail = s.Detail[len(s.Name):] // just the arguments: (value1, ...)215 }216 row := base.Render(" "+theme.PadRight(s.Name, nw)+" ") + dim.Render(detail)217 lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))218 }219 footer := ""220 if len(list) > rows {221 footer = strconv.Itoa(l.sel+1) + " of " + strconv.Itoa(len(list))222 }223 // The box's text starts one column in, under the word's first letter.224 x := h.TextX() + ansi.StringWidth(string(h.Line().Buf[:start])) - 2225 x = min(max(x, 0), max(width-inner-2, 0))226 return overlay.Box{ID: ID, X: x, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "", footer, lines)}, true227}228229// Mouse lets the mouse hover and click suggestions and scroll the list.230// It reports false for events outside the list.231func (l *List) Mouse(h Host, msg tea.MouseMsg) bool {232 b, ok := l.Box(h)233 if !ok {234 return false235 }236 mouse := msg.Mouse()237 if mouse.X < b.X || mouse.X >= b.X+b.Width() || mouse.Y < b.Y || mouse.Y >= b.Y+b.Height() {238 return false239 }240 list, start := l.Shown(h)241 i := l.top + mouse.Y - b.Y - 1242 onRow := i >= l.top && i < min(len(list), l.top+b.Height()-2)243 switch msg.(type) {244 case tea.MouseWheelMsg:245 d := 1246 if mouse.Button == tea.MouseWheelUp {247 d = -1248 }249 l.sel = min(max(l.sel+d, 0), len(list)-1)250 case tea.MouseMotionMsg:251 if onRow {252 l.sel = i253 }254 case tea.MouseClickMsg:255 if mouse.Button == tea.MouseLeft && onRow {256 l.accept(h, list[i], start)257 }258 }259 return true260}261262// Status is the status line while the list shows: what the highlighted263// suggestion is, and the keys that apply.264func (l *List) Status(h Host) (desc, keys string, ok bool) {265 list, _ := l.Shown(h)266 if list == nil {267 return "", "", false268 }269 return list[l.sel].Desc, h.Theme().KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true270}271272// SignatureLine puts the signature of the function around the caret on273// the context line, with what the function does and the keys that apply274// (hints) as room allows. It reports false outside a function.275func SignatureLine(th *theme.Theme, width int, buf []rune, pos int, sep byte, hints string) (left, right string, ok bool) {276 sig, desc := Signature(th, buf, pos, sep)277 if sig == "" {278 return "", "", false279 }280 full := sig + " " + th.Muted.Render(desc)281 for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {282 if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {283 return try[0], try[1], true284 }285 }286 return sig, "", true287}288289// Signature renders the signature of the function around the caret, e.g.290// SUM(value1, [value2, ...]), with the current argument marked, and what291// the function does. It is "" outside a function. buf is the formula in292// the syntax it's parsed in (see Host.Stored); sep separates the293// arguments shown, ; in a locale with a decimal comma.294func Signature(th *theme.Theme, buf []rune, pos int, sep byte) (sig, desc string) {295 c := formula.ScanCaret(buf, pos)296 f, ok := sheet.LookupFunc(c.Fn)297 if c.Fn == "" || !ok {298 return "", ""299 }300 parts := formula.SplitArgs(f.Args)301 cur := formula.ArgPart(parts, c.Arg, f.Max < 0)302 var b strings.Builder303 b.WriteString(th.Key.Render(f.Name) + "(")304 for i, p := range parts {305 if i > 0 {306 b.WriteString(string(sep) + " ")307 }308 if sep != ',' {309 p = strings.ReplaceAll(p, ",", string(sep)) // [value2; ...]310 }311 if i == cur {312 b.WriteString(th.Argument.Render(p))313 } else {314 b.WriteString(p)315 }316 }317 b.WriteString(")")318 return b.String(), f.Desc319}