cat / 012
clone

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

Merge branch 'main' into worktree-agent-ae758ab3375f6ccdb

Dami committed d871e00
commit
d871e009c8f52f451e24f282ab071c496e667081
parent
0e5ea6caf5361d387cec1e8d404f2ae8219eb012
parent
159188b43ba045f1ccc17f58c9def66a145d1756
author
Dami <dami.etoile@gmail.com>
14 files changed, +404 -23
Mdocs/contributing/limits.md1+
Mdocs/nushell/notebooks.md8++
Me2e/screennotebook_test.go2+
Minternal/fileio/nuon.go3+-
Minternal/fileio/nuonrows.go34+++++++
Minternal/live/stream.go39+++++++-
Minternal/sheet/live.go10+-
Minternal/ui/nbgrid.go8++
Ainternal/ui/nbgridstream.go93+++++++++++++++++++
Ainternal/ui/nbgridstream_test.go146++++++++++++++++++++++++++++++
Minternal/ui/nbstream.go17+++-
Minternal/ui/nbview/grid.go37++++++++
Minternal/ui/nbview/output.go16++-
Minternal/ui/nbview/view_test.go13++-
M docs/contributing/limits.md +1 -0
@@ -41,6 +41,7 @@ it lags, and past a second it stalls.
4141 | Pivot tables | A pivot over 8191 rows recomputes after an edit to its source in 0.8 to 2.5 ms | | Results past the grid (the pivot shows #REF!) | Grouping the source rows |4242 | Linked sources | Ten million rows of Parquet or SQLite: opened in 0.4 ms or 0.65 s, a frame scrolling them 0.9 ms at 200 x 60, a page anywhere 0.2 to 12 ms; SUM of a column 0.42 s (Parquet) or 1.4 s (SQLite), COUNTIFS 1.2 or 2.3 s, a pivot table 2.5 or 3.9 s, each in a few MB | Sorting ten million rows: 3.5 s (Parquet) or 19 s (SQLite), in the background | Functions that hold what they read, over more than `max-cells` cells: `#VALUE!`, saying why | Reading the column from the file; sorting |4343 | Following files | A log growing by 10,000 rows a second, formulas over it: each update (about 2,500 rows, four times a second) applied in 7 ms keeping every row, 1.7 to 2 ms keeping the last 1000, 14 ms the last 10,000; frames 2.5 to 3.6 ms at 200 x 60 | | Rows past `max-cells`: left out, with a note | Writing the rows and recalculating what reads them; a window rewrites its rows on every update |44+| Notebook streams (`TestStreamGridAtFrameSpeed`) | 10,000 rows arriving in batches of 250, the output's grid on screen: 2.3 ms a batch through to its frame at 200 x 60 (3.6 ms at worst) | Past 20,000 rows in one run: the grid is read again from the 10,000 the output keeps, about 60 ms once every 10,000 rows | | Reading the batch's rows into the grid and measuring their text; the output's NUON copied once a batch |4445 | Arrays and spills | FILTER, SORT or UNIQUE over 8192 rows: about 0.1 ms each per edit; 1000 of them spilling 516 k cells, an edit recomputing 500: 52 ms | Full recalculation of those 1000: 106 ms | An array past `maxArray` values (2,097,152) stored: `#VALUE!`; a spill past the sheet's edge or `max-cells` cells: `#REF!` | Computing each array's values; writing only the spilled cells that changed |4546 4647 ## Sheet size
M docs/nushell/notebooks.md +8 -0
M e2e/screennotebook_test.go +2 -0
M internal/fileio/nuon.go +2 -1
M internal/fileio/nuonrows.go +34 -0
@@ -3,6 +3,8 @@ package fileio
33 import (44 "bytes"55 "context"6+ "errors"7+ "io"68 "strings"79 810 "github.com/FelineStateMachine/012/internal/nuon"
@@ -33,3 +35,35 @@ func NUONRows(ctx context.Context, data []byte, maxCells int) (TailRows, string,
3335 // value's own, and its format (a file size's Size, a date's Date time,3436 // in the local time zone), as importing it would store it.3537 func NUONCell(v nuon.Value) sheet.LiveCell { return nuonCell(v, zone()) }38+39+// AppendTable reads data, a NUON table, into s as more rows of the table40+// importing a table put there: under its row last, each value in the41+// column its header row (row 0, cols columns) names, a column named for42+// the first time added after the others with its name in the header,43+// the cells as importing stores them. It leaves the widths alone and44+// recalculates nothing, the cells being values, and returns the rows45+// and the columns the table has now.46+func AppendTable(ctx context.Context, s *sheet.Sheet, last, cols int, data []byte) (rows, allCols int, err error) {47+ b := &builder{s: s, maxCells: -1}48+ t := tableCells{b: b, cols: make(map[string]int, cols), zone: zone(), dated: map[int]bool{}, fixed: true}49+ for c := range cols {50+ t.cols[s.ShownText(sheet.Addr{Col: c})] = c51+ }52+ r := nuon.NewReader(bytes.NewReader(data))53+ for ; ; rows++ {54+ if rows%256 == 0 {55+ if err := ctx.Err(); err != nil {56+ return rows, len(t.cols), err57+ }58+ }59+ fields, err := r.Next()60+ if errors.Is(err, io.EOF) {61+ return rows, len(t.cols), nil62+ }63+ if err != nil {64+ return rows, len(t.cols), err65+ }66+ t.header(r.Header())67+ t.row(last+rows+1, fields)68+ }69+}
M internal/live/stream.go +34 -5
@@ -35,6 +35,7 @@ type Stream struct {
3535 partial []byte // a line not yet ended3636 tail *fileio.Tail3737 values [][]byte38+ printed int // values printed in all; guarded by mu3839 header sheet.LiveRow3940 rows []sheet.LiveRow4041 total int // rows printed in all; guarded by mu
@@ -145,9 +146,6 @@ func (s *Stream) read(got []byte, ended bool, u *Update) {
145146 if len(lines) == 0 {146147 return147148 }148- s.mu.Lock()149- s.values = keepLast(append(s.values, lines...), s.Keep)150- s.mu.Unlock()151149 res, err := s.tail.Feed(append(bytes.Join(lines, []byte("\n")), '\n'))152150 if err != nil {153151 u.Err = err.Error()
@@ -156,7 +154,9 @@ func (s *Stream) read(got []byte, ended bool, u *Update) {
156154 s.header = res.Header157155 u.Header = res.Header158156 }159- s.mu.Lock()157+ s.mu.Lock() // the values and the counts change together (Snapshot)158+ s.values = keepLast(append(s.values, lines...), s.Keep)159+ s.printed += len(lines)160160 s.total += len(res.Rows)161161 s.mu.Unlock()162162 s.rows = keepLast(append(s.rows, res.Rows...), s.Keep)
@@ -210,5 +210,34 @@ func (s *Stream) Rows() int {
210210 func (s *Stream) NUON() []byte {211211 s.mu.Lock()212212 defer s.mu.Unlock()213- return append(append([]byte("["), bytes.Join(s.values, []byte(", "))...), ']')213+ return list(s.values)214+}215+216+// list is values as a NUON list.217+func list(values [][]byte) []byte {218+ return append(append([]byte("["), bytes.Join(values, []byte(", "))...), ']')219+}220+221+// Snapshot is what a stream has printed, read at one moment.222+type Snapshot struct {223+ NUON []byte // the values kept, as NUON gives them224+ // More are the values printed after the first seen (Snapshot's225+ // argument), as a NUON list, so what shows the output can add them226+ // to what it shows rather than read it all again; nil when some of227+ // them are kept no more.228+ More []byte229+ // Values and Rows are how many values and rows have been printed.230+ Values, Rows int231+}232+233+// Snapshot is what the stream has printed so far, and what of it came234+// after the first seen values.235+func (s *Stream) Snapshot(seen int) Snapshot {236+ s.mu.Lock()237+ defer s.mu.Unlock()238+ snap := Snapshot{NUON: list(s.values), Values: s.printed, Rows: s.total}239+ if n := s.printed - seen; n >= 0 && n <= len(s.values) {240+ snap.More = list(s.values[len(s.values)-n:])241+ }242+ return snap214243 }
M internal/sheet/live.go +7 -3
M internal/ui/nbgrid.go +8 -0
A internal/ui/nbgridstream.go +93 -0
@@ -0,0 +1,93 @@
1+package ui2+3+import (4+ "context"5+6+ "github.com/FelineStateMachine/012/internal/fileio"7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// A cell run as a stream prints rows until it's stopped, and its output11+// grows a few times a second. Its grid takes the rows that arrived12+// under those it has (nbview.View.Carry) rather than being made again,13+// so what the reader has done in it stays: the pointer, the selection,14+// the rows as sorted (rows arriving go under them) and the filter, which15+// covers the new rows when it covered the table to its end. Columns16+// widen to fit the new rows' text as fitting the table fits it, never17+// narrowing, and not while the grid has the keys, so the table doesn't18+// shift under the pointer: they widen once it's left. A column the19+// reader resized keeps its width.20+21+// Append adds more, a NUON table of n rows, under the grid's rows22+// (nbview.Grid); data is the whole output, which a grid not made yet23+// is made from.24+func (g *outGrid) Append(more []byte, n int, data []byte) {25+ g.data = data26+ if g.child == nil {27+ g.rows += n28+ return29+ }30+ s := g.child.sheet31+ from, cols := g.rows, g.cols32+ rows, all, err := fileio.AppendTable(context.Background(), s, from, cols, more)33+ if err != nil {34+ g.child.note = "Rows the stream printed aren't a table: " + err.Error()35+ }36+ g.rows, g.cols = from+rows, max(all, cols)37+ g.followFilter(from, cols)38+ g.measure(from, cols)39+ g.widen()40+}41+42+// followFilter has the child's filter cover the rows and columns that43+// arrived when it covered the table's last row, of from, and its last44+// column, of cols, and applies it to them.45+func (g *outGrid) followFilter(from, cols int) {46+ s := g.child.sheet47+ f := s.Filter()48+ if f == nil {49+ return50+ }51+ if f.Range.To.Row >= from {52+ f.Range.To.Row = max(f.Range.To.Row, g.rows)53+ }54+ if f.Range.To.Col >= cols-1 {55+ f.Range.To.Col = max(f.Range.To.Col, g.cols-1)56+ }57+ s.LoadFilter(f)58+}59+60+// measure widens need to the text of the rows after row from, and of61+// the header of the columns after the first cols.62+func (g *outGrid) measure(from, cols int) {63+ s := g.child.sheet64+ for c := len(g.fit); c < g.cols; c++ {65+ g.fit, g.need = append(g.fit, s.ColWidth(c)), append(g.need, 0)66+ }67+ for c := range g.cols {68+ first := from + 169+ if c >= cols {70+ first = 071+ }72+ w := g.need[c]73+ for r := first; r <= g.rows; r++ {74+ w = max(w, sheet.FitWidth(s.ShownText(sheet.Addr{Col: c, Row: r})))75+ }76+ g.need[c] = w77+ }78+}79+80+// widen widens the columns to what their text needs, unless the grid81+// has the keys: a column keeps a width the reader gave it.82+func (g *outGrid) widen() {83+ if g.Entered() {84+ return85+ }86+ s := g.child.sheet87+ for c, w := range g.need {88+ if now := s.ColWidth(c); now == g.fit[c] && w > now {89+ s.LoadColWidth(c, w)90+ g.fit[c] = s.ColWidth(c)91+ }92+ }93+}
A internal/ui/nbgridstream_test.go +146 -0
@@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "strings"8+ "testing"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/live"14+ "github.com/FelineStateMachine/012/internal/notebook"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// streamed is a notebook of one cell run as a stream, its output19+// selected, and a function printing lines as the stream's pipeline and20+// showing them as the output, as a poll and the next update of the21+// output do.22+func streamed(t *testing.T) (*Model, func(lines ...string)) {23+ t.Helper()24+ m := sized(sheet.New(), 100, 30)25+ m.OpenNotebook()26+ id := m.book().NewCellID()27+ m.sheet.SetNotebookCells("cells", []notebook.Cell{{ID: id, Source: "log = tail -f app.log | lines | parse '{level} {msg}'"}})28+ printed := make(chan string)29+ src := live.NewStream(func(ctx context.Context, w io.Writer) error {30+ for {31+ select {32+ case <-ctx.Done():33+ return ctx.Err()34+ case l := <-printed:35+ io.WriteString(w, l+"\n")36+ }37+ }38+ })39+ src.Wait = 5 * time.Second40+ t.Cleanup(src.Close)41+ st := &nbStream{nbRun: &nbRun{nbQueued: nbQueued{m.sheet, id}, start: time.Now()}, src: src, count: 1}42+ m.nb.runs.streams = map[int]*nbStream{id: st}43+ m.showStream(st)44+ return m, func(lines ...string) {45+ t.Helper()46+ printed <- strings.Join(lines, "\n")47+ if _, ok := src.Poll(context.Background()); !ok {48+ t.Fatal("the poll found nothing")49+ }50+ m.showStream(st)51+ if m.nbView().SelectedGrid() == nil {52+ m.nbView().Select(0, true)53+ }54+ screen(m)55+ }56+}57+58+// selectedGrid is the selected output's grid.59+func selectedGrid(t *testing.T, m *Model) *outGrid {60+ t.Helper()61+ g, ok := m.nbView().SelectedGrid().(*outGrid)62+ if !ok || g.child == nil {63+ t.Fatalf("no grid:\n%s", screen(m))64+ }65+ return g66+}67+68+// A stream's grid takes the rows as they arrive, its columns widening69+// to fit a longer row, but not while the reader is in it; the pointer70+// and the filter stay.71+func TestStreamGridWidens(t *testing.T) {72+ m, feed := streamed(t)73+ feed(`{level: info, msg: started}`, `{level: info, msg: "GET /"}`)74+ g := selectedGrid(t, m)75+ msg := g.child.sheet.ColWidth(1)76+ if msg != sheet.DefaultWidth || g.Rows() != 2 {77+ t.Fatalf("msg %d wide, %d rows", msg, g.Rows())78+ }79+ long := "slow query on the orders table, 812ms"80+ feed(`{level: warn, msg: "` + long[:20] + `"}`)81+ if selectedGrid(t, m) != g || g.Rows() != 3 {82+ t.Fatalf("the grid was made again, or has %d rows", g.Rows())83+ }84+ if got := g.child.sheet.ColWidth(1); got != sheet.FitWidth(long[:20]) {85+ t.Errorf("msg is %d wide, want %d", got, sheet.FitWidth(long[:20]))86+ }87+ if s := screen(m); !strings.Contains(s, long[:20]) || !strings.Contains(s, "● live, 3 rows") {88+ t.Errorf("the new row:\n%s", s)89+ }90+91+ // In the grid, a filter hiding warnings, the pointer on row 2.92+ press(t, m, "<enter>", "<down>")93+ g.child.sheet.CreateFilter(g.table())94+ g.child.sheet.FilterColumn(0, sheet.Criteria{Hidden: []string{"warn"}})95+ feed(`{level: info, msg: "`+long+`"}`, `{level: warn, msg: hidden}`)96+ if got := g.child.sheet.ColWidth(1); got != sheet.FitWidth(long[:20]) {97+ t.Errorf("msg widened to %d under the pointer", got)98+ }99+ if g.child.cur != addr("A3") || g.Rows() != 3 || !g.child.sheet.RowHidden(5) {100+ t.Errorf("pointer at %s, %d rows showing, row 5 hidden %v", g.child.cur, g.Rows(), g.child.sheet.RowHidden(5))101+ }102+ press(t, m, "<esc>")103+ screen(m) // drawn once left104+ if got := g.child.sheet.ColWidth(1); got != sheet.FitWidth(long) {105+ t.Errorf("left, msg is %d wide, want %d", got, sheet.FitWidth(long))106+ }107+108+ // A column the reader resized keeps its width.109+ g.child.sheet.SetColWidth(0, 6)110+ feed(`{level: "a level wider than six", msg: x}`)111+ if got := g.child.sheet.ColWidth(0); got != 6 {112+ t.Errorf("level is %d wide, resized to 6", got)113+ }114+}115+116+// A stream printing 10,000 rows in batches of 250, a frame drawn after117+// each as the output updates, costs each batch the rows it brings, well118+// within a frame, rather than the rows so far.119+func TestStreamGridAtFrameSpeed(t *testing.T) {120+ if testing.Short() {121+ t.Skip("streams 10,000 rows")122+ }123+ m, feed := streamed(t)124+ term := newFakeTerm(200, 60)125+ m.Update(tea.WindowSizeMsg{Width: 200, Height: 60})126+ batch := make([]string, 250)127+ var worst, all time.Duration128+ for b := range 40 {129+ for i := range batch {130+ n := b*len(batch) + i131+ batch[i] = fmt.Sprintf(`{time: "09:%02d:%02d", level: info, msg: "GET /orders/%d 200"}`, n/60%60, n%60, n)132+ }133+ start := time.Now()134+ feed(batch...)135+ term.frame(m)136+ d := time.Since(start)137+ worst, all = max(worst, d), all+d138+ }139+ if g := selectedGrid(t, m); g.Rows() != 10000 {140+ t.Fatalf("%d rows", g.Rows())141+ }142+ t.Logf("a batch of 250 rows through to its frame: %s on average, %s at worst", all/40, worst)143+ if per := all / 40; per > 8*time.Millisecond {144+ t.Errorf("a batch takes %s", per)145+ }146+}
M internal/ui/nbstream.go +13 -4
@@ -30,7 +30,9 @@ type nbStream struct {
3030 *nbRun3131 src *live.Stream3232 count int // the run's number, [n]33- rows int // rows printed so far33+ // rows and values are how many rows and values the output has had34+ // so far, as it last showed them.35+ rows, values int3436 // shown is when the output was last updated, so a fast stream3537 // updates it a few times a second rather than on every poll, and3638 // behind is set when rows came since.
@@ -124,7 +126,6 @@ func (m *Model) streamPolled(msg nbStreamMsg) tea.Cmd {
124126 m.stopStream(st.id)125127 return nil126128 }127- st.rows = st.src.Rows()128129 st.behind = st.behind || msg.ok129130 if msg.ok {130131 if _, r, ok := m.book().Region(c.Name()); ok && r.Output {
@@ -145,11 +146,19 @@ func (m *Model) streamPolled(msg nbStreamMsg) tea.Cmd {
145146 }146147 147148 // showStream keeps what the stream has printed so far as the cell's148-// output.149+// output, the rows it says it has printed with it, so the two agree. The150+// grid showing the output takes the rows printed since it last showed151+// (nbgridstream.go).149152 func (m *Model) showStream(st *nbStream) {153+ snap := st.src.Snapshot(st.values)150154 st.shown, st.behind = time.Now(), false151- m.book().SetOutput(st.id, &notebook.Output{NUON: st.src.NUON(), Count: st.count, Took: time.Since(st.start),155+ st.rows, st.values = snap.Rows, snap.Values156+ old := m.book().Output(st.id)157+ m.book().SetOutput(st.id, &notebook.Output{NUON: snap.NUON, Count: st.count, Took: time.Since(st.start),152158 Source: st.source, Reads: st.reads, Selection: st.selection})159+ if v := m.nb.views[st.s]; v != nil && old != nil && old.Count == st.count && snap.More != nil {160+ v.Carry(st.id, old, m.book().Output(st.id), snap.More)161+ }153162 }154163 155164 // endStream keeps a stream's output as it ended: its rows, and why it
M internal/ui/nbview/grid.go +37 -0
@@ -3,6 +3,7 @@ package nbview
33 import (44 "strings"55 6+ "github.com/FelineStateMachine/012/internal/notebook"67 "github.com/FelineStateMachine/012/internal/nuon"78 "github.com/FelineStateMachine/012/internal/ui/theme"89 )
@@ -31,6 +32,9 @@ type Grid interface {
3132 // Entered reports whether the grid has the keys: its active cell3233 // and selection show, and arrows move them.3334 Entered() bool35+ // Append adds more, a NUON table of n rows, under the grid's rows,36+ // as a stream's rows arrive; data is the whole output now.37+ Append(more []byte, n int, data []byte)3438 }3539 3640 // gridData is what the UI's grid of an output holds, as NUON: a table as
@@ -197,3 +201,36 @@ func (v *View) FollowOutput() {
197201 v.follow()198202 }199203 }204+205+// maxCarried is the most rows a stream's grid holds, carried from output206+// to output: the rows a stream keeps, twice over, so a grid past them207+// is read again from the output about once for every StreamKeep rows.208+const maxCarried = 20000209+210+// Carry shows o, cell id's output, as old showed: o is old with the211+// rows a stream printed since, more (a NUON list), so they go under the212+// rows of old's grid, whose widths, pointer, selection, sort and filter213+// stay, rather than o being read whole. It reports false, and o is read214+// afresh when it shows, when old isn't showing as a table's grid, more215+// isn't a table, or the grid would hold more than maxCarried rows.216+func (v *View) Carry(id int, old, o *notebook.Output, more []byte) bool {217+ prev := v.outs[old]218+ if prev == nil || prev.kind != outTable || prev.grid == nil || v.h.Output(id) != o {219+ return false220+ }221+ val, err := nuon.Parse(more)222+ if err != nil || val.Kind != nuon.List || !isTable(val.List) || prev.total+len(val.List) > maxCarried {223+ return false224+ }225+ keys := addKeys(prev.keys, val.List)226+ sh := &shown{kind: outTable, total: prev.total + len(val.List), cols: len(keys), keys: keys, data: o.NUON, grid: prev.grid}227+ if len(val.List) > 0 {228+ prev.grid.Append(more, len(val.List), o.NUON)229+ }230+ delete(v.outs, old)231+ v.outs[o] = sh232+ if v.full != nil && v.full.sh == prev {233+ v.full.sh = sh234+ }235+ return true236+}
M internal/ui/nbview/output.go +10 -6
M internal/ui/nbview/view_test.go +9 -4