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