cat / 012
clone

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

Pivot values take the column's common format; theme search by dark or light filters by kind

Dami committed 6897a31
A pivot's sums, averages and extremes took the number format of the
column's first filled cell, so one price typed as $9,000 turned every
total into whole dollars. They now take the column's own format when it
has one, else the format most of its numbers show in, ties to the first.

In the theme picker, a search starting with "dark" or "light" lists
only schemes of that kind by meta.isDark and matches the rest against
names, so "light" no longer lists dark schemes like Bright Lights. The
generic picker gains a Narrow hook for this.
commit
6897a3197c717420b99563c718bc83273469aa32
parent
943b6ef938e58f12f04a12ca15a9114ca19a424b
author
Dami <dami.etoile@gmail.com>
7 files changed, +219 -9
Mdocs/data.md7+++-
Mdocs/themes.md7+++-
Minternal/sheet/pivotcalc.go36+++++++++++++++++--
Ainternal/sheet/pivotformat_test.go59++++++++++++++++++++++++++++++
Minternal/ui/picker/picker.go28++++++++++++++-
Minternal/ui/themepicker/themepicker.go33++++++++++++++++-
Minternal/ui/themepicker/themepicker_test.go58+++++++++++++++++++++++++++++
M docs/data.md +5 -2
M docs/themes.md +5 -2
M internal/sheet/pivotcalc.go +33 -3
@@ -187,10 +187,40 @@ func (c *pivotCalc) valueFormat(v PivotValue) Format {
187187 if !summaries[v.Summarize].keepsFormat {188188 return Format{}189189 }190- if row, ok := c.src.cells.filled.nextRow(v.Col, c.p.Range.From.Row+1, 1); ok && row <= c.p.Range.To.Row {191- return c.src.DisplayFormat(Addr{Col: v.Col, Row: row})190+ return c.columnFormat(v.Col)191+}192+193+// columnFormat is the number format of column col's data in the source194+// range: the column's own when it has one, else the one most of its195+// numbers show in, ties going to the one met first. So one cell typed196+// differently ("$9,000" among "$2.50"s) doesn't restyle every result,197+// as in Sheets.198+func (c *pivotCalc) columnFormat(col int) Format {199+ if f := c.src.lines.cols[col].Format; !f.IsZero() {200+ return f201+ }202+ counts := map[Format]int{}203+ var seen []Format // in the order first met204+ r := c.p.Range205+ for row, ok := c.src.cells.filled.nextRow(col, r.From.Row+1, 1); ok && row <= r.To.Row; row, ok = c.src.cells.filled.nextRow(col, row+1, 1) {206+ a := Addr{Col: col, Row: row}207+ if c.src.Value(a).Kind != Number {208+ continue209+ }210+ f := c.src.DisplayFormat(a)211+ if counts[f] == 0 {212+ seen = append(seen, f)213+ }214+ counts[f]++215+ }216+ var best Format217+ most := 0218+ for _, f := range seen {219+ if counts[f] > most {220+ best, most = f, counts[f]221+ }192222 }193- return Format{}223+ return best194224 }195225 196226 // gather reads the source rows the filters let through, skipping rows
A internal/sheet/pivotformat_test.go +59 -0
@@ -0,0 +1,59 @@
1+package sheet2+3+import "testing"4+5+// One price typed as "$9,000" among "$x.xx" prices keeps the results in6+// the column's two-decimal currency, as in Sheets: the pivot takes the7+// format most of the column's numbers show in, not the first row's.8+func TestPivotValueFormatIgnoresOutlier(t *testing.T) {9+ w := sales(t)10+ src := w.Lookup("Sales")11+ if err := src.Set(at("E2"), "$9,000"); err != nil {12+ t.Fatal(err)13+ }14+ s := newPivot(t, w, func(p *Pivot) {15+ p.Rows = []PivotGroup{{Col: 1}}16+ p.Values = []PivotValue{{Col: 4, Summarize: SumBy}}17+ })18+ checkGrid(t, s,19+ "Product|SUM of Price",20+ "Ink|$18.00",21+ "Pens|$9,005.25",22+ "Grand Total|$9,023.25")23+24+ // A frequency table counts, so its values show no currency at all;25+ // each label keeps its own cell's format.26+ r, _ := src.UsedRange()27+ f, err := w.CreatePivot(src, r, "Frequency of Price", FrequencyPivot(src, r, 4))28+ if err != nil {29+ t.Fatal(err)30+ }31+ checkGrid(t, f,32+ "Price|Count|Percent",33+ "$9.00|2|40.00%",34+ "$2.50|1|20.00%",35+ "$2.75|1|20.00%",36+ "$9,000|1|20.00%",37+ "Grand Total|5|100.00%")38+}39+40+// The column's own format wins over its cells'; without one, a tie goes41+// to the format met first.42+func TestPivotValueFormatColumnAndTies(t *testing.T) {43+ w := bookOf(t, page{"Sales", map[string]string{44+ "A1": "K", "B1": "N",45+ "A2": "a", "B2": "$1",46+ "A3": "a", "B3": "2.5%",47+ "A4": "a", "B4": "$3",48+ "A5": "a", "B5": "4%",49+ "A6": "a", "B6": "x",50+ }})51+ s := newPivot(t, w, func(p *Pivot) {52+ p.Rows = []PivotGroup{{Col: 0}}53+ p.Values = []PivotValue{{Col: 1, Summarize: MaxBy}}54+ })55+ checkGrid(t, s, "K|MAX of N", "a|$3", "Grand Total|$3")56+57+ w.Lookup("Sales").SetFormat(col(1), Preset(FmtNumber))58+ checkGrid(t, s, "K|MAX of N", "a|3.00", "Grand Total|3.00")59+}
M internal/ui/picker/picker.go +27 -1
@@ -71,6 +71,12 @@ type Picker struct {
7171 Action string7272 Items []Item7373 74+ // Narrow, when set, sees the search first and can narrow the items by75+ // it, as "dark" does the themes: it returns the rest of the search,76+ // matched against the kept items' titles only, and which items to77+ // keep, or a nil keep to search every item as usual.78+ Narrow func(query string) (rest string, keep func(*Item) bool)79+7480 // Enter, when set, gets the first look at Enter with the search text,7581 // e.g. to take a typed path; it reports whether it handled it.7682 Enter func(query string) (tea.Cmd, bool)
@@ -120,12 +126,26 @@ func (p *Picker) Changed() {
120126 p.Sel, p.Top = 0, 0121127 p.shown = p.shown[:0]122128 q := strings.TrimSpace(p.h.Line().Text())129+ var keep func(*Item) bool130+ if p.Narrow != nil {131+ if rest, k := p.Narrow(q); k != nil {132+ q, keep = strings.TrimSpace(rest), k133+ }134+ }123135 if q == "" {124136 for i := range p.Items {125- p.shown = append(p.shown, Match{Item: &p.Items[i]})137+ if keep == nil || keep(&p.Items[i]) {138+ p.shown = append(p.shown, Match{Item: &p.Items[i]})139+ }126140 }127141 return128142 }143+ p.rank(q, keep)144+}145+146+// rank shows the items matching q, best first: of those keep keeps,147+// matching only their titles, when it is set.148+func (p *Picker) rank(q string, keep func(*Item) bool) {129149 // Rank title and path matches together by fuzzy score, so a tight130150 // match like "file" on the File menu beats letters scattered across a131151 // title. Title matches get a small bonus and win ties. Titles with a
@@ -144,8 +164,14 @@ func (p *Picker) Changed() {
144164 }145165 best := map[int]*ranked{}146166 for _, mt := range fuzzy.FindNoSort(q, names) {167+ if keep != nil && !keep(&p.Items[mt.Index]) {168+ continue169+ }147170 best[mt.Index] = &ranked{Match{Item: &p.Items[mt.Index], InTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}148171 }172+ if keep != nil {173+ hay = nil // titles only174+ }149175 for _, mt := range fuzzy.FindNoSort(q, hay) {150176 if r, ok := best[mt.Index]; ok && r.score >= mt.Score {151177 continue
M internal/ui/themepicker/themepicker.go +32 -1
@@ -1,5 +1,6 @@
11 // Package themepicker is File > Settings > Theme: every theme in a2-// picker, fuzzy searched by name, "dark" or "light". The highlighted theme2+// picker, fuzzy searched by name, narrowed to one kind by a search3+// starting with "dark" or "light". The highlighted theme34 // is drawn live; Enter keeps it, Esc goes back to the theme there was. It45 // knows the UI only through Host, which draws and saves themes.56 package themepicker
@@ -70,11 +71,41 @@ func New(h Host) *Picker {
7071 }7172 tp.Picker = picker.New(closing{h, tp}, "Theme", "Type a name, dark or light", 64, items)7273 tp.Action = "keep"74+ tp.Narrow = byKind(entries)7375 tp.Sel = sel7476 tp.preview()7577 return tp7678 }7779 80+// byKind narrows a search starting with the word "dark" or "light" to81+// the schemes of that kind (by their meta.isDark), the rest of it then82+// matching their names. So "light" lists light schemes, not dark ones83+// named like "Bright Lights", and "light sol" finds Solarized Light.84+// The terminal's theme is neither kind.85+func byKind(entries []theme.Entry) func(string) (string, func(*picker.Item) bool) {86+ dark := make(map[string]bool, len(entries))87+ for _, e := range entries {88+ if e.Name != theme.Terminal {89+ dark[e.Name] = e.Dark90+ }91+ }92+ return func(q string) (string, func(*picker.Item) bool) {93+ word, rest, _ := strings.Cut(q, " ")94+ var want bool95+ switch strings.ToLower(word) {96+ case "dark":97+ want = true98+ case "light":99+ default:100+ return q, nil101+ }102+ return rest, func(it *picker.Item) bool {103+ d, ok := dark[it.Title]104+ return ok && d == want105+ }106+ }107+}108+78109 // closing is the host as the inner picker sees it: closing it closes the79110 // theme picker.80111 type closing struct {
M internal/ui/themepicker/themepicker_test.go +58 -0
@@ -1,6 +1,8 @@
11 package themepicker22 33 import (4+ "slices"5+ "strings"46 "testing"57 68 tea "charm.land/bubbletea/v2"
@@ -114,3 +116,59 @@ func TestSearchPreviewsTheBestMatch(t *testing.T) {
114116 t.Errorf("searching %q selected %q, drew %v", target, selected(p), h.previews)115117 }116118 }119+120+// search types s into the picker.121+func search(p *Picker, s string) {122+ for _, r := range s {123+ press(p, typed(string(r)))124+ }125+}126+127+// shownNames are the titles the picker lists.128+func shownNames(p *Picker) []string {129+ var out []string130+ for _, m := range p.Shown() {131+ out = append(out, m.Item.Title)132+ }133+ return out134+}135+136+func TestDarkOrLightFiltersByKind(t *testing.T) {137+ dark := map[string]bool{}138+ for _, e := range entries() {139+ dark[e.Name] = e.Dark140+ }141+ for _, q := range []string{"light", "Dark", "light sol", "dark cat"} {142+ p := New(newHost(name(0)))143+ search(p, q)144+ want := strings.EqualFold(q[:4], "dark")145+ got := shownNames(p)146+ if len(got) == 0 {147+ t.Errorf("%q lists nothing", q)148+ }149+ for _, n := range got {150+ if n == theme.Terminal || dark[n] != want {151+ t.Errorf("%q lists %q (dark %v)", q, n, dark[n])152+ }153+ }154+ }155+ p := New(newHost(name(0)))156+ search(p, "light")157+ if slices.Contains(shownNames(p), "Bright Lights") {158+ t.Error(`"light" lists the dark "Bright Lights"`)159+ }160+ if n := len(shownNames(p)); n >= len(entries())/2 {161+ t.Errorf(`"light" lists %d of %d themes`, n, len(entries()))162+ }163+ p = New(newHost(name(0)))164+ search(p, "light sol")165+ if !strings.Contains(selected(p), "Solarized Light") {166+ t.Errorf(`"light sol" selected %q`, selected(p))167+ }168+ // Other searches still match names fuzzily, whatever their kind.169+ p = New(newHost(name(0)))170+ search(p, "brightli")171+ if selected(p) != "Bright Lights" {172+ t.Errorf(`"brightli" selected %q`, selected(p))173+ }174+}