cat / 012
clone

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

cat/module-path
012 / internal / ui / picker / picker.go
picker.go 12.4K
1// Package picker is the searchable list behind the command palette and2// every other picker of the UI (functions, sheets, named ranges, macros,3// themes, files to import): a search field on top, results below with4// matched characters highlighted, in the manner of fzf. It knows the UI5// only through Host.6package picker78import (9 "cmp"10 "maps"11 "slices"12 "strconv"13 "strings"1415 tea "charm.land/bubbletea/v2"16 "github.com/charmbracelet/x/ansi"17 "github.com/sahilm/fuzzy"1819 "src.rest/cat/012/internal/ui/lineedit"20 "src.rest/cat/012/internal/ui/overlay"21 "src.rest/cat/012/internal/ui/theme"22)2324// Host is what a picker needs of the UI.25type Host interface {26 Theme() *theme.Theme27 Size() (width, height int)28 // Line is the shared edit line the search is typed in.29 Line() *lineedit.Line30 // Close closes the open overlay.31 Close()32 // RecordAnswer tells a macro recording the answer to the question of33 // the command that opened the picker, or that it was cancelled.34 RecordAnswer(answer string, cancelled bool)35}3637// Item is one row of a picker.38type Item struct {39 Title string // e.g. "Save as"40 Name int // bytes of Title that are searched, e.g. just "SUM" of "SUM(value, ...)"41 Detail string // a dimmed second column, also searched, e.g. the menu path42 Key string // shortcut, shown as a key chip43 Desc string // what it does, for the status line44 Off bool // unavailable right now45 Pick func() tea.Cmd46 // Swatch, when set, is drawn before the title as it is, styled: a47 // sample of a color or a line.48 Swatch string49}5051// haystack is what a search matches against.52func (it Item) haystack() string { return it.Title[:it.Name] + " " + it.Detail }5354// Match is an item that matches the search, with the matched byte55// offsets in its title and detail.56type Match struct {57 Item *Item58 InTitle, InDetail []int59}6061// Picker is a searchable list in a box: a search field on top, results62// below with matched characters highlighted, and the highlighted result's63// description on the status line. The search is edited in the host's64// edit line with the usual line-editing keys.65type Picker struct {66 h Host67 title string68 placeholder string69 maxW int70 shown []Match71 overlay.List7273 // Action is what Enter does, for the key hints ("run" by default).74 Action string75 Items []Item7677 // Narrow, when set, sees the search first and can narrow the items by78 // it, as "dark" does the themes: it returns the rest of the search,79 // matched against the kept items' titles only, and which items to80 // keep, or a nil keep to search every item as usual.81 Narrow func(query string) (rest string, keep func(*Item) bool)8283 // Enter, when set, gets the first look at Enter with the search text,84 // e.g. to take a typed path; it reports whether it handled it.85 Enter func(query string) (tea.Cmd, bool)8687 // Answers marks a picker that is the question of a command that88 // changes the workbook: a recording keeps the title picked as the89 // command's answer, and scripts answer it with a title.90 Answers bool9192 // At, when set, is where the box's top-left corner goes, as a93 // dropdown opens under its cell; the box stays on screen. Pickers94 // open centered under the menu bar otherwise.95 At *[2]int96}9798// ID identifies the picker's box in mouse events.99const ID = "picker"100101// New returns a picker of items titled title, at most maxW columns wide102// inside its frame, with the edit line cleared for the search.103func New(h Host, title, placeholder string, maxW int, items []Item) *Picker {104 p := &Picker{h: h, title: title, placeholder: placeholder, maxW: maxW, Items: items}105 h.Line().Clear()106 p.Changed()107 return p108}109110// Title is the picker's title, on its frame.111func (p *Picker) Title() string { return p.title }112113// Shown are the items that match the search, best first.114func (p *Picker) Shown() []Match { return p.shown }115116// Selected is the highlighted item, or nil when nothing matches.117func (p *Picker) Selected() *Item {118 if p.Sel >= len(p.shown) {119 return nil120 }121 return p.shown[p.Sel].Item122}123124func (p *Picker) Indicator() string { return "MENU" }125126// Changed filters the items by the search. Best matches come first; ties127// keep menu order.128func (p *Picker) Changed() {129 p.Sel, p.Top = 0, 0130 p.shown = p.shown[:0]131 q := strings.TrimSpace(p.h.Line().Text())132 var keep func(*Item) bool133 if p.Narrow != nil {134 if rest, k := p.Narrow(q); k != nil {135 q, keep = strings.TrimSpace(rest), k136 }137 }138 if q == "" {139 for i := range p.Items {140 if keep == nil || keep(&p.Items[i]) {141 p.shown = append(p.shown, Match{Item: &p.Items[i]})142 }143 }144 return145 }146 p.rank(q, keep)147}148149// rank shows the items matching q, best first: of those keep keeps,150// matching only their titles, when it is set.151func (p *Picker) rank(q string, keep func(*Item) bool) {152 // Rank title and path matches together by fuzzy score, so a tight153 // match like "file" on the File menu beats letters scattered across a154 // title. Title matches get a small bonus and win ties. Titles with a155 // word starting with the query ("col" in Column width) come before156 // the rest, such as "Command line", which only spells it across words.157 names, hay := make([]string, len(p.Items)), make([]string, len(p.Items))158 for i, it := range p.Items {159 names[i], hay[i] = it.Title[:it.Name], it.haystack()160 }161 const titleBonus = 10162 type ranked struct {163 pm Match164 score int165 order int166 prefix bool // a word of the title starts with the query167 }168 best := map[int]*ranked{}169 for _, mt := range fuzzy.FindNoSort(q, names) {170 if keep != nil && !keep(&p.Items[mt.Index]) {171 continue172 }173 best[mt.Index] = &ranked{Match{Item: &p.Items[mt.Index], InTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}174 }175 if keep != nil {176 hay = nil // titles only177 }178 for _, mt := range fuzzy.FindNoSort(q, hay) {179 if r, ok := best[mt.Index]; ok && r.score >= mt.Score {180 continue181 }182 it := &p.Items[mt.Index]183 pm := Match{Item: it}184 for _, i := range mt.MatchedIndexes {185 switch {186 case i < it.Name:187 pm.InTitle = append(pm.InTitle, i)188 case i >= it.Name+2:189 pm.InDetail = append(pm.InDetail, i-it.Name-2)190 }191 }192 best[mt.Index] = &ranked{pm, mt.Score, mt.Index, false}193 }194 for i, r := range best {195 r.prefix = wordPrefix(names[i], q)196 }197 all := slices.Collect(maps.Values(best))198 slices.SortFunc(all, func(a, b *ranked) int {199 if a.prefix != b.prefix {200 if a.prefix {201 return -1202 }203 return 1204 }205 if c := cmp.Compare(b.score, a.score); c != 0 {206 return c207 }208 return cmp.Compare(a.order, b.order)209 })210 for _, r := range all {211 p.shown = append(p.shown, r.pm)212 }213}214215// wordPrefix reports whether a word of title starts with q, ignoring case.216func wordPrefix(title, q string) bool {217 q = strings.ToLower(q)218 for w := range strings.FieldsSeq(strings.ToLower(title)) {219 if strings.HasPrefix(w, q) {220 return true221 }222 }223 return false224}225226func (p *Picker) Key(k tea.KeyPressMsg) tea.Cmd {227 rows := p.rows()228 switch k.String() {229 case "up", "ctrl+p", "shift+tab":230 p.Move(-1, len(p.shown))231 case "down", "ctrl+n", "tab":232 p.Move(1, len(p.shown))233 case "pgup":234 p.Sel = max(p.Sel-rows, 0)235 case "pgdown":236 p.Sel = max(min(p.Sel+rows, len(p.shown)-1), 0)237 case "enter":238 return p.pick()239 case "esc":240 p.Close()241 default:242 line := p.h.Line()243 before := line.Text()244 line.Key(k)245 if line.Text() != before {246 p.Changed()247 }248 }249 return nil250}251252func (p *Picker) pick() tea.Cmd {253 if p.Enter != nil {254 if cmd, ok := p.Enter(strings.TrimSpace(p.h.Line().Text())); ok {255 return cmd256 }257 }258 if p.Sel >= len(p.shown) || p.shown[p.Sel].Item.Off {259 return nil260 }261 it := p.shown[p.Sel].Item262 cmd := it.Pick()263 if p.Answers {264 p.h.RecordAnswer(it.Title, false)265 }266 return cmd267}268269// Close closes the picker without picking, which cancels the question270// it asks.271func (p *Picker) Close() {272 p.h.Close()273 if p.Answers {274 p.h.RecordAnswer("", true)275 }276}277278// Answer picks the item titled title (in any case), as a script answers279// the picker's question, and reports whether there was one.280func (p *Picker) Answer(title string) (tea.Cmd, bool) {281 for i := range p.Items {282 if it := &p.Items[i]; strings.EqualFold(it.Title, title) && !it.Off {283 return it.Pick(), true284 }285 }286 return nil, false287}288289// FirstRow is the box's first result row, under the top border, the290// search field and a separator.291const FirstRow = 3292293func (p *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {294 if e.Box != ID {295 if e.Kind == overlay.MousePress {296 p.Close()297 }298 return nil299 }300 i := p.Top + e.Row - FirstRow301 switch {302 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:303 p.Move(-1, len(p.shown))304 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:305 p.Move(1, len(p.shown))306 case e.Row < FirstRow || i >= len(p.shown) || i >= p.Top+p.rows():307 case e.Kind == overlay.MouseMotion:308 p.Sel = i309 case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:310 p.Sel = i311 return p.pick()312 }313 return nil314}315316func (p *Picker) Status() (string, string) {317 keys := p.h.Theme().KeyHints("Up/Down", "move", "Enter", cmp.Or(p.Action, "run"), "Esc", "close")318 if p.Sel >= len(p.shown) {319 return "", keys320 }321 it := p.shown[p.Sel].Item322 if it.Off {323 return it.Desc + " (not available now)", keys324 }325 return it.Desc, keys326}327328// rows is how many results show. The box shrinks from the bottom as the329// search narrows, so the search field never moves, and it leaves the330// status line visible.331func (p *Picker) rows() int {332 _, height := p.h.Size()333 return max(min(len(p.shown), 12, height-1-overlay.MenuLine-1-4), 1)334}335336func (p *Picker) box() (x, y, inner int) {337 width, _ := p.h.Size()338 tw, dw, kw := 0, 0, 0339 for _, it := range p.Items {340 tw = max(tw, ansi.StringWidth(it.Title))341 dw = max(dw, ansi.StringWidth(it.Detail))342 kw = max(kw, ansi.StringWidth(it.Key)+2)343 }344 inner = min(1+tw+3+dw+3+kw+1, p.maxW, width-2)345 inner = max(inner, min(40, width-2))346 if p.At != nil {347 _, height := p.h.Size()348 h := max(min(len(p.Items), 12), 1) + 4 // its tallest, so it never moves349 x := min(max(p.At[0], 0), max(width-inner-2, 0))350 y := min(max(p.At[1], 0), max(height-1-h, 0))351 return x, y, inner352 }353 return (width - inner - 2) / 2, overlay.MenuLine + 1, inner354}355356func (p *Picker) Cursor() (int, int) {357 x, y, _ := p.box()358 return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + ansi.StringWidth(p.h.Line().Head()), y + 1359}360361func (p *Picker) Layout() []overlay.Box {362 th, line := p.h.Theme(), p.h.Line()363 x, y, inner := p.box()364 rows := p.rows()365 if len(p.shown) > 0 {366 p.Show(rows)367 }368 input := th.Title.Render(overlay.SearchPrompt) + line.Text()369 if len(line.Buf) == 0 {370 input += th.Muted.Render(p.placeholder)371 }372 lines := []string{theme.Cells(th.MenuBar, input, inner), theme.SepRow}373 lines = append(lines, p.resultRows(th, inner, rows)...)374 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.Items))375 return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, footer, lines)}}376}377378// resultRows lays out the visible results in columns: title, detail and379// the shortcut chip at the right. The detail column is dropped when the380// box is too narrow for it.381func (p *Picker) resultRows(th *theme.Theme, inner, rows int) []string {382 tw, kw := 0, 0383 for _, it := range p.Items {384 tw = max(tw, ansi.StringWidth(it.Title))385 if it.Key != "" {386 kw = max(kw, ansi.StringWidth(it.Key)+2)387 }388 }389 tw = min(tw, inner*3/5)390 dw := inner - 1 - tw - 3 - kw - 2391 out := make([]string, 0, rows)392 for r := range rows {393 i := p.Top + r394 if i >= len(p.shown) {395 text := ""396 if r == 0 {397 text = th.Muted.Render(" No matches")398 }399 out = append(out, theme.Cells(th.MenuBar, text, inner))400 continue401 }402 pm := p.shown[i]403 base, dim, hl := th.MenuBar, th.Muted, th.Match404 switch {405 case i == p.Sel:406 base, dim, hl = th.MenuSelected, th.MenuSelected, th.MatchSelected407 case pm.Item.Off:408 base, dim, hl = th.Disabled, th.Disabled, th.Disabled409 }410 row := base.Render(" ")411 if pm.Item.Swatch != "" {412 row += pm.Item.Swatch + base.Render(" ")413 }414 row += theme.HighlightMatches(pm.Item.Title, pm.InTitle, tw, base, hl)415 if dw >= 8 {416 row += base.Render(" ") + theme.HighlightMatches(pm.Item.Detail, pm.InDetail, dw, dim, hl)417 }418 k := ""419 switch {420 case pm.Item.Key == "":421 case i == p.Sel:422 k = base.Render(" " + pm.Item.Key + " ")423 case pm.Item.Off:424 k = th.Disabled.Render(" " + pm.Item.Key + " ")425 default:426 k = th.Chip(pm.Item.Key)427 }428 row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")429 out = append(out, ansi.Truncate(row, inner, ""))430 }431 return out432}