The picker moves behind a five-method host (theme, size, edit line,
close, locale), with what applying and cancelling do as callbacks. The
sheet's filter records run("data.filter_column", answer={...}) with the
column and its criteria as the file writes them, whether the picker was
opened by key or the header's button, and scripts answer it the same
way. The sheet gains JSON lines for charts, pivots and criteria.- commit
- 708ec6688ca52f2353b6fcd31d317903f4a16e22
- parent
- 6116df1ec906fbbea5cb114c9af8a03a270fe00c
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.0 of 3, Jev's estimate
| A | internal/ | 70 | +++++ |
| A | internal/ | 37 | +++ |
| M | internal/ | 14 | +- |
| M | internal/ | 369 | +++------------------------- |
| A | internal/ | 395 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 131 | ++++++++++ |
| M | internal/ | 8 | +- |
| M | internal/ | 47 | ++++ |
| M | internal/ | 8 | +- |
| M | internal/ | 1 | + |
| M | internal/ | 3 | +- |
@@ -0,0 +1,70 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+)7+8+// A chart, a pivot table and a filter column's criteria as the lines of9+// the file that hold them, to be written and read one at a time: macros10+// answer the dialogs that make them with these (see11+// docs/reference/macro-api.md), as they answer the rules panel with a12+// rule's line.13+14+// JSON is the chart as a line of the file.15+func (c Chart) JSON() string {16+ raw, _ := encodeChart(c)17+ return string(raw)18+}19+20+// ParseChart reads a chart written by JSON.21+func ParseChart(line string) (Chart, error) {22+ var fc fileChart23+ if err := json.Unmarshal([]byte(line), &fc); err != nil {24+ return Chart{}, err25+ }26+ return decodeChart(fc)27+}28+29+// JSON is the pivot table's definition as a line of the file.30+func (p Pivot) JSON() string {31+ raw, _ := encodePivot(&p)32+ return string(raw)33+}34+35+// ParsePivot reads a pivot table's definition written by JSON.36+func ParsePivot(line string) (Pivot, error) {37+ var fp filePivot38+ if err := json.Unmarshal([]byte(line), &fp); err != nil {39+ return Pivot{}, err40+ }41+ p, err := decodePivot(fp)42+ if err != nil {43+ return Pivot{}, err44+ }45+ return *p, nil46+}47+48+// JSON is the criteria as a filter column's are written in the file:49+// the values hidden, and a condition and its value.50+func (cr Criteria) JSON() string {51+ raw, _ := json.Marshal(fileCriteria{Hidden: cr.Hidden, Condition: cr.Cond.Op.String(), Value: cr.Cond.Arg})52+ return string(raw)53+}54+55+// ParseCriteria reads criteria written by JSON.56+func ParseCriteria(line string) (Criteria, error) {57+ var fc fileCriteria58+ if err := json.Unmarshal([]byte(line), &fc); err != nil {59+ return Criteria{}, err60+ }61+ op, ok := ParseCondOp(fc.Condition)62+ if !ok {63+ return Criteria{}, fmt.Errorf("unknown filter condition %q", fc.Condition)64+ }65+ cr := Criteria{Hidden: fc.Hidden, Cond: Condition{Op: op, Arg: fc.Value}}66+ if !op.TakesArg() {67+ cr.Cond.Arg = ""68+ }69+ return cr, nil70+}@@ -0,0 +1,37 @@
1+package sheet2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestLinesRoundTrip(t *testing.T) {9+ c := Chart{Type: ChartLine, Data: Rect{To: Addr{Col: 2, Row: 6}}, At: Addr{Col: 4, Row: 1}, W: 44, H: 14, Header: true, Title: "Sales"}10+ c.Legend = LegendNone11+ got, err := ParseChart(c.JSON())12+ if err != nil || got.JSON() != c.JSON() {13+ t.Fatalf("chart %s came back %s, %v", c.JSON(), got.JSON(), err)14+ }15+ if _, err := ParseChart(`{"type":"blimp","data":"A1:B2","at":"C1"}`); err == nil {16+ t.Error("a chart of an unknown type was read")17+ }18+19+ p := Pivot{Source: "Q3 plan", Range: Rect{To: Addr{Col: 3, Row: 9}}, Rows: []PivotGroup{{Col: 1, Desc: true}},20+ Values: []PivotValue{{Col: 3, Summarize: SumBy}}, Filters: []PivotFilter{{Col: 0, Criteria: Criteria{Hidden: []string{"North"}}}}, RowTotals: true}21+ back, err := ParsePivot(p.JSON())22+ if err != nil || back.JSON() != p.JSON() || !strings.HasPrefix(p.JSON(), `{"source":"'Q3 plan'!A1:D10"`) {23+ t.Fatalf("pivot %s came back %s, %v", p.JSON(), back.JSON(), err)24+ }25+26+ cr := Criteria{Hidden: []string{"", "x"}, Cond: Condition{Op: CondGreater, Arg: "3"}}27+ if cr.JSON() != `{"hidden":["","x"],"condition":"gt","value":"3"}` {28+ t.Fatalf("criteria %s", cr.JSON())29+ }30+ crBack, err := ParseCriteria(cr.JSON())31+ if err != nil || crBack.JSON() != cr.JSON() {32+ t.Fatalf("criteria came back %s, %v", crBack.JSON(), err)33+ }34+ if empty, _ := ParseCriteria(`{"condition":"empty","value":"ignored"}`); empty.Cond.Arg != "" {35+ t.Errorf("a condition without a value kept %q", empty.Cond.Arg)36+ }37+}@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/filterpick"1112 )1213 1314 // handleX is the screen x of the fill handle in column c (scrolled to A).@@ -165,8 +166,7 @@ func TestFilterPicker(t *testing.T) {
165166 t.Fatalf("filter %v %v", r, ok)166167 }167168 press(t, m, "<alt+down>")168- p, ok := m.overlay.(*filterPicker)169- if !ok {169+ if _, ok := m.overlay.(*filterpick.Picker); !ok {170170 t.Fatalf("overlay %T", m.overlay)171171 }172172 out := screen(m)@@ -177,8 +177,8 @@ func TestFilterPicker(t *testing.T) {
177177 }178178 // Uncheck 0 and 1; the search narrows the list.179179 press(t, m, "<down>", "<space>", "<down>", "<space>", "3")180- if len(p.shown) != 1 || !strings.Contains(screen(m), "1 of 4") {181- t.Errorf("search shows %d", len(p.shown))180+ if !strings.Contains(screen(m), "1 of 4") {181+ t.Errorf("search shows:\n%s", screen(m))182182 }183183 press(t, m, "<enter>")184184 if m.sheet.HiddenRows() != 4 || m.note != "Filtered column B: 4 rows hidden" {@@ -219,12 +219,12 @@ func TestFilterButtonClick(t *testing.T) {
219219 t.Fatalf("button at %d in %q", x, line(m, headerLine))220220 }221221 click(m, x, headerLine, 0)222- if p, ok := m.overlay.(*filterPicker); !ok || !strings.HasPrefix(p.title, "Filter B") {222+ if _, ok := m.overlay.(*filterpick.Picker); !ok || !strings.Contains(screen(m), "Filter B") {223223 t.Fatalf("overlay %T", m.overlay)224224 }225225 // Clicking a value toggles it; clicking outside cancels.226- click(m, x+4, gridTop+filterFirstRow+1, 0)227- if p := m.overlay.(*filterPicker); p.checked["0"] {226+ click(m, x+4, gridTop+5+1, 0) // the list starts five rows into the box227+ if !strings.Contains(screen(m), "[ ] 0") {228228 t.Error("click didn't uncheck 0")229229 }230230 click(m, 70, 18, 0)@@ -1,17 +1,17 @@
11 package ui22 33 import (4+ "encoding/json"5+ "errors"6+ "fmt"47 "log/slog"58 "strconv"69 "strings"710 811 tea "charm.land/bubbletea/v2"9- "github.com/charmbracelet/x/ansi"10- "github.com/sahilm/fuzzy"1112 1213 "github.com/FelineStateMachine/012/internal/sheet"13- "github.com/FelineStateMachine/012/internal/ui/overlay"14- "github.com/FelineStateMachine/012/internal/ui/theme"14+ "github.com/FelineStateMachine/012/internal/ui/filterpick"1515 )1616 1717 // Filters follow Sheets' Data > Create a filter: the headers of the@@ -31,6 +31,7 @@ func init() {
3131 m.openFilterPicker(m.cur.Col)3232 return nil3333 },34+ answer: (*Model).answerFilter,3435 enabled: func(m *Model) bool {3536 r, on := m.sheet.FilterRange()3637 return on && m.cur.Col >= r.From.Col && m.cur.Col <= r.To.Col@@ -93,160 +94,69 @@ func (g *grid) filterButtonX(c int) int {
9394 return g.colStart(c) + (w-lw)/2 + lw - 19495 }9596 96-// filterPicker is the values list and condition for one column: of the97-// sheet's filter, or of a pivot table's (pivoteditor.go), which set what98-// applying and cancelling do.99-type filterPicker struct {100- m *Model // the model it acts on101- title string102- x int // where the box goes, e.g. over its column103- onApply func(m *Model, cr sheet.Criteria)104- // onCancel, when set, runs after Esc or a click outside closes the105- // picker.106- onCancel func(m *Model)107- values []sheet.FilterValue108- checked map[string]bool109- cond sheet.Condition110- field int // 0 is the search, 1 the condition's value111- fields [2]string // the text of each field112- shown []int // values matching the search, best first113- overlay.List // over the rows: "Select all", then shown114-}115-116-const filterID = "filter"117-118-// Rows of the box: the top border, the condition, a separator, the119-// search, a separator, then the list.120-const filterFirstRow = 5121-12297 // openFilterPicker opens the picker for column col of the filter.123-func (m *Model) openFilterPicker(col int) {98+func (m *Model) openFilterPicker(col int) *filterpick.Picker {12499 r, on := m.sheet.FilterRange()125100 if !on || col < r.From.Col || col > r.To.Col {126- return101+ return nil127102 }128103 title := "Filter " + sheet.ColName(col)129104 if h := m.sheet.ShownText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {130105 title += " " + h131106 }132- m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,133- func(m *Model, cr sheet.Criteria) { m.filterColumn(col, cr) })107+ return m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,108+ func(cr sheet.Criteria) {109+ m.filterColumn(col, cr)110+ m.recordDialog("data.filter_column", filterAnswer(col, cr))111+ })134112 }135113 136114 // openValuesPicker opens a filter picker titled title at screen column x137115 // over values, with cond as the condition, calling apply with the138116 // criteria chosen.139-func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(*Model, sheet.Criteria)) *filterPicker {140- p := &filterPicker{m: m, title: title, x: x, onApply: apply, values: values, checked: map[string]bool{}, cond: cond}141- for _, v := range p.values {142- p.checked[v.Text] = v.Shown143- }144- p.fields[1] = sheet.LocalArg(p.cond.Arg, m.locale())117+func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria)) *filterpick.Picker {118+ p := filterpick.New(m.host(), title, x, values, cond, apply)145119 m.openOverlay(p)146- m.line.Clear()147- p.search()120+ p.Start()148121 return p149122 }150123 151-// close closes the picker without applying it.152-func (p *filterPicker) close(m *Model) {153- m.closeOverlay()154- if p.onCancel != nil {155- p.onCancel(m)124+// filterAnswer is the answer to data.filter_column that sets column125+// col's criteria to cr: {"column": "B", "hidden": [...]}, the criteria126+// as a filter column's are written in the file.127+func filterAnswer(col int, cr sheet.Criteria) string {128+ rest := strings.TrimPrefix(cr.JSON(), "{")129+ if rest != "}" {130+ rest = "," + rest156131 }132+ return `{"column":"` + sheet.ColName(col) + `"` + rest157133 }158134 159-func (p *filterPicker) Indicator() string { return "FILTER" }160-161-// focus moves editing to field i, keeping the other field's text.162-func (p *filterPicker) focus(m *Model, i int) {163- p.fields[p.field] = m.line.Text()164- p.field = i165- m.line.Set(p.fields[i])166-}167-168-func (p *filterPicker) Changed() {169- m := p.m170- p.fields[p.field] = m.line.Text()171- if p.field == 0 {172- p.search()135+// answerFilter sets a column's criteria from a macro's answer, as the136+// picker does: the column named, or the active one.137+func (m *Model) answerFilter(text string) (tea.Cmd, error) {138+ var a struct {139+ Column string `json:"column"`173140 }174-}175-176-// valueLabel is how a value shows in the list.177-func valueLabel(text string) string {178- if text == "" {179- return "(Blanks)"141+ if err := json.Unmarshal([]byte(text), &a); err != nil {142+ return nil, err180143 }181- return text182-}183-184-// search narrows the list to the values matching the search.185-func (p *filterPicker) search() {186- p.Sel, p.Top = 0, 0187- p.shown = p.shown[:0]188- q := strings.TrimSpace(p.fields[0])189- if q == "" {190- for i := range p.values {191- p.shown = append(p.shown, i)144+ col := m.cur.Col145+ if a.Column != "" {146+ c, ok := sheet.ParseCol(strings.ToUpper(a.Column))147+ if !ok {148+ return nil, fmt.Errorf("no column %q", a.Column)192149 }193- return194- }195- labels := make([]string, len(p.values))196- for i, v := range p.values {197- labels[i] = valueLabel(v.Text)198- }199- for _, mt := range fuzzy.Find(q, labels) {200- p.shown = append(p.shown, mt.Index)150+ col = c201151 }202-}203-204-// toggle checks or unchecks list row i: "Select all" (row 0) checks every205-// shown value, or unchecks them if they all are.206-func (p *filterPicker) toggle(i int) {207- if i > 0 {208- t := p.values[p.shown[i-1]].Text209- p.checked[t] = !p.checked[t]210- return211- }212- all := p.allChecked()213- for _, k := range p.shown {214- p.checked[p.values[k].Text] = !all215- }216-}217-218-func (p *filterPicker) allChecked() bool {219- for _, k := range p.shown {220- if !p.checked[p.values[k].Text] {221- return false222- }223- }224- return true225-}226-227-// cycle steps the condition through Sheets' list.228-func (p *filterPicker) cycle(d int) {229- n := len(sheet.CondOps())230- p.cond.Op = sheet.CondOp((int(p.cond.Op) + d + n) % n)231-}232-233-// apply sets the column's criteria and closes the picker.234-func (p *filterPicker) apply(m *Model) {235- p.fields[p.field] = m.line.Text()236- var cr sheet.Criteria237- for _, v := range p.values {238- if !p.checked[v.Text] {239- cr.Hidden = append(cr.Hidden, v.Text)240- }241- }242- cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CanonicalArg(strings.TrimSpace(p.fields[1]), m.locale())}243- if !cr.Cond.Op.TakesArg() {244- cr.Cond.Arg = ""245- } else if cr.Cond.Arg == "" {246- cr.Cond = sheet.Condition{}152+ r, on := m.sheet.FilterRange()153+ switch {154+ case !on:155+ return nil, errors.New("the sheet has no filter: run(\"data.filter\") creates one")156+ case col < r.From.Col || col > r.To.Col:157+ return nil, fmt.Errorf("column %s isn't in the filter's range %s", sheet.ColName(col), r)247158 }248- m.closeOverlay()249- p.onApply(m, cr)159+ return nil, m.openFilterPicker(col).Answer(text)250160 }251161 252162 // filterColumn sets the criteria of column col of the sheet's filter.@@ -268,196 +178,3 @@ func rowCount(n int) string {
268178 }269179 return strconv.Itoa(n) + " rows"270180 }271-272-func (p *filterPicker) Key(k tea.KeyPressMsg) tea.Cmd {273- m := p.m274- n := len(p.shown) + 1275- switch key := k.String(); {276- case key == "esc":277- p.close(m)278- case key == "enter":279- p.apply(m)280- case key == "tab" || key == "shift+tab":281- p.focus(m, 1-p.field)282- case p.field == 1 && (key == "up" || key == "down"):283- if key == "up" {284- p.cycle(-1)285- } else {286- p.cycle(1)287- }288- case key == "up" || key == "ctrl+p":289- p.Move(-1, n)290- case key == "down" || key == "ctrl+n":291- p.Move(1, n)292- case key == "pgup":293- p.Sel = max(p.Sel-p.rows(m), 0)294- case key == "pgdown":295- p.Sel = min(p.Sel+p.rows(m), n-1)296- case p.field == 0 && key == "space":297- p.toggle(p.Sel)298- default:299- before := m.line.Text()300- m.line.Key(k)301- if m.line.Text() != before {302- p.Changed()303- }304- }305- return nil306-}307-308-func (p *filterPicker) Mouse(e overlay.MouseEvent) tea.Cmd {309- m := p.m310- if e.Box != filterID {311- if e.Kind == overlay.MousePress {312- p.close(m)313- }314- return nil315- }316- n := len(p.shown) + 1317- i := p.Top + e.Row - filterFirstRow318- switch {319- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:320- p.Move(-1, n)321- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:322- p.Move(1, n)323- case e.Kind != overlay.MousePress && e.Kind != overlay.MouseMotion:324- case e.Row == 1 && e.Kind == overlay.MousePress:325- if p.field != 1 {326- p.focus(m, 1)327- }328- switch x := e.Col - 1 - len(" If "); {329- case x == 0:330- p.cycle(-1)331- case x == ansi.StringWidth(p.condChip())-1:332- p.cycle(1)333- }334- case e.Row == 3 && e.Kind == overlay.MousePress:335- p.focus(m, 0)336- case e.Row < filterFirstRow || i >= n || i >= p.Top+p.rows(m):337- case e.Kind == overlay.MouseMotion:338- p.Sel = i339- case e.Button == tea.MouseLeft:340- p.Sel = i341- p.toggle(i)342- }343- return nil344-}345-346-func (p *filterPicker) Status() (string, string) {347- m := p.m348- if p.field == 1 {349- return "Rows must also meet the condition", m.th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")350- }351- pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}352- desc := "Type to search the values"353- for {354- keys := m.th.KeyHints(pairs...)355- switch {356- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:357- return m.th.Muted.Render(desc), keys358- case desc != "":359- desc = ""360- case len(pairs) > 4:361- pairs = append(pairs[:2], pairs[4:]...)362- default:363- return "", keys364- }365- }366-}367-368-// rows is how many list rows show: all of them if they fit above the369-// status line, at most twelve.370-func (p *filterPicker) rows(m *Model) int {371- return max(min(len(p.shown)+1, 12, m.height-1-gridTop-filterFirstRow-1), 1)372-}373-374-func (p *filterPicker) box(m *Model) (x, y, inner int) {375- w := 0376- for _, v := range p.values {377- w = max(w, ansi.StringWidth(valueLabel(v.Text))+len(strconv.Itoa(v.Count)))378- }379- inner = clamp(w+10, 36, 48)380- inner = min(inner, m.width-2)381- h := p.rows(m) + filterFirstRow + 1382- x, y = m.clampBox(p.x, gridTop, inner+2, h)383- return x, min(y, max(m.height-1-h, 0)), inner384-}385-386-func (p *filterPicker) Cursor() (int, int) {387- m := p.m388- x, y, _ := p.box(m)389- caret := ansi.StringWidth(m.line.Head())390- if p.field == 1 {391- return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1392- }393- return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3394-}395-396-// condChip is the condition's name between arrows that change it.397-func (p *filterPicker) condChip() string {398- return "‹ " + p.cond.Op.Title() + " ›"399-}400-401-func (p *filterPicker) Layout() []overlay.Box {402- m := p.m403- x, y, inner := p.box(m)404- rows := p.rows(m)405- p.Show(rows)406-407- chipStyle := m.th.KeyChip408- if p.field == 1 {409- chipStyle = m.th.MenuSelected410- }411- cond := m.th.Muted.Render(" If ") + chipStyle.Render(p.condChip())412- if p.cond.Op.TakesArg() {413- arg := p.fields[1]414- if p.field == 1 {415- arg = m.line.Text()416- }417- if arg == "" && p.field != 1 {418- arg = m.th.Muted.Render("value")419- }420- cond += " " + arg421- }422- search := p.fields[0]423- if p.field == 0 {424- search = m.line.Text()425- }426- input := m.th.Title.Render(overlay.SearchPrompt) + search427- if search == "" {428- input += m.th.Muted.Render("Search values")429- }430- lines := []string{theme.Cells(m.th.MenuBar, cond, inner), theme.SepRow, theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}431-432- for r := range rows {433- i := p.Top + r434- if i > len(p.shown) {435- lines = append(lines, theme.Cells(m.th.MenuBar, "", inner))436- continue437- }438- label, count, checked := "Select all", "", p.allChecked()439- dim := false440- if i > 0 {441- v := p.values[p.shown[i-1]]442- label, count, checked, dim = valueLabel(v.Text), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""443- }444- box := "[ ] "445- if checked {446- box = "[x] "447- }448- base, muted := m.th.MenuBar, m.th.Muted449- if i == p.Sel {450- base, muted = m.th.MenuSelected, m.th.MenuSelected451- }452- labelStyle := base453- if dim {454- labelStyle = muted455- }456- text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)457- text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")458- lines = append(lines, ansi.Truncate(text, inner, ""))459- }460- title := p.title461- footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))462- return []overlay.Box{{ID: filterID, X: x, Y: y, Lines: m.th.Frame(inner, title, footer, lines)}}463-}@@ -0,0 +1,395 @@
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.6+package filterpick7+8+import (9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+ "github.com/sahilm/fuzzy"15+16+ "github.com/FelineStateMachine/012/internal/locale"17+ "github.com/FelineStateMachine/012/internal/sheet"18+ "github.com/FelineStateMachine/012/internal/ui/lineedit"19+ "github.com/FelineStateMachine/012/internal/ui/overlay"20+ "github.com/FelineStateMachine/012/internal/ui/theme"21+)22+23+// Host is what the filter picker needs of the UI.24+type 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+}34+35+// Picker is the values list and condition for one column.36+type 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+ overlay.List // over the rows: "Select all", then shown52+}53+54+// ID identifies the picker's box in mouse events.55+const ID = "filter"56+57+// Rows of the box: the top border, the condition, a separator, the58+// search, a separator, then the list.59+const firstRow = 560+61+// New returns a picker titled title at screen column x over values, with62+// cond as the condition, calling apply with the criteria chosen. The63+// caller opens it, then calls Start.64+func New(h Host, title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria)) *Picker {65+ p := &Picker{h: h, title: title, x: x, OnApply: apply, values: values, checked: map[string]bool{}, cond: cond}66+ for _, v := range p.values {67+ p.checked[v.Text] = v.Shown68+ }69+ p.fields[1] = sheet.LocalArg(p.cond.Arg, h.Locale())70+ return p71+}72+73+// Start readies the edit line for the search, once the picker is open.74+func (p *Picker) Start() {75+ p.h.Line().Clear()76+ p.search()77+}78+79+// close closes the picker without applying it.80+func (p *Picker) close() {81+ p.h.Close()82+ if p.OnCancel != nil {83+ p.OnCancel()84+ }85+}86+87+func (p *Picker) Indicator() string { return "FILTER" }88+89+// focus moves editing to field i, keeping the other field's text.90+func (p *Picker) focus(i int) {91+ line := p.h.Line()92+ p.fields[p.field] = line.Text()93+ p.field = i94+ line.Set(p.fields[i])95+}96+97+func (p *Picker) Changed() {98+ p.fields[p.field] = p.h.Line().Text()99+ if p.field == 0 {100+ p.search()101+ }102+}103+104+// valueLabel is how a value shows in the list.105+func valueLabel(text string) string {106+ if text == "" {107+ return "(Blanks)"108+ }109+ return text110+}111+112+// search narrows the list to the values matching the search.113+func (p *Picker) search() {114+ p.Sel, p.Top = 0, 0115+ p.shown = p.shown[:0]116+ q := strings.TrimSpace(p.fields[0])117+ if q == "" {118+ for i := range p.values {119+ p.shown = append(p.shown, i)120+ }121+ return122+ }123+ labels := make([]string, len(p.values))124+ for i, v := range p.values {125+ labels[i] = valueLabel(v.Text)126+ }127+ for _, mt := range fuzzy.Find(q, labels) {128+ p.shown = append(p.shown, mt.Index)129+ }130+}131+132+// toggle checks or unchecks list row i: "Select all" (row 0) checks every133+// shown value, or unchecks them if they all are.134+func (p *Picker) toggle(i int) {135+ if i > 0 {136+ t := p.values[p.shown[i-1]].Text137+ p.checked[t] = !p.checked[t]138+ return139+ }140+ all := p.allChecked()141+ for _, k := range p.shown {142+ p.checked[p.values[k].Text] = !all143+ }144+}145+146+func (p *Picker) allChecked() bool {147+ for _, k := range p.shown {148+ if !p.checked[p.values[k].Text] {149+ return false150+ }151+ }152+ return true153+}154+155+// cycle steps the condition through Sheets' list.156+func (p *Picker) cycle(d int) {157+ n := len(sheet.CondOps())158+ p.cond.Op = sheet.CondOp((int(p.cond.Op) + d + n) % n)159+}160+161+// apply closes the picker and hands on the criteria chosen.162+func (p *Picker) apply() {163+ p.fields[p.field] = p.h.Line().Text()164+ var cr sheet.Criteria165+ for _, v := range p.values {166+ if !p.checked[v.Text] {167+ cr.Hidden = append(cr.Hidden, v.Text)168+ }169+ }170+ cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: sheet.CanonicalArg(strings.TrimSpace(p.fields[1]), p.h.Locale())}171+ if !cr.Cond.Op.TakesArg() {172+ cr.Cond.Arg = ""173+ } else if cr.Cond.Arg == "" {174+ cr.Cond = sheet.Condition{}175+ }176+ p.h.Close()177+ p.OnApply(cr)178+}179+180+// Answer applies criteria a macro recorded, as sheet.Criteria.JSON writes181+// them: the values hidden, and a condition with its value in en-US's182+// form, as if checked and typed.183+func (p *Picker) Answer(text string) error {184+ cr, err := sheet.ParseCriteria(text)185+ if err != nil {186+ return err187+ }188+ hidden := map[string]bool{}189+ for _, v := range cr.Hidden {190+ hidden[v] = true191+ }192+ for _, v := range p.values {193+ p.checked[v.Text] = !hidden[v.Text]194+ }195+ p.cond.Op = cr.Cond.Op196+ p.field = 1197+ p.h.Line().Set(sheet.LocalArg(cr.Cond.Arg, p.h.Locale()))198+ p.apply()199+ return nil200+}201+202+func (p *Picker) Key(k tea.KeyPressMsg) tea.Cmd {203+ n := len(p.shown) + 1204+ switch key := k.String(); {205+ case key == "esc":206+ p.close()207+ case key == "enter":208+ p.apply()209+ case key == "tab" || key == "shift+tab":210+ p.focus(1 - p.field)211+ case p.field == 1 && (key == "up" || key == "down"):212+ if key == "up" {213+ p.cycle(-1)214+ } else {215+ p.cycle(1)216+ }217+ case key == "up" || key == "ctrl+p":218+ p.Move(-1, n)219+ case key == "down" || key == "ctrl+n":220+ p.Move(1, n)221+ case key == "pgup":222+ p.Sel = max(p.Sel-p.rows(), 0)223+ case key == "pgdown":224+ p.Sel = min(p.Sel+p.rows(), n-1)225+ case p.field == 0 && key == "space":226+ p.toggle(p.Sel)227+ default:228+ line := p.h.Line()229+ before := line.Text()230+ line.Key(k)231+ if line.Text() != before {232+ p.Changed()233+ }234+ }235+ return nil236+}237+238+func (p *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {239+ if e.Box != ID {240+ if e.Kind == overlay.MousePress {241+ p.close()242+ }243+ return nil244+ }245+ n := len(p.shown) + 1246+ i := p.Top + e.Row - firstRow247+ switch {248+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:249+ p.Move(-1, n)250+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:251+ p.Move(1, n)252+ case e.Kind != overlay.MousePress && e.Kind != overlay.MouseMotion:253+ case e.Row == 1 && e.Kind == overlay.MousePress:254+ if p.field != 1 {255+ p.focus(1)256+ }257+ switch x := e.Col - 1 - len(" If "); {258+ case x == 0:259+ p.cycle(-1)260+ case x == ansi.StringWidth(p.condChip())-1:261+ p.cycle(1)262+ }263+ case e.Row == 3 && e.Kind == overlay.MousePress:264+ p.focus(0)265+ case e.Row < firstRow || i >= n || i >= p.Top+p.rows():266+ case e.Kind == overlay.MouseMotion:267+ p.Sel = i268+ case e.Button == tea.MouseLeft:269+ p.Sel = i270+ p.toggle(i)271+ }272+ return nil273+}274+275+func (p *Picker) Status() (string, string) {276+ th := p.h.Theme()277+ if p.field == 1 {278+ return "Rows must also meet the condition", th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")279+ }280+ width, _ := p.h.Size()281+ pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}282+ desc := "Type to search the values"283+ for {284+ keys := th.KeyHints(pairs...)285+ switch {286+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:287+ return th.Muted.Render(desc), keys288+ case desc != "":289+ desc = ""290+ case len(pairs) > 4:291+ pairs = append(pairs[:2], pairs[4:]...)292+ default:293+ return "", keys294+ }295+ }296+}297+298+// rows is how many list rows show: all of them if they fit above the299+// status line, at most twelve.300+func (p *Picker) rows() int {301+ _, height := p.h.Size()302+ return max(min(len(p.shown)+1, 12, height-1-overlay.GridTop-firstRow-1), 1)303+}304+305+func (p *Picker) box() (x, y, inner int) {306+ width, height := p.h.Size()307+ w := 0308+ for _, v := range p.values {309+ w = max(w, ansi.StringWidth(valueLabel(v.Text))+len(strconv.Itoa(v.Count)))310+ }311+ inner = min(max(w+10, 36), 48, width-2)312+ h := p.rows() + firstRow + 1313+ x = max(min(p.x, width-inner-2), 0)314+ y = max(min(overlay.GridTop, height-h), 0)315+ return x, min(y, max(height-1-h, 0)), inner316+}317+318+func (p *Picker) Cursor() (int, int) {319+ x, y, _ := p.box()320+ caret := ansi.StringWidth(p.h.Line().Head())321+ if p.field == 1 {322+ return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1323+ }324+ return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3325+}326+327+// condChip is the condition's name between arrows that change it.328+func (p *Picker) condChip() string {329+ return "‹ " + p.cond.Op.Title() + " ›"330+}331+332+func (p *Picker) Layout() []overlay.Box {333+ th := p.h.Theme()334+ x, y, inner := p.box()335+ rows := p.rows()336+ p.Show(rows)337+338+ chipStyle := th.KeyChip339+ if p.field == 1 {340+ chipStyle = th.MenuSelected341+ }342+ cond := th.Muted.Render(" If ") + chipStyle.Render(p.condChip())343+ if p.cond.Op.TakesArg() {344+ arg := p.fields[1]345+ if p.field == 1 {346+ arg = p.h.Line().Text()347+ }348+ if arg == "" && p.field != 1 {349+ arg = th.Muted.Render("value")350+ }351+ cond += " " + arg352+ }353+ search := p.fields[0]354+ if p.field == 0 {355+ search = p.h.Line().Text()356+ }357+ input := th.Title.Render(overlay.SearchPrompt) + search358+ if search == "" {359+ input += th.Muted.Render("Search values")360+ }361+ lines := []string{theme.Cells(th.MenuBar, cond, inner), theme.SepRow, theme.Cells(th.MenuBar, input, inner), theme.SepRow}362+ for r := range rows {363+ lines = append(lines, p.row(th, p.Top+r, inner))364+ }365+ footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))366+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, footer, lines)}}367+}368+369+// row draws list row i: "Select all", then the values shown.370+func (p *Picker) row(th *theme.Theme, i, inner int) string {371+ if i > len(p.shown) {372+ return theme.Cells(th.MenuBar, "", inner)373+ }374+ label, count, checked := "Select all", "", p.allChecked()375+ dim := false376+ if i > 0 {377+ v := p.values[p.shown[i-1]]378+ label, count, checked, dim = valueLabel(v.Text), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""379+ }380+ box := "[ ] "381+ if checked {382+ box = "[x] "383+ }384+ base, muted := th.MenuBar, th.Muted385+ if i == p.Sel {386+ base, muted = th.MenuSelected, th.MenuSelected387+ }388+ labelStyle := base389+ if dim {390+ labelStyle = muted391+ }392+ text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)393+ text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")394+ return ansi.Truncate(text, inner, "")395+}@@ -0,0 +1,131 @@
1+package filterpick2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/locale"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/lineedit"13+ "github.com/FelineStateMachine/012/internal/ui/overlay"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// fakeHost is a screen and an edit line in a locale.18+type fakeHost struct {19+ th theme.Theme20+ line lineedit.Line21+ loc *locale.Locale22+ closed int23+}24+25+func (h *fakeHost) Theme() *theme.Theme { return &h.th }26+func (h *fakeHost) Size() (int, int) { return 100, 30 }27+func (h *fakeHost) Line() *lineedit.Line { return &h.line }28+func (h *fakeHost) Close() { h.closed++ }29+func (h *fakeHost) Locale() *locale.Locale { return h.loc }30+31+var values = []sheet.FilterValue{{Text: "East", Count: 2, Shown: true}, {Text: "North", Count: 1, Shown: true}, {Text: "", Count: 1, Shown: false}}32+33+// open opens a picker as the model does, noting the criteria applied34+// and whether it was cancelled.35+func open(h *fakeHost, got *sheet.Criteria, cancelled *bool) *Picker {36+ p := New(h, "Filter A Region", 3, values, sheet.Condition{}, func(cr sheet.Criteria) { *got = cr })37+ p.OnCancel = func() { *cancelled = true }38+ p.Start()39+ return p40+}41+42+func newHost() *fakeHost {43+ loc, _ := locale.Lookup("en-US")44+ return &fakeHost{th: theme.New(true), loc: loc}45+}46+47+func keys(p *Picker, ks ...tea.KeyPressMsg) {48+ for _, k := range ks {49+ p.Key(k)50+ }51+}52+53+func text(p *Picker, s string) {54+ for _, r := range s {55+ p.Key(tea.KeyPressMsg{Code: r, Text: string(r)})56+ }57+}58+59+var (60+ down = tea.KeyPressMsg{Code: tea.KeyDown}61+ space = tea.KeyPressMsg{Code: tea.KeySpace}62+ tab = tea.KeyPressMsg{Code: tea.KeyTab}63+ enter = tea.KeyPressMsg{Code: tea.KeyEnter}64+ esc = tea.KeyPressMsg{Code: tea.KeyEscape}65+)66+67+func TestCheckAndApply(t *testing.T) {68+ h := newHost()69+ var cr sheet.Criteria70+ var cancelled bool71+ p := open(h, &cr, &cancelled)72+ out := ansi.Strip(strings.Join(p.Layout()[0].Lines, "\n"))73+ for _, want := range []string{"Filter A Region", "[ ] Select all", "[x] East", "[ ] (Blanks)", "3 of 3"} {74+ if !strings.Contains(out, want) {75+ t.Errorf("box lacks %q:\n%s", want, out)76+ }77+ }78+ keys(p, down, space) // uncheck East79+ text(p, "nor")80+ if out := ansi.Strip(strings.Join(p.Layout()[0].Lines, "\n")); !strings.Contains(out, "1 of 3") {81+ t.Errorf("search didn't narrow:\n%s", out)82+ }83+ keys(p, enter)84+ if h.closed != 1 || strings.Join(cr.Hidden, ",") != "East," || cr.Cond.Op != sheet.CondNone {85+ t.Fatalf("applied %+v, closed %d", cr, h.closed)86+ }87+}88+89+func TestConditionAndCancel(t *testing.T) {90+ h := newHost()91+ var cr sheet.Criteria92+ var cancelled bool93+ p := open(h, &cr, &cancelled)94+ keys(p, tab, down, down, down) // the condition: Text contains95+ text(p, "or")96+ if x, y := p.Cursor(); y != overlay.GridTop+1 || x < 3 {97+ t.Errorf("cursor %d, %d", x, y)98+ }99+ keys(p, enter)100+ if cr.Cond.Op != sheet.CondContains || cr.Cond.Arg != "or" {101+ t.Fatalf("condition %+v", cr.Cond)102+ }103+ p = open(h, &cr, &cancelled)104+ keys(p, esc)105+ if !cancelled {106+ t.Fatal("Esc didn't cancel")107+ }108+ cancelled = false109+ p = open(h, &cr, &cancelled)110+ p.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: 90, Y: 20})111+ if !cancelled {112+ t.Fatal("a click outside didn't cancel")113+ }114+}115+116+func TestAnswerInTheLocale(t *testing.T) {117+ h := newHost()118+ h.loc, _ = locale.Lookup("de-DE")119+ var cr sheet.Criteria120+ var cancelled bool121+ p := open(h, &cr, &cancelled)122+ if err := p.Answer(`{"hidden":["North"],"condition":"gt","value":"1.5"}`); err != nil {123+ t.Fatal(err)124+ }125+ if strings.Join(cr.Hidden, ",") != "North" || cr.Cond.Op != sheet.CondGreater || cr.Cond.Arg != "1.5" {126+ t.Fatalf("answered %+v", cr)127+ }128+ if err := open(h, &cr, &cancelled).Answer(`{"condition":"bigger"}`); err == nil || !strings.Contains(err.Error(), `"bigger"`) {129+ t.Fatalf("a bad condition: %v", err)130+ }131+}@@ -3,10 +3,12 @@ package ui
33 import (44 tea "charm.land/bubbletea/v2"55 6+ "github.com/FelineStateMachine/012/internal/locale"67 "github.com/FelineStateMachine/012/internal/sheet"78 "github.com/FelineStateMachine/012/internal/telemetry"89 "github.com/FelineStateMachine/012/internal/ui/choicebar"910 "github.com/FelineStateMachine/012/internal/ui/cmdline"11+ "github.com/FelineStateMachine/012/internal/ui/filterpick"1012 "github.com/FelineStateMachine/012/internal/ui/findbar"1113 "github.com/FelineStateMachine/012/internal/ui/lineedit"1214 "github.com/FelineStateMachine/012/internal/ui/picker"@@ -32,6 +34,7 @@ var (
3234 _ rules.Host = host{}3335 _ choicebar.Host = host{}3436 _ sortbar.Host = host{}37+ _ filterpick.Host = host{}3538 )3639 3740 // The in-package hosts the model implements itself.@@ -57,8 +60,8 @@ func (m *Model) syncChanged() { m.changed = m.sheet.StateID() != m.saved }
5760 // pickValues opens a filter's values list titled title at screen column5861 // x; apply gets the criteria chosen, and cancel runs after Esc.5962 func (m *Model) pickValues(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria), cancel func()) {60- fp := m.openValuesPicker(title, x, values, cond, func(_ *Model, cr sheet.Criteria) { apply(cr) })61- fp.onCancel = func(*Model) { cancel() }63+ fp := m.openValuesPicker(title, x, values, cond, apply)64+ fp.OnCancel = cancel6265 }6366 6467 // pointRange asks for a range on the context line; done gets it, and@@ -87,6 +90,7 @@ func (h host) Theme() *theme.Theme { return &h.m.th }
8790 func (h host) Size() (width, height int) { return h.m.width, h.m.height }8891 func (h host) Line() *lineedit.Line { return &h.m.line }8992 func (h host) Close() { h.m.closeOverlay() }93+func (h host) Locale() *locale.Locale { return h.m.locale() }9094 9195 // The picker.9296 @@ -3,6 +3,8 @@ package ui
33 import (44 "strings"55 "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"68 )79 810 // Dialogs recorded as the commands that open them, answered with their@@ -98,3 +100,48 @@ func TestScriptAnswersSortBar(t *testing.T) {
98100 t.Fatalf("warn %q overlay %T", m.warn, m.overlay)99101 }100102 }103+104+func TestRecordFilterPicker(t *testing.T) {105+ m := salesModel()106+ got := recordDo(t, m, "Filter", func() {107+ run(m, m.runCommand("data.filter"))108+ press(t, m, "<alt+down>", "<down>", "<down>", "<space>", "<enter>")109+ // The button of another column, with a condition.110+ press(t, m, "<right>")111+ click(m, m.filterButtonX(2), headerLine, 0)112+ press(t, m, "<tab>")113+ for range int(sheet.CondGreater) {114+ press(t, m, "<down>")115+ }116+ press(t, m, "3", "<enter>")117+ })118+ want := `run("data.filter")119+run("data.filter_column", answer={"column": "A", "hidden": ["North"]})120+select("B1")121+run("data.filter_column", answer={"column": "C", "condition": "gt", "value": "3"})`122+ if got != want {123+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)124+ }125+ if m.sheet.HiddenRows() != 2 {126+ t.Fatalf("hidden %d", m.sheet.HiddenRows())127+ }128+ replays(t, m, "Filter", salesModel)129+}130+131+func TestScriptAnswersFilterPicker(t *testing.T) {132+ m := salesModel()133+ script(t, m, `run("data.filter_column", answer={"column": "B", "hidden": ["40"]})`)134+ if !strings.Contains(m.warn, "the sheet has no filter") {135+ t.Fatalf("warn %q", m.warn)136+ }137+ script(t, m, `run("data.filter")138+run("data.filter_column", answer={"column": "D", "hidden": ["40"]})`)139+ if !strings.Contains(m.warn, "column D isn't in the filter's range A1:C5") || m.overlay != nil {140+ t.Fatalf("warn %q overlay %T", m.warn, m.overlay)141+ }142+ script(t, m, `select("B2")143+run("data.filter_column", answer={"hidden": ["40", "7"]})`)144+ if m.sheet.HiddenRows() != 2 || m.note != "Ran S" {145+ t.Fatalf("hidden %d note %q warn %q", m.sheet.HiddenRows(), m.note, m.warn)146+ }147+}@@ -40,12 +40,12 @@ func (m mode) String() string {
4040 // Layout: three control panel lines (menu bar, formula bar, context4141 // line), the column header, the grid, and a status line.4242 const (43- menuLine = 0 // menu bar on the left, mode indicator on the right44- formulaLine = 1 // name box, then the cell's contents or the entry45- contextLine = 2 // prompts, key hints and formula errors43+ menuLine = overlay.MenuLine // menu bar on the left, mode indicator on the right44+ formulaLine = 1 // name box, then the cell's contents or the entry45+ contextLine = overlay.ContextLine // prompts, key hints and formula errors4646 panelLines = 34747 headerLine = panelLines48- gridTop = panelLines + 148+ gridTop = overlay.GridTop4949 minRowHdrW = 6 // the row numbers up to 9999; see grid.hdrW5050 nameBoxW = 11 // fits most ranges, e.g. "AA100:AB200", without jumping5151 )@@ -18,6 +18,7 @@ import (
1818 const (1919 MenuLine = 0 // the menu bar; dropdowns and pickers open under it2020 ContextLine = 2 // prompts and bars; completions open under it21+ GridTop = 4 // the grid's first row, under the column header; boxes over the grid start here2122 )2223 2324 // SearchPrompt starts a search field, in pickers and bars.@@ -5,6 +5,7 @@ import (
55 "testing"66 77 "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/filterpick"89 )910 1011 // salesTable types a table of sales and leaves A1 active.@@ -155,7 +156,7 @@ func TestPivotFilterAndSource(t *testing.T) {
155156 press(t, m, "<down>", "<down>", "<space>", "units", "<enter>")156157 // Filters: add Region, then uncheck West in its values.157158 press(t, m, "<down>", "<space>", "reg", "<enter>", "<space>")158- if _, ok := m.overlay.(*filterPicker); !ok || !strings.Contains(screen(m), "Filter Region") {159+ if _, ok := m.overlay.(*filterpick.Picker); !ok || !strings.Contains(screen(m), "Filter Region") {159160 t.Fatalf("no values picker:\n%s", screen(m))160161 }161162 press(t, m, "<down>", "<down>", "<space>", "<enter>")