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 / filter.go
filter.go 6.3K
1package ui23import (4 "encoding/json"5 "errors"6 "fmt"7 "log/slog"8 "strconv"9 "strings"1011 tea "charm.land/bubbletea/v2"12 "github.com/charmbracelet/x/ansi"1314 "src.rest/cat/012/internal/sheet"15 "src.rest/cat/012/internal/ui/filterpick"16)1718// Filters follow Sheets' Data > Create a filter: the headers of the19// filtered range get a button (▾, ▼ once the column hides something),20// and clicking it or pressing Alt+Down in the column opens a picker in the21// manner of fzf: a condition on top, then the column's values with22// checkboxes, narrowed by a fuzzy search as you type. Rows that don't23// pass are hidden, not deleted.2425func init() {26 register(27 &command{id: "data.filter", title: "Create a filter", desc: "Filter the rows of the selection, or of the data around the active cell; the first row holds the headers",28 run: (*Model).createFilter,29 enabled: func(m *Model) bool { _, on := m.sheet.FilterRange(); return !on }},30 &command{id: "data.filter_column", title: "Filter by column", desc: "Choose which values of the active column show, or a condition they must meet",31 run: func(m *Model) tea.Cmd {32 m.openFilterPicker(m.cur.Col)33 return nil34 },35 answer: (*Model).answerFilter,36 enabled: func(m *Model) bool {37 r, on := m.sheet.FilterRange()38 return on && m.cur.Col >= r.From.Col && m.cur.Col <= r.To.Col39 }},40 &command{id: "data.filter_remove", title: "Remove filter", desc: "Remove the filter and show every row again",41 run: func(m *Model) tea.Cmd {42 m.sheet.RemoveFilter()43 m.changed = true44 return nil45 },46 enabled: func(m *Model) bool { _, on := m.sheet.FilterRange(); return on }},47 )48}4950// createFilter filters the selection, or the data around the active cell.51func (m *Model) createFilter() tea.Cmd {52 r := m.dataRange()53 if m.out != nil {54 r = m.out.table() // an output's grid filters the whole table, under its header55 }56 m.sheet.CreateFilter(r)57 m.changed = true58 m.note = "Created a filter on " + m.rangeLabel(r) + " " + m.th.KeyHints(m.shortcut("data.filter_column"), "filter the active column")59 return nil60}6162// dataRange is the range data commands act on: the selection when there63// is one, cut to the data in it, or else the table around the active64// cell.65func (g *grid) dataRange() sheet.Rect {66 if !g.hasRange() {67 return g.tableAround(g.cur)68 }69 r := g.selection()70 if used, ok := g.sheet.UsedRange(); ok {71 r.To.Row = max(min(r.To.Row, used.To.Row), r.From.Row)72 r.To.Col = max(min(r.To.Col, used.To.Col), r.From.Col)73 }74 return r75}7677// tableAround is the table around a: a table's range, or in a region,78// the region's table from its header row down, whatever is next to79// them; elsewhere the block of data around a (Sheet.Region).80func (g *grid) tableAround(a sheet.Addr) sheet.Rect {81 if t, ok := g.sheet.TableAt(a); ok {82 return t.Range83 }84 if reg, ok := g.sheet.RegionAt(a); ok {85 if t, ok := g.sheet.RegionTable(reg.Name); ok {86 return t87 }88 }89 return g.sheet.Region(a)90}9192// filterMark is the filter button in column c's header, if c is in the93// filter's range, and whether the column's filter hides anything.94func (g *grid) filterMark(c int) (string, bool) {95 r, on := g.sheet.FilterRange()96 switch {97 case !on || c < r.From.Col || c > r.To.Col:98 return "", false99 case g.sheet.ColumnFiltered(c):100 return "▼", true101 }102 return "▾", false103}104105// filterButtonX is the screen x of column c's filter button, or -1.106func (g *grid) filterButtonX(c int) int {107 w := g.sheet.ColWidth(c)108 mark, _ := g.filterMark(c)109 name := g.colLabel(c, w, 2)110 if mark == "" || w < ansi.StringWidth(name)+3 {111 return -1112 }113 lw := ansi.StringWidth(name) + 2114 return g.colStart(c) + (w-lw)/2 + lw - 1115}116117// openFilterPicker opens the picker for column col of the filter.118func (m *Model) openFilterPicker(col int) *filterpick.Picker {119 r, on := m.sheet.FilterRange()120 if !on || col < r.From.Col || col > r.To.Col {121 return nil122 }123 title := "Filter " + m.colName(col)124 if h := m.sheet.LocalText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" && !m.named {125 title += " " + h126 }127 return m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,128 func(cr sheet.Criteria) {129 m.filterColumn(col, cr)130 m.recordDialog("data.filter_column", filterAnswer(col, cr))131 })132}133134// openValuesPicker opens a filter picker titled title at screen column x135// over values, with cond as the condition, calling apply with the136// criteria chosen.137func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria)) *filterpick.Picker {138 p := filterpick.New(m.host(), title, x, values, cond, apply)139 m.openOverlay(p)140 p.Start()141 return p142}143144// filterAnswer is the answer to data.filter_column that sets column145// col's criteria to cr: {"column": "B", "hidden": [...]}, the criteria146// as a filter column's are written in the file.147func filterAnswer(col int, cr sheet.Criteria) string {148 rest := strings.TrimPrefix(cr.JSON(), "{")149 if rest != "}" {150 rest = "," + rest151 }152 return `{"column":"` + sheet.ColName(col) + `"` + rest153}154155// answerFilter sets a column's criteria from a macro's answer, as the156// picker does: the column named, or the active one.157func (m *Model) answerFilter(text string) (tea.Cmd, error) {158 var a struct {159 Column string `json:"column"`160 }161 if err := json.Unmarshal([]byte(text), &a); err != nil {162 return nil, err163 }164 col := m.cur.Col165 if a.Column != "" {166 c, ok := sheet.ParseCol(strings.ToUpper(a.Column))167 if !ok {168 return nil, fmt.Errorf("no column %q", a.Column)169 }170 col = c171 }172 r, on := m.sheet.FilterRange()173 switch {174 case !on:175 return nil, errors.New("the sheet has no filter: run(\"data.filter\") creates one")176 case col < r.From.Col || col > r.To.Col:177 return nil, fmt.Errorf("column %s isn't in the filter's range %s", sheet.ColName(col), r)178 }179 return nil, m.openFilterPicker(col).Answer(text)180}181182// filterColumn sets the criteria of column col of the sheet's filter.183func (m *Model) filterColumn(col int, cr sheet.Criteria) {184 span := m.spans.Start("filter")185 m.sheet.FilterColumn(col, cr)186 span.End(slog.Int("hidden", m.sheet.HiddenRows()))187 m.changed = true188 if n := m.sheet.HiddenRows(); n > 0 {189 m.note = "Filtered column " + m.colName(col) + ": " + rowCount(n) + " hidden"190 } else {191 m.note = "The filter shows every row"192 }193}194195func rowCount(n int) string {196 if n == 1 {197 return "1 row"198 }199 return strconv.Itoa(n) + " rows"200}