cat / 012
clone

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

Sort bar: package sortbar; macros record and replay its choices

Dami committed 6116df1
The bar moves to its own package behind a five-method host. Sorting from
it records run("data.sort_range", answer={...}) with the columns, their
order and the header row, and a command's new answer hook lets scripts
run a dialog's command with such an answer. run() takes dict and list
answers and hands them to the host as JSON; recordings write JSON
answers back as dicts.
commit
6116df1ec906fbbea5cb114c9af8a03a270fe00c
parent
dd5cb3abc4aef9480fdcddb1b297b075f4b47491
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.9 of 3, Jev's estimate
14 files changed, +825 -207
Ainternal/macro/answer.go162++++++++++++++++
Ainternal/macro/answer_test.go44++++
Minternal/macro/builtins.go10+-
Minternal/macro/macro.go11+-
Minternal/ui/commands.go6+
Minternal/ui/hosts.go2+
Ainternal/ui/macrodialog_test.go100++++++++++
Minternal/ui/macrohostnav.go23++-
Minternal/ui/macrorec.go12+
Minternal/ui/macroscript_test.go11+-
Minternal/ui/pivotactions.go3+-
Minternal/ui/sort.go216++-------------------
Ainternal/ui/sortbar/sortbar.go304++++++++++++++++++++++++++++++
Ainternal/ui/sortbar/sortbar_test.go128+++++++++++++
A internal/macro/answer.go +162 -0
@@ -0,0 +1,162 @@
1+package macro2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "io"8+ "strconv"9+ "strings"10+11+ "go.starlark.net/starlark"12+ "go.starlark.net/syntax"13+)14+15+// Structured answers. A dialog's choices (the columns of a sort, the16+// values a filter hides, a chart's options) don't fit one string, so a17+// recording writes them as a dict, run("data.sort_range", answer={"by":18+// [{"column": "B"}]}), and run hands a dict or list answer to the Host19+// as JSON text, keys in the order written.20+21+// JSON is JSON text an Action writes as a Starlark dict or list literal,22+// keys in the order they come.23+type JSON string24+25+// starlarkLiteral writes JSON text as a Starlark literal: objects as26+// dicts, arrays as lists, and null, true and false as None, True and27+// False.28+func starlarkLiteral(text string) (string, error) {29+ d := json.NewDecoder(strings.NewReader(text))30+ d.UseNumber()31+ var b strings.Builder32+ if err := writeLiteral(&b, d); err != nil {33+ return "", err34+ }35+ if _, err := d.Token(); err != io.EOF {36+ return "", fmt.Errorf("more than one value in %q", text)37+ }38+ return b.String(), nil39+}40+41+// writeLiteral writes the next JSON value of d.42+func writeLiteral(b *strings.Builder, d *json.Decoder) error {43+ t, err := d.Token()44+ if err != nil {45+ return err46+ }47+ switch t := t.(type) {48+ case json.Delim:49+ return writeContainer(b, d, t)50+ case string:51+ b.WriteString(syntax.Quote(t, false))52+ case json.Number:53+ b.WriteString(t.String())54+ case bool:55+ b.WriteString(map[bool]string{true: "True", false: "False"}[t])56+ case nil:57+ b.WriteString("None")58+ }59+ return nil60+}61+62+// writeContainer writes an object or array whose opening delimiter was63+// open.64+func writeContainer(b *strings.Builder, d *json.Decoder, open json.Delim) error {65+ obj := open == '{'66+ if obj {67+ b.WriteByte('{')68+ } else {69+ b.WriteByte('[')70+ }71+ for i := 0; d.More(); i++ {72+ if i > 0 {73+ b.WriteString(", ")74+ }75+ if obj {76+ k, err := d.Token()77+ if err != nil {78+ return err79+ }80+ b.WriteString(syntax.Quote(k.(string), false) + ": ")81+ }82+ if err := writeLiteral(b, d); err != nil {83+ return err84+ }85+ }86+ if _, err := d.Token(); err != nil { // the closing delimiter87+ return err88+ }89+ if obj {90+ b.WriteByte('}')91+ } else {92+ b.WriteByte(']')93+ }94+ return nil95+}96+97+// answerJSON writes a script's dict or list answer as JSON text, keys in98+// the order the dict holds them.99+func answerJSON(v starlark.Value) (string, error) {100+ var b bytes.Buffer101+ if err := writeJSON(&b, v); err != nil {102+ return "", err103+ }104+ return b.String(), nil105+}106+107+func writeJSON(b *bytes.Buffer, v starlark.Value) error {108+ switch v := v.(type) {109+ case starlark.NoneType:110+ b.WriteString("null")111+ case starlark.Bool:112+ b.WriteString(strconv.FormatBool(bool(v)))113+ case starlark.Int:114+ b.WriteString(v.String())115+ case starlark.Float:116+ raw, err := json.Marshal(float64(v))117+ if err != nil {118+ return fmt.Errorf("run: answer: %v", err)119+ }120+ b.Write(raw)121+ case starlark.String:122+ raw, _ := json.Marshal(string(v))123+ b.Write(raw)124+ case *starlark.Dict:125+ return writeDict(b, v)126+ case starlark.Indexable: // lists and tuples127+ b.WriteByte('[')128+ for i := range v.Len() {129+ if i > 0 {130+ b.WriteByte(',')131+ }132+ if err := writeJSON(b, v.Index(i)); err != nil {133+ return err134+ }135+ }136+ b.WriteByte(']')137+ default:138+ return fmt.Errorf("run: an answer can't hold a %s", v.Type())139+ }140+ return nil141+}142+143+func writeDict(b *bytes.Buffer, d *starlark.Dict) error {144+ b.WriteByte('{')145+ for i, kv := range d.Items() {146+ k, ok := kv[0].(starlark.String)147+ if !ok {148+ return fmt.Errorf("run: an answer's keys are strings, not %s", kv[0].Type())149+ }150+ if i > 0 {151+ b.WriteByte(',')152+ }153+ raw, _ := json.Marshal(string(k))154+ b.Write(raw)155+ b.WriteByte(':')156+ if err := writeJSON(b, kv[1]); err != nil {157+ return err158+ }159+ }160+ b.WriteByte('}')161+ return nil162+}
A internal/macro/answer_test.go +44 -0
@@ -0,0 +1,44 @@
1+package macro2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestJSONAnswersAreWrittenAsDicts(t *testing.T) {9+ a := Call("run", "data.sort_range").With("answer", JSON(`{"by":[{"column":"B","order":"desc"},{"column":"A"}],"header":true,"min":-1.5,"x":null,"q":"it's \"so\""}`))10+ want := `run("data.sort_range", answer={"by": [{"column": "B", "order": "desc"}, {"column": "A"}], "header": True, "min": -1.5, "x": None, "q": "it's \"so\""})`11+ if got := a.String(); got != want {12+ t.Fatalf("got %s\nwant %s", got, want)13+ }14+ // The script hands the answer back as the JSON it came from, keys in15+ // the order written.16+ f := newFake()17+ if _, err := New("m", Source("x", []Action{a}), Env{Host: f}).Exec(); err != nil {18+ t.Fatal(err)19+ }20+ wantCall := `run data.sort_range "{\"by\":[{\"column\":\"B\",\"order\":\"desc\"},{\"column\":\"A\"}],\"header\":true,\"min\":-1.5,\"x\":null,\"q\":\"it's \\\"so\\\"\"}"`21+ if len(f.calls) != 1 || f.calls[0] != wantCall {22+ t.Fatalf("calls %q\nwant %q", f.calls, wantCall)23+ }24+}25+26+func TestListAnswersAndBadOnes(t *testing.T) {27+ f := newFake()28+ if _, err := New("m", `run("x", answer=[1, (2, 3), {"k": [True]}])`, Env{Host: f}).Exec(); err != nil {29+ t.Fatal(err)30+ }31+ if f.calls[0] != `run x "[1,[2,3],{\"k\":[true]}]"` {32+ t.Fatalf("calls %q", f.calls)33+ }34+ for src, want := range map[string]string{35+ `run("x", answer={1: 2})`: "keys are strings, not int",36+ `run("x", answer={"f": print})`: "can't hold a builtin_function_or_method",37+ `run("x", answer=[float("nan")])`: "unsupported value",38+ } {39+ _, err := New("m", src, Env{Host: newFake()}).Exec()40+ if err == nil || !strings.Contains(err.Error(), want) {41+ t.Errorf("%s: %v, want %q", src, err, want)42+ }43+ }44+}
M internal/macro/builtins.go +9 -1
M internal/macro/macro.go +9 -2
M internal/ui/commands.go +6 -0
M internal/ui/hosts.go +2 -0
A internal/ui/macrodialog_test.go +100 -0
@@ -0,0 +1,100 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+)7+8+// Dialogs recorded as the commands that open them, answered with their9+// choices, and replayed from those answers.10+11+// salesModel is a table of sales with a header row, the fixture both a12+// recording and its replay start from.13+func salesModel() *Model {14+ m := newModel()15+ for i, row := range [][]string{16+ {"Region", "Units", "Price"},17+ {"North", "12", "3.5"},18+ {"South", "40", "2"},19+ {"East", "7", "9"},20+ {"West", "25", "4"},21+ } {22+ for j, v := range row {23+ m.sheet.Set(addr(string(rune('A'+j))+string(rune('1'+i))), v)24+ }25+ }26+ return m27+}28+29+// recordDo records what do does as a macro named name, from wherever m30+// is, and returns its script without the header.31+func recordDo(t *testing.T, m *Model, name string, do func()) string {32+ t.Helper()33+ send(m, nil)34+ run(m, m.runCommand("macro.record"))35+ do()36+ send(m, nil)37+ run(m, m.runCommand("macro.stop"))38+ m.line.Set(name)39+ press(t, m, "<enter>")40+ m.line.Set("")41+ press(t, m, "<enter>")42+ mc, ok := m.book().Macro(name)43+ if !ok {44+ t.Fatalf("no macro %s: %q", name, m.errMsg)45+ }46+ return body(mc.Source)47+}48+49+// replays runs macro name from the recording model on a fresh fixture50+// made by fresh, and checks it leaves the same workbook.51+func replays(t *testing.T, recorded *Model, name string, fresh func() *Model) *Model {52+ t.Helper()53+ r := fresh()54+ mc, _ := recorded.book().Macro(name)55+ if err := r.book().SaveMacro("", mc, "add"); err != nil {56+ t.Fatal(err)57+ }58+ r.trustHere()59+ mc, _ = r.book().Macro(name)60+ run(r, r.runMacro(mc))61+ if r.note != "Ran "+name {62+ t.Fatalf("running %s: note %q warn %q err %q", name, r.note, r.warn, r.errMsg)63+ }64+ if got, want := fileOf(t, r), fileOf(t, recorded); got != want {65+ t.Errorf("%s replayed differently:\n%s\nrecorded:\n%s", name, got, want)66+ }67+ return r68+}69+70+func TestRecordSortBar(t *testing.T) {71+ m := salesModel()72+ got := recordDo(t, m, "Sort", func() {73+ run(m, m.runCommand("data.sort_range"))74+ press(t, m, "<right>", "<space>", "<alt+a>", "<enter>")75+ })76+ want := `run("data.sort_range", answer={"by": [{"column": "B", "order": "desc"}, {"column": "A"}], "header": True})`77+ if got != want {78+ t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)79+ }80+ if input(m, "A2") != "South" {81+ t.Fatalf("not sorted: A2 %q", input(m, "A2"))82+ }83+ replays(t, m, "Sort", salesModel)84+}85+86+func TestScriptAnswersSortBar(t *testing.T) {87+ m := salesModel()88+ script(t, m, `run("data.sort_range", answer={"by": [{"column": "C"}], "header": False})`)89+ if input(m, "A1") != "South" || input(m, "A5") != "Region" {90+ t.Fatalf("sorted with the header: A1 %q A5 %q warn %q", input(m, "A1"), input(m, "A5"), m.warn)91+ }92+ if m.overlay != nil || m.mode != modeReady {93+ t.Fatalf("left mode %v, overlay %T", m.mode, m.overlay)94+ }95+ m = salesModel()96+ script(t, m, `run("data.sort_range", answer={"by": [{"column": "Z"}]})`)97+ if !strings.Contains(m.warn, `Sort range: column "Z" isn't in A1:C5`) || m.overlay != nil {98+ t.Fatalf("warn %q overlay %T", m.warn, m.overlay)99+ }100+}
M internal/ui/macrohostnav.go +22 -1
@@ -276,6 +276,8 @@ func (h scriptHost) Run(id string, answer *string) error {
276276 return fmt.Errorf("no command %q; the palette (Ctrl+K) lists them, and docs/reference/macro-api.md how to find ids", id)277277 case c.macro == macroNever:278278 return fmt.Errorf("%s (%s) can't run in a macro", c.title, id)279+ case c.answer != nil && answer != nil:280+ return h.answered(c, *answer)279281 case !c.available(m):280282 return fmt.Errorf("%s can't run now", c.title)281283 }
@@ -283,6 +285,21 @@ func (h scriptHost) Run(id string, answer *string) error {
283285 return h.answer(c, answer)284286 }285287 288+// answered runs a command that opens a dialog with the dialog's289+// choices, as a recording writes them.290+func (h scriptHost) answered(c *command, text string) error {291+ m := h.m292+ cmd, err := c.answer(m, text)293+ m.macros.cmds = append(m.macros.cmds, cmd)294+ if err != nil {295+ if m.overlay != nil {296+ m.closeOverlay()297+ }298+ return fmt.Errorf("%s: %w", c.title, err)299+ }300+ return h.answer(c, nil)301+}302+286303 // answer answers the question a command left open, or backs out of it287304 // and reports that it needed one.288305 func (h scriptHost) answer(c *command, answer *string) error {
@@ -321,7 +338,11 @@ func (h scriptHost) choose(c *command, answer *string) error {
321338 return h.pickAnswer(c, p, answer)322339 }323340 bar, ok := m.overlay.(*choicebar.Bar)324- if !ok {341+ switch {342+ case !ok && c.answer != nil:343+ m.closeOverlay()344+ return fmt.Errorf("%s opens a dialog: give run(%q, answer={...}) with its choices, as a recording writes them", c.title, c.id)345+ case !ok:325346 m.closeOverlay()326347 return fmt.Errorf("%s opens a dialog; it can't run in a macro", c.title)327348 }
M internal/ui/macrorec.go +12 -0
M internal/ui/macroscript_test.go +6 -5
M internal/ui/pivotactions.go +2 -1
M internal/ui/sort.go +19 -197
@@ -5,10 +5,9 @@ import (
55 "strings"66 77 tea "charm.land/bubbletea/v2"8- "github.com/charmbracelet/x/ansi"98 109 "github.com/FelineStateMachine/012/internal/sheet"11- "github.com/FelineStateMachine/012/internal/ui/overlay"10+ "github.com/FelineStateMachine/012/internal/ui/sortbar"1211 )1312 1413 // Sorting follows Sheets' Data menu: sort the sheet or a range by the
@@ -32,7 +31,8 @@ func init() {
3231 run: func(m *Model) tea.Cmd {3332 m.openSortBar()3433 return nil35- }},34+ },35+ answer: func(m *Model, text string) (tea.Cmd, error) { return m.openSortBar().Answer(text) }},3636 )3737 }3838
@@ -79,19 +79,12 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
7979 m.changed = true8080 var by []string8181 for _, k := range keys {82- by = append(by, sheet.ColName(k.Col)+" "+orderName(k.Desc))82+ by = append(by, sheet.ColName(k.Col)+" "+sortbar.OrderName(k.Desc))8383 }8484 m.note = "Sorted " + r.String() + " by " + strings.Join(by, ", then ")8585 return nil8686 }8787 88-func orderName(desc bool) string {89- if desc {90- return "Z→A"91- }92- return "A→Z"93-}94-9588 // headerRows is how many rows at the top of r are headers that sorting9689 // leaves in place: frozen rows, the filter's header row, and, for a range9790 // found around the active cell, a first row of text over numbers.
@@ -133,35 +126,19 @@ func (g *grid) looksLikeHeader(r sheet.Rect) bool {
133126 return text && numbers134127 }135128 136-// sortBar picks the columns and order to sort a range by.137-type sortBar struct {138- m *Model // the model it acts on139- rng sheet.Rect // the whole range, header rows included140- headers int // header rows at the top of rng; 0 when switched off141- guess int // header rows to use when switched on142- keys []sheet.SortKey143- cur int // the key being changed144-}145-146-func (m *Model) openSortBar() {129+// openSortBar opens the sort bar over the selection, or the data around130+// the active cell.131+func (m *Model) openSortBar() *sortbar.Bar {147132 r := m.dataRange()148- b := &sortBar{m: m, rng: r, headers: m.headerRows(r, m.hasRange())}149- b.guess = max(b.headers, 1)150- b.keys = []sheet.SortKey{{Col: clamp(m.cur.Col, r.From.Col, r.To.Col)}}133+ b := sortbar.New(m.host(), r, m.headerRows(r, m.hasRange()), m.cur.Col)151134 m.openOverlay(b)152-}153-154-// data is the range the sort moves: rng without its headers.155-func (b *sortBar) data() sheet.Rect {156- r := b.rng157- r.From.Row = min(r.From.Row+b.headers, r.To.Row)158- return r135+ return b159136 }160137 161138 // barRange is the range highlighted while a data bar is open.162139 func (m *Model) barRange() (sheet.Rect, bool) {163- if b, ok := m.overlay.(*sortBar); ok {164- return b.data(), true140+ if b, ok := m.overlay.(*sortbar.Bar); ok {141+ return b.Data(), true165142 }166143 return sheet.Rect{}, false167144 }
@@ -169,171 +146,16 @@ func (m *Model) barRange() (sheet.Rect, bool) {
169146 // barActive is the cell drawn as active while a data bar is open: the top170147 // of the focused sort column, so its header lights up.171148 func (m *Model) barActive() (sheet.Addr, bool) {172- if b, ok := m.overlay.(*sortBar); ok {173- return sheet.Addr{Col: b.keys[b.cur].Col, Row: b.data().From.Row}, true149+ if b, ok := m.overlay.(*sortbar.Bar); ok {150+ return b.Active(), true174151 }175152 return sheet.Addr{}, false176153 }177154 178-func (b *sortBar) Indicator() string { return "SORT" }179-func (b *sortBar) Layout() []overlay.Box { return nil }180-181-// setCol points the focused key at column c, if it's in the range.182-func (b *sortBar) setCol(c int) {183- if c >= b.rng.From.Col && c <= b.rng.To.Col {184- b.keys[b.cur].Col = c185- }186-}187-188-// add adds a sort column after the focused one: the next column not yet189-// sorted by.190-func (b *sortBar) add() {191- used := map[int]bool{}192- for _, k := range b.keys {193- used[k.Col] = true194- }195- for c := b.rng.From.Col; c <= b.rng.To.Col; c++ {196- if !used[c] {197- b.keys = append(b.keys[:b.cur+1], append([]sheet.SortKey{{Col: c}}, b.keys[b.cur+1:]...)...)198- b.cur++199- return200- }201- }202-}203-204-func (b *sortBar) Key(k tea.KeyPressMsg) tea.Cmd {205- m := b.m206- key := k.String()207- switch key {208- case "esc":209- m.closeOverlay()210- case "enter":211- keys, r := b.keys, b.data()212- m.closeOverlay()213- return m.sort(r, keys)214- case "left":215- b.setCol(b.keys[b.cur].Col - 1)216- case "right":217- b.setCol(b.keys[b.cur].Col + 1)218- case "up", "down", "space":219- b.keys[b.cur].Desc = !b.keys[b.cur].Desc220- case "tab":221- b.cur = (b.cur + 1) % len(b.keys)222- case "shift+tab":223- b.cur = (b.cur + len(b.keys) - 1) % len(b.keys)224- case "alt+a":225- b.add()226- case "alt+h":227- if b.headers > 0 {228- b.headers = 0229- } else {230- b.headers = min(b.guess, b.rng.To.Row-b.rng.From.Row)231- }232- case "backspace", "delete":233- if len(b.keys) > 1 {234- b.keys = append(b.keys[:b.cur], b.keys[b.cur+1:]...)235- b.cur = min(b.cur, len(b.keys)-1)236- }237- default:238- // Typing a column's letter sorts by it.239- if t := typed(k); len(t) == 1 {240- if c, ok := sheet.ParseCol(t); ok {241- b.setCol(c)242- }243- }244- }245- return nil246-}247-248-// sortPart is a piece of the bar: plain text, a sort key's chip, or the249-// header row toggle.250-type sortPart struct {251- text string252- key int // index of the sort key, -1 for others253- toggle bool254-}255-256-func (b *sortBar) parts(m *Model) []sortPart {257- parts := []sortPart{{text: m.th.Key.Render("Sort "+b.data().String()) + m.th.Muted.Render(" by "), key: -1}}258- for i, k := range b.keys {259- if i > 0 {260- parts = append(parts, sortPart{text: m.th.Muted.Render(" then "), key: -1})261- }262- label := sheet.ColName(k.Col)263- if b.headers > 0 {264- if h := m.sheet.ShownText(sheet.Addr{Col: k.Col, Row: b.rng.From.Row + b.headers - 1}); h != "" {265- label += " " + ansi.Truncate(h, 16, "…")266- }267- }268- style := m.th.KeyChip269- if i == b.cur {270- style = m.th.MenuSelected271- }272- parts = append(parts, sortPart{text: style.Render(" " + label + " " + orderName(k.Desc) + " "), key: i})273- }274- style := m.th.Muted275- if b.headers > 0 {276- style = m.th.MenuSelected277- }278- parts = append(parts, sortPart{text: " ", key: -1}, sortPart{text: style.Render(" Header row "), key: -1, toggle: true})279- return parts280-}281-282-// ContextLine renders the bar for the context line.283-func (b *sortBar) ContextLine() (string, string) {284- var s strings.Builder285- for _, p := range b.parts(b.m) {286- s.WriteString(p.text)287- }288- return s.String(), ""289-}290-291-// Mouse focuses a key's chip (a second click flips its order) or flips292-// the header row toggle; a click anywhere else cancels.293-func (b *sortBar) Mouse(e overlay.MouseEvent) tea.Cmd {294- m := b.m295- if e.Kind != overlay.MousePress {296- return nil297- }298- if e.Y != contextLine {299- m.closeOverlay()300- return nil301- }302- x := 0303- for _, p := range b.parts(m) {304- w := ansi.StringWidth(p.text)305- if e.X >= x && e.X < x+w {306- switch {307- case p.toggle:308- return b.Key(overlay.KeyFor("alt+h"))309- case p.key == b.cur:310- b.keys[b.cur].Desc = !b.keys[b.cur].Desc311- case p.key >= 0:312- b.cur = p.key313- }314- return nil315- }316- x += w317- }318- return nil319-}320-321-// Status shows the keys, the most useful ones first on narrow screens.322-func (b *sortBar) Status() (string, string) {323- m := b.m324- pairs := []string{"Left/Right", "column", "Space", "order", "Enter", "sort", "Esc", "cancel"}325- desc := "Alt+A add Alt+H header Tab next"326- for {327- keys := m.th.KeyHints(pairs...)328- switch {329- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:330- return m.th.Muted.Render(desc), keys331- case desc != "":332- desc = ""333- case len(pairs) > 4:334- pairs = pairs[2:]335- default:336- return "", keys337- }338- }155+// Sort sorts from the sort bar, which a macro records as the bar's156+// command answered with its choices.157+func (h host) Sort(data sheet.Rect, keys []sheet.SortKey, answer string) tea.Cmd {158+ cmd := h.m.sort(data, keys)159+ h.m.recordDialog("data.sort_range", answer)160+ return cmd339161 }
A internal/ui/sortbar/sortbar.go +304 -0
@@ -0,0 +1,304 @@
1+// Package sortbar is the bar that picks the columns and order to sort a2+// range by, as Sheets' "Advanced range sorting options" dialog, on the3+// context line. It shows each sort column as a chip; arrows change the4+// focused one, and the model highlights the range being sorted with the5+// sort column's header lit. It knows the UI only through Host.6+package sortbar7+8+import (9+ "encoding/json"10+ "errors"11+ "fmt"12+ "strings"13+14+ tea "charm.land/bubbletea/v2"15+ "github.com/charmbracelet/x/ansi"16+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 sort bar needs of the UI.24+type Host interface {25+ Theme() *theme.Theme26+ Size() (width, height int)27+ // Sheet is the sheet sorted, for the headers' text.28+ Sheet() *sheet.Sheet29+ // Close closes the open overlay.30+ Close()31+ // Sort sorts data by keys, after the bar has closed. answer is the32+ // bar's choices as a macro records them, the answer Answer takes.33+ Sort(data sheet.Rect, keys []sheet.SortKey, answer string) tea.Cmd34+}35+36+// Bar is the sort bar over a range.37+type Bar struct {38+ h Host39+ rng sheet.Rect // the whole range, header rows included40+ headers int // header rows at the top of rng; 0 when switched off41+ guess int // header rows to use when switched on42+ keys []sheet.SortKey43+ cur int // the key being changed44+}45+46+// New returns a bar sorting rng, whose first headers rows are headers,47+// by column col.48+func New(h Host, rng sheet.Rect, headers, col int) *Bar {49+ b := &Bar{h: h, rng: rng, headers: headers, guess: max(headers, 1)}50+ b.keys = []sheet.SortKey{{Col: min(max(col, rng.From.Col), rng.To.Col)}}51+ return b52+}53+54+// Data is the range the sort moves: the range without its headers.55+func (b *Bar) Data() sheet.Rect {56+ r := b.rng57+ r.From.Row = min(r.From.Row+b.headers, r.To.Row)58+ return r59+}60+61+// Active is the cell drawn as active while the bar is open: the top of62+// the focused sort column, so its header lights up.63+func (b *Bar) Active() sheet.Addr {64+ return sheet.Addr{Col: b.keys[b.cur].Col, Row: b.Data().From.Row}65+}66+67+func (b *Bar) Indicator() string { return "SORT" }68+func (b *Bar) Layout() []overlay.Box { return nil }69+70+// setCol points the focused key at column c, if it's in the range.71+func (b *Bar) setCol(c int) {72+ if c >= b.rng.From.Col && c <= b.rng.To.Col {73+ b.keys[b.cur].Col = c74+ }75+}76+77+// add adds a sort column after the focused one: the next column not yet78+// sorted by.79+func (b *Bar) add() {80+ used := map[int]bool{}81+ for _, k := range b.keys {82+ used[k.Col] = true83+ }84+ for c := b.rng.From.Col; c <= b.rng.To.Col; c++ {85+ if !used[c] {86+ b.keys = append(b.keys[:b.cur+1], append([]sheet.SortKey{{Col: c}}, b.keys[b.cur+1:]...)...)87+ b.cur++88+ return89+ }90+ }91+}92+93+// toggleHeader switches the header rows off, or on at the guess.94+func (b *Bar) toggleHeader() {95+ if b.headers > 0 {96+ b.headers = 097+ } else {98+ b.headers = min(b.guess, b.rng.To.Row-b.rng.From.Row)99+ }100+}101+102+// sort closes the bar and sorts.103+func (b *Bar) sort() tea.Cmd {104+ keys, r := b.keys, b.Data()105+ b.h.Close()106+ return b.h.Sort(r, keys, b.answer())107+}108+109+func (b *Bar) Key(k tea.KeyPressMsg) tea.Cmd {110+ switch k.String() {111+ case "esc":112+ b.h.Close()113+ case "enter":114+ return b.sort()115+ case "left":116+ b.setCol(b.keys[b.cur].Col - 1)117+ case "right":118+ b.setCol(b.keys[b.cur].Col + 1)119+ case "up", "down", "space":120+ b.keys[b.cur].Desc = !b.keys[b.cur].Desc121+ case "tab":122+ b.cur = (b.cur + 1) % len(b.keys)123+ case "shift+tab":124+ b.cur = (b.cur + len(b.keys) - 1) % len(b.keys)125+ case "alt+a":126+ b.add()127+ case "alt+h":128+ b.toggleHeader()129+ case "backspace", "delete":130+ if len(b.keys) > 1 {131+ b.keys = append(b.keys[:b.cur], b.keys[b.cur+1:]...)132+ b.cur = min(b.cur, len(b.keys)-1)133+ }134+ default:135+ // Typing a column's letter sorts by it.136+ if t := lineedit.Typed(k); len(t) == 1 {137+ if c, ok := sheet.ParseCol(t); ok {138+ b.setCol(c)139+ }140+ }141+ }142+ return nil143+}144+145+// OrderName is a sort order as the bar and notes show it.146+func OrderName(desc bool) string {147+ if desc {148+ return "Z→A"149+ }150+ return "A→Z"151+}152+153+// part is a piece of the bar: plain text, a sort key's chip, or the154+// header row toggle.155+type part struct {156+ text string157+ key int // index of the sort key, -1 for others158+ toggle bool159+}160+161+func (b *Bar) parts() []part {162+ th := b.h.Theme()163+ parts := []part{{text: th.Key.Render("Sort "+b.Data().String()) + th.Muted.Render(" by "), key: -1}}164+ for i, k := range b.keys {165+ if i > 0 {166+ parts = append(parts, part{text: th.Muted.Render(" then "), key: -1})167+ }168+ label := sheet.ColName(k.Col)169+ if b.headers > 0 {170+ if h := b.h.Sheet().ShownText(sheet.Addr{Col: k.Col, Row: b.rng.From.Row + b.headers - 1}); h != "" {171+ label += " " + ansi.Truncate(h, 16, "…")172+ }173+ }174+ style := th.KeyChip175+ if i == b.cur {176+ style = th.MenuSelected177+ }178+ parts = append(parts, part{text: style.Render(" " + label + " " + OrderName(k.Desc) + " "), key: i})179+ }180+ style := th.Muted181+ if b.headers > 0 {182+ style = th.MenuSelected183+ }184+ return append(parts, part{text: " ", key: -1}, part{text: style.Render(" Header row "), key: -1, toggle: true})185+}186+187+// ContextLine renders the bar for the context line.188+func (b *Bar) ContextLine() (string, string) {189+ var s strings.Builder190+ for _, p := range b.parts() {191+ s.WriteString(p.text)192+ }193+ return s.String(), ""194+}195+196+// Mouse focuses a key's chip (a second click flips its order) or flips197+// the header row toggle; a click anywhere else cancels.198+func (b *Bar) Mouse(e overlay.MouseEvent) tea.Cmd {199+ if e.Kind != overlay.MousePress {200+ return nil201+ }202+ if e.Y != overlay.ContextLine {203+ b.h.Close()204+ return nil205+ }206+ x := 0207+ for _, p := range b.parts() {208+ w := ansi.StringWidth(p.text)209+ if e.X >= x && e.X < x+w {210+ switch {211+ case p.toggle:212+ b.toggleHeader()213+ case p.key == b.cur:214+ b.keys[b.cur].Desc = !b.keys[b.cur].Desc215+ case p.key >= 0:216+ b.cur = p.key217+ }218+ return nil219+ }220+ x += w221+ }222+ return nil223+}224+225+// Status shows the keys, the most useful ones first on narrow screens.226+func (b *Bar) Status() (string, string) {227+ th := b.h.Theme()228+ width, _ := b.h.Size()229+ pairs := []string{"Left/Right", "column", "Space", "order", "Enter", "sort", "Esc", "cancel"}230+ desc := "Alt+A add Alt+H header Tab next"231+ for {232+ keys := th.KeyHints(pairs...)233+ switch {234+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:235+ return th.Muted.Render(desc), keys236+ case desc != "":237+ desc = ""238+ case len(pairs) > 4:239+ pairs = pairs[2:]240+ default:241+ return "", keys242+ }243+ }244+}245+246+// answerJSON is the bar's choices as a macro answers them:247+//248+// {"by": [{"column": "B"}, {"column": "C", "order": "desc"}], "header": true}249+//250+// the sort columns in order, as a pivot's rows are written, and whether251+// the first rows are headers left in place. Without "header", the bar's252+// own guess stands.253+type answerJSON struct {254+ By []answerKey `json:"by"`255+ Header *bool `json:"header,omitempty"`256+}257+258+type answerKey struct {259+ Column string `json:"column"`260+ Order string `json:"order,omitempty"` // "desc" for Z to A261+}262+263+// answer is the bar's choices as a macro records them.264+func (b *Bar) answer() string {265+ a := answerJSON{Header: new(bool)}266+ *a.Header = b.headers > 0267+ for _, k := range b.keys {268+ ak := answerKey{Column: sheet.ColName(k.Col)}269+ if k.Desc {270+ ak.Order = "desc"271+ }272+ a.By = append(a.By, ak)273+ }274+ raw, _ := json.Marshal(a)275+ return string(raw)276+}277+278+// Answer makes the choices a macro recorded (see answerJSON) and sorts,279+// as if they were picked and Enter pressed.280+func (b *Bar) Answer(text string) (tea.Cmd, error) {281+ var a answerJSON282+ if err := json.Unmarshal([]byte(text), &a); err != nil {283+ return nil, fmt.Errorf("the sort's answer: %w", err)284+ }285+ if len(a.By) == 0 {286+ return nil, errors.New(`the sort's answer names no columns: give "by"`)287+ }288+ var keys []sheet.SortKey289+ for _, k := range a.By {290+ c, ok := sheet.ParseCol(strings.ToUpper(k.Column))291+ if !ok || c < b.rng.From.Col || c > b.rng.To.Col {292+ return nil, fmt.Errorf("column %q isn't in %s", k.Column, b.rng)293+ }294+ if k.Order != "" && k.Order != "asc" && k.Order != "desc" {295+ return nil, fmt.Errorf("the order of %s is %q: asc or desc", k.Column, k.Order)296+ }297+ keys = append(keys, sheet.SortKey{Col: c, Desc: k.Order == "desc"})298+ }299+ b.keys, b.cur = keys, 0300+ if a.Header != nil && *a.Header != (b.headers > 0) {301+ b.toggleHeader()302+ }303+ return b.sort(), nil304+}
A internal/ui/sortbar/sortbar_test.go +128 -0
@@ -0,0 +1,128 @@
1+package sortbar2+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/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// fakeHost is a sheet with a header row, recording the sort asked for.16+type fakeHost struct {17+ th theme.Theme18+ s *sheet.Sheet19+ w int20+ closed int21+ data sheet.Rect22+ keys []sheet.SortKey23+ answer string24+}25+26+func newHost() *fakeHost {27+ s := sheet.New()28+ for i, row := range [][]string{{"Item", "Amount", "Qty"}, {"Rent", "1450", "1"}, {"Food", "300", "4"}} {29+ for j, v := range row {30+ s.Set(sheet.Addr{Col: j, Row: i}, v)31+ }32+ }33+ return &fakeHost{th: theme.New(true), s: s, w: 120}34+}35+36+func (h *fakeHost) Theme() *theme.Theme { return &h.th }37+func (h *fakeHost) Size() (int, int) { return h.w, 30 }38+func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }39+func (h *fakeHost) Close() { h.closed++ }40+func (h *fakeHost) Sort(data sheet.Rect, keys []sheet.SortKey, answer string) tea.Cmd {41+ h.data, h.keys, h.answer = data, keys, answer42+ return nil43+}44+45+var rng = sheet.Rect{To: sheet.Addr{Col: 2, Row: 2}}46+47+func press(b *Bar, keys ...tea.KeyPressMsg) {48+ for _, k := range keys {49+ b.Key(k)50+ }51+}52+53+var (54+ right = tea.KeyPressMsg{Code: tea.KeyRight}55+ space = tea.KeyPressMsg{Code: tea.KeySpace}56+ enter = tea.KeyPressMsg{Code: tea.KeyEnter}57+ altA = tea.KeyPressMsg{Code: 'a', Mod: tea.ModAlt}58+ altH = tea.KeyPressMsg{Code: 'h', Mod: tea.ModAlt}59+ letter = func(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Text: string(r)} }60+)61+62+func TestKeysPickColumnsAndOrder(t *testing.T) {63+ h := newHost()64+ b := New(h, rng, 1, 0)65+ if b.Data().String() != "A2:C3" || b.Active() != (sheet.Addr{Row: 1}) {66+ t.Fatalf("data %s, active %s", b.Data(), b.Active())67+ }68+ press(b, right, space, altA, letter('c'))69+ line, _ := b.ContextLine()70+ if plain := ansi.Strip(line); !strings.Contains(plain, "Sort A2:C3 by B Amount Z→A then C Qty A→Z") {71+ t.Fatalf("bar %q", plain)72+ }73+ press(b, enter)74+ if h.closed != 1 || h.data.String() != "A2:C3" || len(h.keys) != 2 || h.keys[0] != (sheet.SortKey{Col: 1, Desc: true}) || h.keys[1].Col != 2 {75+ t.Fatalf("sorted %s by %v, closed %d", h.data, h.keys, h.closed)76+ }77+ if want := `{"by":[{"column":"B","order":"desc"},{"column":"C"}],"header":true}`; h.answer != want {78+ t.Fatalf("answer %s\nwant %s", h.answer, want)79+ }80+}81+82+func TestHeaderToggle(t *testing.T) {83+ h := newHost()84+ b := New(h, rng, 1, 0)85+ press(b, altH)86+ if b.Data().String() != "A1:C3" {87+ t.Fatalf("without the header row the data is %s", b.Data())88+ }89+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: 0, Y: overlay.ContextLine + 1})90+ if h.closed != 1 {91+ t.Fatalf("a click off the bar should close it")92+ }93+}94+95+func TestAnswerSortsAsPicked(t *testing.T) {96+ h := newHost()97+ b := New(h, rng, 1, 0)98+ if _, err := b.Answer(`{"by":[{"column":"c","order":"desc"}],"header":false}`); err != nil {99+ t.Fatal(err)100+ }101+ if h.data.String() != "A1:C3" || len(h.keys) != 1 || h.keys[0] != (sheet.SortKey{Col: 2, Desc: true}) {102+ t.Fatalf("sorted %s by %v", h.data, h.keys)103+ }104+ for text, want := range map[string]string{105+ `{"by":[]}`: "names no columns",106+ `{"by":[{"column":"F"}]}`: `column "F" isn't in A1:C3`,107+ `{"by":[{"column":"A","order":"up"}]}`: `the order of A is "up"`,108+ `["A"]`: "the sort's answer",109+ } {110+ if _, err := New(h, rng, 1, 0).Answer(text); err == nil || !strings.Contains(err.Error(), want) {111+ t.Errorf("Answer(%s) = %v, want %q", text, err, want)112+ }113+ }114+}115+116+func TestStatusFitsNarrowScreens(t *testing.T) {117+ h := newHost()118+ b := New(h, rng, 1, 0)119+ desc, keys := b.Status()120+ if !strings.Contains(ansi.Strip(desc), "Alt+A add") || !strings.Contains(ansi.Strip(keys), "Esc") {121+ t.Fatalf("wide status %q %q", desc, keys)122+ }123+ h.w = 30124+ desc, keys = b.Status()125+ if ansi.Strip(desc) != "" || ansi.StringWidth(keys) > 30 {126+ t.Fatalf("narrow status %q %q", desc, keys)127+ }128+}