cat / 012
clone

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

main
012 / internal / ui / filterpick / filterpick.go
filterpick.go 11.2K
1// Package filterpick is the values list and condition of one column of a2// filter, in the manner of fzf: a condition on top, then the column's3// values with checkboxes, narrowed by a fuzzy search as you type. The4// sheet's filter and a pivot table's filters open it, each saying what5// applying and cancelling do. It knows the UI only through Host.6package filterpick78import (9 "strconv"10 "strings"1112 tea "charm.land/bubbletea/v2"13 "github.com/charmbracelet/x/ansi"14 "github.com/sahilm/fuzzy"1516 "src.rest/cat/012/internal/locale"17 "src.rest/cat/012/internal/sheet"18 "src.rest/cat/012/internal/ui/lineedit"19 "src.rest/cat/012/internal/ui/overlay"20 "src.rest/cat/012/internal/ui/theme"21)2223// Host is what the filter picker needs of the UI.24type Host interface {25 Theme() *theme.Theme26 Size() (width, height int)27 // Line is the shared edit line the focused field is typed in.28 Line() *lineedit.Line29 // Close closes the open overlay.30 Close()31 // Locale is how the condition's value is typed and shown.32 Locale() *locale.Locale33}3435// Picker is the values list and condition for one column.36type Picker struct {37 h Host38 title string39 x int // where the box goes, e.g. over its column40 // OnApply gets the criteria chosen, after the picker has closed.41 OnApply func(cr sheet.Criteria)42 // OnCancel, when set, runs after Esc or a click outside closes the43 // picker.44 OnCancel func()45 values []sheet.FilterValue46 checked map[string]bool47 cond sheet.Condition48 field int // 0 is the search, 1 the condition's value49 fields [2]string // the text of each field50 shown []int // values matching the search, best first51 condOnly bool // no values to tick, only the condition: OnlyCondition52 overlay.List // over the rows: "Select all", then shown53}5455// ID identifies the picker's box in mouse events.56const ID = "filter"5758// Rows of the box: the top border, the condition, a separator, the59// search, a separator, then the list.60const firstRow = 56162// New returns a picker titled title at screen column x over values, with63// cond as the condition, calling apply with the criteria chosen. The64// caller opens it, then calls Start.65func New(h Host, title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria)) *Picker {66 p := &Picker{h: h, title: title, x: x, OnApply: apply, values: values, checked: map[string]bool{}, cond: cond}67 for _, v := range p.values {68 p.checked[v.Text] = v.Shown69 }70 p.fields[1] = sheet.LocalCondArg(p.cond.Op, p.cond.Arg, h.Locale())71 return p72}7374// Start readies the edit line for the search, once the picker is open.75func (p *Picker) Start() {76 p.h.Line().Clear()77 p.search()78}7980// OnlyCondition has the picker ask for the condition alone, with no81// values to tick, for a column whose values are too many to list (a82// linked source's). Call it after Start.83func (p *Picker) OnlyCondition() {84 p.condOnly = true85 p.focus(1)86}8788// close closes the picker without applying it.89func (p *Picker) close() {90 p.h.Close()91 if p.OnCancel != nil {92 p.OnCancel()93 }94}9596func (p *Picker) Indicator() string { return "FILTER" }9798// focus moves editing to field i, keeping the other field's text.99func (p *Picker) focus(i int) {100 line := p.h.Line()101 p.fields[p.field] = line.Text()102 p.field = i103 line.Set(p.fields[i])104}105106func (p *Picker) Changed() {107 p.fields[p.field] = p.h.Line().Text()108 if p.field == 0 {109 p.search()110 }111}112113// valueLabel is how a value shows in the list: as the sheet's locale114// shows it.115func valueLabel(v sheet.FilterValue) string {116 switch {117 case v.Text == "":118 return "(Blanks)"119 case v.Label != "":120 return v.Label121 }122 return v.Text123}124125// search narrows the list to the values matching the search.126func (p *Picker) search() {127 p.Sel, p.Top = 0, 0128 p.shown = p.shown[:0]129 q := strings.TrimSpace(p.fields[0])130 if q == "" {131 for i := range p.values {132 p.shown = append(p.shown, i)133 }134 return135 }136 labels := make([]string, len(p.values))137 for i, v := range p.values {138 labels[i] = valueLabel(v)139 }140 for _, mt := range fuzzy.Find(q, labels) {141 p.shown = append(p.shown, mt.Index)142 }143}144145// toggle checks or unchecks list row i: "Select all" (row 0) checks every146// shown value, or unchecks them if they all are.147func (p *Picker) toggle(i int) {148 if i > 0 {149 t := p.values[p.shown[i-1]].Text150 p.checked[t] = !p.checked[t]151 return152 }153 all := p.allChecked()154 for _, k := range p.shown {155 p.checked[p.values[k].Text] = !all156 }157}158159func (p *Picker) allChecked() bool {160 for _, k := range p.shown {161 if !p.checked[p.values[k].Text] {162 return false163 }164 }165 return true166}167168// cycle steps the condition through Sheets' list.169func (p *Picker) cycle(d int) {170 n := len(sheet.CondOps())171 p.cond.Op = sheet.CondOp((int(p.cond.Op) + d + n) % n)172}173174// apply closes the picker and hands on the criteria chosen.175func (p *Picker) apply() {176 p.fields[p.field] = p.h.Line().Text()177 var cr sheet.Criteria178 for _, v := range p.values {179 if !p.checked[v.Text] {180 cr.Hidden = append(cr.Hidden, v.Text)181 }182 }183 cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CondArg(p.cond.Op, strings.TrimSpace(p.fields[1]), p.h.Locale())}184 if !cr.Cond.Op.TakesArg() {185 cr.Cond.Arg = ""186 } else if cr.Cond.Arg == "" {187 cr.Cond = sheet.Condition{}188 }189 p.h.Close()190 p.OnApply(cr)191}192193// Answer applies criteria a macro recorded, as sheet.Criteria.JSON writes194// them: the values hidden, and a condition with its value in en-US's195// form, as if checked and typed.196func (p *Picker) Answer(text string) error {197 cr, err := sheet.ParseCriteria(text)198 if err != nil {199 return err200 }201 hidden := map[string]bool{}202 for _, v := range cr.Hidden {203 hidden[v] = true204 }205 for _, v := range p.values {206 p.checked[v.Text] = !hidden[v.Text]207 }208 p.cond.Op = cr.Cond.Op209 p.field = 1210 p.h.Line().Set(sheet.LocalCondArg(cr.Cond.Op, cr.Cond.Arg, p.h.Locale()))211 p.apply()212 return nil213}214215func (p *Picker) Key(k tea.KeyPressMsg) tea.Cmd {216 n := len(p.shown) + 1217 switch key := k.String(); {218 case key == "esc":219 p.close()220 case key == "enter":221 p.apply()222 case key == "tab" || key == "shift+tab":223 if !p.condOnly {224 p.focus(1 - p.field)225 }226 case p.field == 1 && (key == "up" || key == "down"):227 if key == "up" {228 p.cycle(-1)229 } else {230 p.cycle(1)231 }232 case key == "up" || key == "ctrl+p":233 p.Move(-1, n)234 case key == "down" || key == "ctrl+n":235 p.Move(1, n)236 case key == "pgup":237 p.Sel = max(p.Sel-p.rows(), 0)238 case key == "pgdown":239 p.Sel = min(p.Sel+p.rows(), n-1)240 case p.field == 0 && key == "space":241 p.toggle(p.Sel)242 default:243 line := p.h.Line()244 before := line.Text()245 line.Key(k)246 if line.Text() != before {247 p.Changed()248 }249 }250 return nil251}252253func (p *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {254 if e.Box != ID {255 if e.Kind == overlay.MousePress {256 p.close()257 }258 return nil259 }260 n := len(p.shown) + 1261 i := p.Top + e.Row - firstRow262 switch {263 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:264 p.Move(-1, n)265 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:266 p.Move(1, n)267 case e.Kind != overlay.MousePress && e.Kind != overlay.MouseMotion:268 case e.Row == 1 && e.Kind == overlay.MousePress:269 if p.field != 1 {270 p.focus(1)271 }272 switch x := e.Col - 1 - len(" If "); {273 case x == 0:274 p.cycle(-1)275 case x == ansi.StringWidth(p.condChip())-1:276 p.cycle(1)277 }278 case e.Row == 3 && e.Kind == overlay.MousePress && !p.condOnly:279 p.focus(0)280 case e.Row < firstRow || i >= n || i >= p.Top+p.rows():281 case e.Kind == overlay.MouseMotion:282 p.Sel = i283 case e.Button == tea.MouseLeft:284 p.Sel = i285 p.toggle(i)286 }287 return nil288}289290func (p *Picker) Status() (string, string) {291 th := p.h.Theme()292 if p.condOnly {293 return "Rows must meet the condition", th.KeyHints("Up/Down", "condition", "Enter", "apply", "Esc", "cancel")294 }295 if p.field == 1 {296 return "Rows must also meet the condition", th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")297 }298 width, _ := p.h.Size()299 pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}300 desc := "Type to search the values"301 for {302 keys := th.KeyHints(pairs...)303 switch {304 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:305 return th.Muted.Render(desc), keys306 case desc != "":307 desc = ""308 case len(pairs) > 4:309 pairs = append(pairs[:2], pairs[4:]...)310 default:311 return "", keys312 }313 }314}315316// rows is how many list rows show: all of them if they fit above the317// status line, at most twelve.318func (p *Picker) rows() int {319 _, height := p.h.Size()320 return max(min(len(p.shown)+1, 12, height-1-overlay.GridTop-firstRow-1), 1)321}322323func (p *Picker) box() (x, y, inner int) {324 width, height := p.h.Size()325 w := 0326 for _, v := range p.values {327 w = max(w, ansi.StringWidth(valueLabel(v))+len(strconv.Itoa(v.Count)))328 }329 inner = min(max(w+10, 36), 48, width-2)330 h := p.rows() + firstRow + 1331 x = max(min(p.x, width-inner-2), 0)332 y = max(min(overlay.GridTop, height-h), 0)333 return x, min(y, max(height-1-h, 0)), inner334}335336func (p *Picker) Cursor() (int, int) {337 x, y, _ := p.box()338 caret := ansi.StringWidth(p.h.Line().Head())339 if p.field == 1 {340 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1341 }342 return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3343}344345// condChip is the condition's name between arrows that change it.346func (p *Picker) condChip() string {347 return "‹ " + p.cond.Op.Title() + " ›"348}349350func (p *Picker) Layout() []overlay.Box {351 th := p.h.Theme()352 x, y, inner := p.box()353 rows := p.rows()354 p.Show(rows)355356 chipStyle := th.KeyChip357 if p.field == 1 {358 chipStyle = th.MenuSelected359 }360 cond := th.Muted.Render(" If ") + chipStyle.Render(p.condChip())361 if p.cond.Op.TakesArg() {362 arg := p.fields[1]363 if p.field == 1 {364 arg = p.h.Line().Text()365 }366 if arg == "" && p.field != 1 {367 arg = th.Muted.Render("value")368 }369 cond += " " + arg370 }371 search := p.fields[0]372 if p.field == 0 {373 search = p.h.Line().Text()374 }375 input := th.Title.Render(overlay.SearchPrompt) + search376 if search == "" {377 input += th.Muted.Render("Search values")378 }379 if p.condOnly {380 return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, "", []string{theme.Cells(th.MenuBar, cond, inner)})}}381 }382 lines := []string{theme.Cells(th.MenuBar, cond, inner), theme.SepRow, theme.Cells(th.MenuBar, input, inner), theme.SepRow}383 for r := range rows {384 lines = append(lines, p.row(th, p.Top+r, inner))385 }386 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))387 return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, footer, lines)}}388}389390// row draws list row i: "Select all", then the values shown.391func (p *Picker) row(th *theme.Theme, i, inner int) string {392 if i > len(p.shown) {393 return theme.Cells(th.MenuBar, "", inner)394 }395 label, count, checked := "Select all", "", p.allChecked()396 dim := false397 if i > 0 {398 v := p.values[p.shown[i-1]]399 label, count, checked, dim = valueLabel(v), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""400 }401 box := "[ ] "402 if checked {403 box = "[x] "404 }405 base, muted := th.MenuBar, th.Muted406 if i == p.Sel {407 base, muted = th.MenuSelected, th.MenuSelected408 }409 labelStyle := base410 if dim {411 labelStyle = muted412 }413 text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)414 text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")415 return ansi.Truncate(text, inner, "")416}