A stream's output grid was made again from the whole output on each update (about 60 ms at 10,000 rows) and lost the pointer, selection, sort and filter; the cell's head counted rows the output didn't show yet, so the golden screen notebook-stream could catch a grid fitted to fewer rows. The head now counts the rows the output has, and the grid appends the rows printed since (live.Stream.Snapshot, nbview Carry, fileio.AppendTable), widening columns never narrower and not while entered: 2.3 ms a batch of 250 rows at 10,000. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- d1f060be50db12b8e0b7ece94a44b2d1702a16c3
- parent
- bfff293ba096514f6bbe21dd0b9c033221c9b805
- author
- Dami <dami.etoile@gmail.com>
- kind
- fix risk score 1.9 of 3, Jev's estimate
| M | docs/ | 1 | + |
| M | docs/ | 8 | ++ |
| M | e2e/ | 2 | + |
| M | internal/ | 3 | +- |
| M | internal/ | 34 | +++++++ |
| M | internal/ | 39 | +++++++- |
| M | internal/ | 10 | +- |
| M | internal/ | 8 | ++ |
| A | internal/ | 93 | +++++++++++++++++++ |
| A | internal/ | 146 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 17 | +++- |
| M | internal/ | 37 | ++++++++ |
| M | internal/ | 16 | ++- |
| M | internal/ | 13 | ++- |
@@ -40,6 +40,7 @@ it lags, and past a second it stalls.
4040 | SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 8.7 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 120 fps pacing; per session, the terminal's cell buffers |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 | 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 |43+| 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 |4344 | 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 |4445 4546 ## Sheet size@@ -357,6 +357,14 @@ keeps every row, up to `max-cells`. The rows arrive as the change
357357 stream does for a [followed file](../files/following.md), outside the358358 undo history.359359 360+A table's grid takes the rows as they arrive, under those it shows, so361+what you've done in it stays: the pointer, the selection, a filter362+(which covers the new rows) and the rows as sorted, the new ones under363+them. Its columns widen to fit longer rows, never narrowing, and wait364+while you're in the grid so nothing shifts under the pointer; a column365+you resized keeps its width. Past 20,000 rows the grid starts again366+from the rows the output keeps.367+360368 ## Names and $name361369 362370 A cell holds one statement or several, a line each (or apart by `;`),@@ -133,6 +133,8 @@ var notebookScreens = []screen{
133133 {name: "notebook-stream", setup: func(s *session) {134134 filesNotebook(s)135135 s.keys("<enter>", "log = tail -f app.log | lines | parse '{time} {level} {msg}'", "<esc>", "f")136+ // The head counts the rows the output has, so the grid is fitted137+ // to all three: only the first shows.136138 s.waitFor("● live, 3 rows")137139 }},138140 {name: "notebook-sent", setup: func(s *session) {@@ -94,6 +94,7 @@ type tableCells struct {
9494 cols map[string]int9595 zone *time.Location9696 dated map[int]bool // columns widened for dates97+ fixed bool // widths are the sheet's own: dates widen nothing9798 }9899 99100 // dateTimeWidth fits a date and time in the Date time format, in any@@ -148,7 +149,7 @@ func (t *tableCells) value(a sheet.Addr, v nuon.Value) {
148149 default:149150 b.text(a, c.V.Str, c.F, none)150151 }151- if v.Kind == nuon.Date && !t.dated[a.Col] && a.Valid() {152+ if v.Kind == nuon.Date && !t.fixed && !t.dated[a.Col] && a.Valid() {152153 t.dated[a.Col] = true153154 b.s.LoadColWidth(a.Col, dateTimeWidth)154155 }@@ -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+}@@ -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 }@@ -210,14 +210,18 @@ func (s *Sheet) fitTable(o Addr, rows, cols int) {
210210 }211211 widest := 0212212 for r := range min(rows, sample) {213- widest = max(widest, len([]rune(s.ShownText(Addr{Col: col, Row: o.Row + r}))))213+ widest = max(widest, FitWidth(s.ShownText(Addr{Col: col, Row: o.Row + r})))214214 }215- if widest+2 > DefaultWidth {216- s.setWidth(col, min(widest+2, maxFitWidth))215+ if widest > DefaultWidth {216+ s.setWidth(col, widest)217217 }218218 }219219 }220220 221+// FitWidth is how wide fitting a table (LoadFitWidths) makes a column222+// for text: its characters and a space either side, capped.223+func FitWidth(text string) int { return min(len([]rune(text))+2, maxFitWidth) }224+221225 // EntryValue is the value and the format an entry implies ("$5", a222226 // date), as typing it into a cell would store them, for a source whose223227 // rows arrive as text. Formulas stay text, as a data file's should.@@ -51,6 +51,9 @@ type outGrid struct {
5151 shownAt uint645252 // frame is the parent's frame the child last drew for.5353 frame int54+ // fit are the columns' widths as the grid fitted them, and need the55+ // widths their text needs as rows arrive (nbgridstream.go).56+ fit, need []int5457 }5558 5659 var _ nbview.Grid = (*outGrid)(nil)@@ -70,6 +73,7 @@ func (g *outGrid) ready(width, rows int) bool {
7073 return false7174 }7275 c := g.child76+ g.widen()7377 if f := g.parent.nb.frame; g.frame != f {7478 g.frame = f7579 c.th = g.parent.th@@ -101,6 +105,10 @@ func (g *outGrid) build() {
101105 s.ClearHistory()102106 used, _ := s.UsedRange()103107 g.cols, g.rows = used.To.Col+1, used.To.Row108+ for c := range g.cols {109+ g.fit = append(g.fit, s.ColWidth(c))110+ }111+ g.need = slices.Clone(g.fit)104112 c := New(s, "")105113 c.out, c.named = g, true106114 c.th = g.parent.th@@ -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+}@@ -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+}@@ -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, ¬ebook.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, ¬ebook.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@@ -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+}@@ -54,6 +54,7 @@ type shown struct {
5454 // UI makes the grid from.5555 data []byte5656 grid Grid57+ keys map[string]bool // a table's column names5758 }5859 5960 // parse reads an output for showing.@@ -101,7 +102,8 @@ func shownOf(v nuon.Value) *shown {
101102 return &shown{}102103 }103104 if isTable(v.List) {104- return &shown{kind: outTable, total: len(v.List), cols: countCols(v.List)}105+ keys := addKeys(nil, v.List)106+ return &shown{kind: outTable, total: len(v.List), cols: len(keys), keys: keys}105107 }106108 sh := &shown{kind: outList, total: len(v.List)}107109 for _, it := range v.List {@@ -122,15 +124,17 @@ func isTable(list []nuon.Value) bool {
122124 return true123125 }124126 125-// countCols is how many columns a list of records names.126-func countCols(list []nuon.Value) int {127- seen := map[string]bool{}127+// addKeys adds the columns a list of records names to keys.128+func addKeys(keys map[string]bool, list []nuon.Value) map[string]bool {129+ if keys == nil {130+ keys = map[string]bool{}131+ }128132 for _, rec := range list {129133 for _, f := range rec.Fields {130- seen[f.Key] = true134+ keys[f.Key] = true131135 }132136 }133- return len(seen)137+ return keys134138 }135139 136140 // isGrid reports whether the output is drawn as the UI's grid.@@ -61,11 +61,18 @@ func (h *fakeHost) Edit(id int, src string) {
6161 6262 // Grid is a fake grid: its column names, then its rows' values, spaced.6363 func (h *fakeHost) Grid(id int, data []byte, rows int) Grid {64- v, err := nuon.Parse(data)64+ g := &fakeGrid{}65+ g.Append(data, rows, data)66+ h.grids = append(h.grids, g)67+ return g68+}69+70+// Append adds the table's rows.71+func (g *fakeGrid) Append(more []byte, _ int, _ []byte) {72+ v, err := nuon.Parse(more)6573 if err != nil {6674 panic(err)6775 }68- g := &fakeGrid{}6976 for _, rec := range v.List {7077 var row []string7178 for i, f := range rec.Fields {@@ -76,8 +83,6 @@ func (h *fakeHost) Grid(id int, data []byte, rows int) Grid {
7683 }7784 g.rows = append(g.rows, strings.Join(row, " "))7885 }79- h.grids = append(h.grids, g)80- return g8186 }8287 8388 type fakeGrid struct {