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>
| M | docs/ | 7 | +++- |
| M | docs/ | 7 | +++- |
| M | internal/ | 36 | +++++++++++++++++-- |
| A | internal/ | 59 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 28 | ++++++++++++++- |
| M | internal/ | 33 | ++++++++++++++++- |
| M | internal/ | 58 | +++++++++++++++++++++++++++++ |
@@ -226,8 +226,11 @@ and its subtotal is just `Total`. Sorting an outer column field by a
226226 value's total orders whole outer groups, so their columns stay227227 together. Rows blank across the whole range are228228 left out, so a range can reach past the data. SUM, AVERAGE, MIN and MAX229-keep the column's number format; an error in a summed column shows as that230-error, as SUM would.229+keep the column's number format: the column's own when it has one, else230+the format most of its numbers show in (ties go to the first), so one231+price typed as `$9,000` among `$2.50`s doesn't turn every total into232+whole dollars. A group's label shows in the format of its first cell. An233+error in a summed column shows as that error, as SUM would.231234 232235 The results are live: any change to the data, typed, pasted, filled or233236 computed by a formula, recomputes the pivot, and rows or columns inserted@@ -24,8 +24,11 @@ theme = light:Catppuccin Latte,dark:Catppuccin Mocha # follow the terminal
2424 and switches when the terminal does (for example with the system's2525 appearance, in terminals that report it).2626 27-File > Settings > Theme opens a picker: type to search the names, or28-"dark" or "light"; the highlighted theme is drawn live, Enter keeps it and27+File > Settings > Theme opens a picker: type to search the names. A28+search starting with "dark" or "light" lists only the schemes of that29+kind (by `meta.isDark`, so "light" leaves out dark schemes such as Bright30+Lights), the rest of it searching their names: "light sol" finds31+Solarized Light. The highlighted theme is drawn live, Enter keeps it and2932 writes `theme = ...` to the config file, and Esc goes back to the theme you3033 had. With a `light:`/`dark:` pair, the picker changes the one in use.3134 `012 config themes` lists every theme, with a `*` on the current one.@@ -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@@ -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+}@@ -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@@ -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 {@@ -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+}