cat / 012
clone

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

Merge branch 'worktree-agent-a2b0e9a607742cefd'

Dami committed ec9c57c
commit
ec9c57c306f55a6312e3472fa555e26384349d99
parent
04282907989a6fb6b2ac98e6e73e810a60cef830
parent
6897a3197c717420b99563c718bc83273469aa32
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+}