1package sheet23import (4 "fmt"5 "strings"6 "time"7 "unicode"89 "src.rest/cat/012/internal/value"10)1112// The change stream: rows reach a region as LiveOps, each applied at13// once through ApplyLive, outside the undo history, as a spill is14// written. An op names its region by name, which the file keeps, and15// carries whole rows of values with the formats they arrived in, so it16// depends on nothing but the region: the same op applied to the same17// workbook elsewhere makes the same cells, which is what a session18// following another's would be sent. Whatever reads a linked file sends19// them, and the UI sends a notebook cell's output to the region it was20// sent to whenever the cell runs.2122// LiveCell is a value as a source gave it, with its format (a date's,23// a file size's), Automatic for none.24type LiveCell struct {25 V Value26 F Format27}2829// LiveRow is a row of a region's table, from its first column.30type LiveRow []LiveCell3132// LiveOp is one change a source makes to its region.33type LiveOp struct {34 // Region is the region's name (Region.Name).35 Region string36 // At is when the source changed.37 At time.Time38 // Reset has Rows replace every row under the header; otherwise they39 // follow the last.40 Reset bool41 // Header is the table's first row, when it is new or changed: the42 // source's column names.43 Header LiveRow44 // Rows are data rows, under the header.45 Rows []LiveRow46 // Err says why the source can't be read, "" when it can. An op with47 // an error changes no rows: the region keeps what it shows.48 Err string49 // Note says what the source left out, if anything.50 Note string51}5253// LiveInfo describes one op applied, for telemetry. It holds counts54// only, never contents.55type LiveInfo struct {56 Rows int // rows the op brought57 Cells int // cells that changed58 Reset bool59 Duration time.Duration // writing the cells and recalculating what reads them60}6162// OnLive, when set, is called after every op applied, like OnRecalc63// (see OnBegin, whose op is "live").64var OnLive func(trace any, i LiveInfo)6566// ApplyLive applies op to its region: its rows are written, a window67// drops the oldest, and what reads the cells that changed recalculates.68// It returns ErrNoRegion for a region the workbook doesn't hold.69func (w *Workbook) ApplyLive(op LiveOp) error {70 s, r, ok := w.Region(op.Region)71 if !ok {72 return ErrNoRegion73 }74 var start time.Time75 if OnLive != nil {76 if OnBegin != nil {77 OnBegin(w.trace, "live")78 }79 start = time.Now()80 }81 changed := s.applyLive(r, s.meta(nameKey(r.Name)), op)82 if len(changed) > 0 {83 if w.hist.depth > 0 {84 w.hist.dirty = append(w.hist.dirty, changed...) // recalculated as the open step ends85 } else {86 w.recalc(changed)87 }88 }89 if OnLive != nil {90 OnLive(w.trace, LiveInfo{Rows: len(op.Rows), Cells: len(changed), Reset: op.Reset, Duration: time.Since(start)})91 }92 return nil93}9495// applyLive writes op's rows into r and returns the cells that changed.96func (s *Sheet) applyLive(r Region, me *regionMeta, op LiveOp) []loc {97 if !op.At.IsZero() && op.Err == "" {98 me.updated = op.At99 }100 if op.Reset {101 me.stale = false102 }103 if op.Err != "" {104 me.err = op.Err105 if me.rows == 0 {106 return s.writeTable(r, me, nil, nil, true)107 }108 return nil109 }110 me.err, me.note = "", op.Note111 rows, header := s.liveRows(r, me, op)112 return s.writeTable(r, me, header, rows, op.Reset)113}114115// liveRows is the data rows the region is to show after op, with the116// header: when rows are dropped (by the window, or when the table is117// full) the rows kept are read back from the region's own cells. Rows118// that already show and stay are nil, so only new ones are written.119func (s *Sheet) liveRows(r Region, me *regionMeta, op LiveOp) (rows []LiveRow, header LiveRow) {120 header = op.Header121 if header == nil && me.rows > 0 {122 header = s.readRow(r, me, 0)123 }124 cols := max(len(header), me.cols)125 for _, row := range op.Rows {126 cols = max(cols, len(row))127 }128 old := max(me.rows-1, 0)129 if op.Reset {130 old = 0131 }132 window := r.File.Window133 total := old + len(op.Rows)134 keep := total135 if window > 0 {136 keep = min(keep, window)137 }138 // Whole rows, as many as fit in max-cells.139 if fit := MaxCells()/max(cols, 1) - 1; keep > fit {140 keep = max(fit, 0)141 me.note = fmt.Sprintf("only the last %d rows fit in max-cells (%d cells)", keep, MaxCells())142 if window == 0 {143 me.note = fmt.Sprintf("only the first %d rows fit in max-cells (%d cells)", keep, MaxCells())144 }145 }146 if op.Reset {147 me.dropped, me.data = 0, 0148 }149 me.data += len(op.Rows)150 if keep < total && window == 0 {151 // Keep everything up to the budget: the newest rows are left out.152 rows = make([]LiveRow, keep)153 copy(rows[min(old, keep):], op.Rows[:max(keep-old, 0)])154 return rows, header155 }156 drop := total - keep157 me.dropped += drop158 rows = make([]LiveRow, keep)159 for i := range keep {160 switch j := i + drop; {161 case j >= old:162 rows[i] = op.Rows[j-old]163 case drop > 0:164 rows[i] = s.readRow(r, me, j+1)165 }166 }167 return rows, header168}169170// readRow reads row i of the region's table (0 for the header) back171// from its cells.172func (s *Sheet) readRow(r Region, me *regionMeta, i int) LiveRow {173 o := r.At174 row := make(LiveRow, me.cols)175 for c := range me.cols {176 v, lk, kind := s.cells.derivedOf(Addr{Col: o.Col + c, Row: o.Row + i})177 if kind == slotSpill {178 row[c] = LiveCell{V: v, F: lk.auto}179 }180 }181 return row182}183184// freedFor has a spill waiting for the cell at a try again, now that a185// region wrote or cleared it.186func (s *Sheet) freedFor(a Addr) {187 if s.spills == nil {188 return189 }190 s.spillAt.readers(a, func(anchor Addr) {191 if sp := s.spills[anchor]; sp != nil && sp.why != "" {192 sp.stale = true193 s.wb.markDirty(loc{s, anchor})194 }195 })196}197198// maxFitWidth caps how wide fitting makes a table's column.199const maxFitWidth = 30200201// fitTable widens the columns of a table at o, the first time rows202// arrive, to their widest text in the first rows, never below the203// default width, outside the undo history as a pivot's are.204func (s *Sheet) fitTable(o Addr, rows, cols int) {205 const sample = 200206 for c := range cols {207 col := o.Col + c208 if s.widths[col] != 0 {209 continue // set by hand, or by the file210 }211 widest := 0212 for r := range min(rows, sample) {213 widest = max(widest, FitWidth(s.ShownText(Addr{Col: col, Row: o.Row + r})))214 }215 if widest > DefaultWidth {216 s.setWidth(col, widest)217 }218 }219}220221// FitWidth is how wide fitting a table (LoadFitWidths) makes a column222// for text: its characters and a space either side, capped.223func FitWidth(text string) int { return min(len([]rune(text))+2, maxFitWidth) }224225// EntryValue is the value and the format an entry implies ("$5", a226// date), as typing it into a cell would store them, for a source whose227// rows arrive as text. Formulas stay text, as a data file's should.228func EntryValue(input string) (Value, Format) {229 input = strings.Map(func(r rune) rune {230 if unicode.IsControl(r) {231 return -1232 }233 return r234 }, input)235 if input == "" {236 return Value{}, Format{}237 }238 if rest, ok := strings.CutPrefix(input, "'"); ok {239 return value.Str(rest), Format{}240 }241 if n, f, ok := ParseValue(input); ok {242 return value.Num(n), f243 }244 switch strings.ToUpper(input) {245 case "TRUE":246 return value.Boolean(true), Format{}247 case "FALSE":248 return value.Boolean(false), Format{}249 }250 return value.Str(input), Format{}251}