cat / 012
clone

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

Regions: blocks of cells filled from outside the engine, stacking on notebook sheets

Dami committed 1ef8b3b
A region is an anchor (its label line) and a command; the table it
shows arrives as RegionData and is written as derived cells, like a
spill's: refused by Set, values to formulas and exports, formatting
kept, never saved. Definitions and the data shown are undo steps; the
file keeps the definitions (notebook, regions). On a notebook sheet a
table that grows or shrinks inserts or deletes rows under it. Formulas
read a region's table as nu.<name>; commands' $name references make a
dependency graph with refresh and run orders, cycles refused.
commit
1ef8b3bc0fb99faf9f46ea42881c81071d908257
parent
211999286af526b35f28bcf4f6a3c08c2cbfa826
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.4 of 3, Jev's estimate
16 files changed, +1466 -11
Mdocs/files/format.md30++-
Minternal/sheet/file.go14+-
Minternal/sheet/filestream_test.go6+
Minternal/sheet/history.go14+-
Minternal/sheet/historyrestore.go5+
Minternal/sheet/historysize.go3+
Minternal/sheet/names.go8+-
Minternal/sheet/ops.go1+
Minternal/sheet/recalc.go4+-
Ainternal/sheet/region.go518++++++++++++++++++++++++++++++
Ainternal/sheet/region_test.go189+++++++++++
Ainternal/sheet/regionfile.go109++++++
Ainternal/sheet/regiongraph.go149+++++++++
Ainternal/sheet/regionwrite.go409++++++++++++++++++++++++
Minternal/sheet/sheet.go13+
Minternal/sheet/workbook.go5+
M docs/files/format.md +29 -1
@@ -86,7 +86,8 @@ save), so they raise no version:
8686 | `conditionalFormats`, `validations` | a sheet | [Rules](#conditional-formats-and-data-validation), one per line |8787 | `arithmetic` | the workbook | `decimal` for [decimal arithmetic](../formulas/decimal.md) |8888 | `locale` | the workbook | The [locale](../sheets/locale.md) it's typed and shown in (`"de-DE"`), when File > Settings > Locale chose one; without it, the file follows the reader's `locale` setting. Cells are stored the same way in every locale: `input` is always as typed in en-US (`1,234.5`, `9/26/2026`, `=ROUND(A1,2)`) |89-| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on: see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |89+| `macros`, `macroOrigin` | the workbook | Macros as Starlark scripts, and the computer they were made or trusted on (macros' and shell regions' both): see [Macro scripting API](../reference/macro-api.md#in-the-file). Opening a file never runs them |90+| `notebook`, `regions` | a sheet | A [notebook sheet](../terminal/nushell.md#notebooks) and its shell regions: see [Regions](#regions). Older builds open the sheet without them |9091 9192 ## Column and row formats9293
@@ -157,6 +158,33 @@ with its data.
157158 `condition` with its `value`.158159 - `rowTotals` and `columnTotals` are the grand total row and column.159160 161+## Regions162+163+A [notebook sheet](../terminal/nushell.md#notebooks) has `"notebook":164+true` and its regions in a `regions` list after its cells, charts and165+rules, one per line, in run order. The file keeps each region's166+command, never its table: reopening shows the regions as not run, and167+nothing runs until you run it.168+169+```json170+ "notebook": true,171+ "regions": [172+ {"name":"r1","command":"ls","at":"A1","rows":12,"cols":4},173+ {"name":"big","command":"$r1 | where size > 1kb","at":"A16","rows":3,"cols":4,"reads":["r1"],"sort":[{"column":3,"desc":true}]}174+ ]175+```176+177+- `name` is what commands read it as (`$r1`) and formulas name it by178+ (`nu.r1`); `command` is the nushell pipeline.179+- `at` is its label line's cell; `rows` and `cols` the size of its table180+ when saved, header row included, which it keeps until it runs.181+- `reads` are the regions its command reads, `input` the range it read182+ as `$in` (`Sheet1!A1:C9`), and `sort` the columns its table is sorted183+ by, counting from 1 at its first column, with `desc` for Z to A.184+185+The table's cells are saved only for their formatting and notes, as a186+spill's are.187+160188 ## Conditional formats and data validation161189 162190 A sheet's rules are two lists after its cells and charts, one rule per
M internal/sheet/file.go +10 -4
@@ -78,6 +78,9 @@ type fileSheet struct {
7878 // Rules need no version: see rulefile.go.7979 CondFormats []fileCondFormat `json:"conditionalFormats,omitempty"`8080 Validations []fileValidation `json:"validations,omitempty"`81+ // A notebook and its regions need no version: see regionfile.go.82+ Notebook bool `json:"notebook,omitempty"`83+ Regions []fileRegion `json:"regions,omitempty"`8184 }8285 8386 // Write saves the workbook the sheet belongs to; see Workbook.Write.
@@ -159,10 +162,10 @@ func (w *Workbook) headLines() string {
159162 if w.locale != nil {160163 lines = append(lines, `"locale": `+jsonString(w.locale.Tag))161164 }165+ if w.macroOrigin != "" && (len(w.macros) > 0 || len(w.allRegions()) > 0) {166+ lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))167+ }162168 if len(w.macros) > 0 {163- if w.macroOrigin != "" {164- lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))165- }166169 lines = append(lines, w.macrosLines())167170 }168171 return strings.Join(lines, ",\n ")
@@ -266,7 +269,7 @@ func (s *Sheet) writeObjects(b *bufio.Writer, indent string) error {
266269 fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)267270 }268271 s.writeRules(b, indent)269- return nil272+ return s.writeRegionDefs(b, indent)270273 }271274 272275 // Read loads a file written by Write, of this or an earlier version, and
@@ -354,5 +357,8 @@ func (s *Sheet) read(f fileSheet) error {
354357 if err := s.readRules(f.CondFormats, f.Validations); err != nil {355358 return err356359 }360+ if err := s.readRegions(f.Notebook, f.Regions); err != nil {361+ return err362+ }357363 return s.readView(f.fileView)358364 }
M internal/sheet/filestream_test.go +6 -0
M internal/sheet/history.go +11 -3
@@ -59,6 +59,8 @@ type step struct {
5959 // rules holds each touched sheet's conditional formats and data6060 // validation before the step.6161 rules map[*Sheet]rulesState62+ // regions holds each touched sheet's regions before the step.63+ regions map[*Sheet]regionState6264 // sheets is the sheet list before the step, when it changed.6365 sheets *sheetList6466 // settings are the workbook's settings before the step, when they
@@ -76,7 +78,8 @@ type colKey struct {
7678 7779 func newStep(label string, s *Sheet, focus Rect) *step {7880 return &step{label: label, sheet: s, focus: focus, cells: map[*Sheet]*image{}, widths: map[colKey]int{}, lines: map[lineKey]lineFmt{},79- names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{}}81+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{},82+ regions: map[*Sheet]regionState{}}8083 }8184 8285 func (st *step) empty() bool {
@@ -87,7 +90,7 @@ func (st *step) empty() bool {
8790 // and row heights.8891 func (st *step) widthOnly() bool {8992 return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.settings == nil &&90- st.macros == nil93+ st.macros == nil && len(st.regions) == 09194 }9295 9396 func (c *Cell) clone() *Cell {
@@ -227,6 +230,11 @@ func (w *Workbook) dropUnchanged(st *step) {
227230 delete(st.pivots, s)228231 }229232 }233+ for s, r := range st.regions {234+ if sameRegions(r, s.regions) {235+ delete(st.regions, s)236+ }237+ }230238 if st.sheets != nil && st.sheets.equal(w.sheetList()) {231239 st.sheets = nil232240 }
@@ -381,6 +389,6 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
381389 w.pushUndo(inv)382390 }383391 h.mergeWidths = false384- macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil392+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && len(st.regions) == 0385393 return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true386394 }
M internal/sheet/historyrestore.go +5 -0
M internal/sheet/historysize.go +3 -0
M internal/sheet/names.go +6 -2
M internal/sheet/ops.go +1 -0
M internal/sheet/recalc.go +3 -1
A internal/sheet/region.go +518 -0
@@ -0,0 +1,518 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "maps"7+ "slices"8+ "strconv"9+ "strings"10+11+ "github.com/FelineStateMachine/012/internal/formula"12+)13+14+// Regions are blocks of cells whose values come from outside the15+// engine: a nushell command's output on a notebook sheet, and in time a16+// file followed as it grows. A region is an anchor (its label line, At)17+// and a definition saying where its table comes from (Command, opaque to18+// the engine); what fills it arrives as a RegionData the UI hands over19+// (ShowRegion). Like a spill, its cells are derived: written by the20+// engine outside the undo history, refused by Set, values to formulas,21+// copies and exports, formatting kept, never saved. The definitions and22+// the data shown are undo steps, so undoing a run shows the table it23+// replaced; the file keeps the definitions only.24+//25+// On a notebook sheet regions stack in run order, a gap row between26+// them: a new one goes below everything on the sheet, and a table that27+// grows or shrinks inserts or deletes rows under it, so what's below28+// moves as rows inserted by hand would move it.29+//30+// In formulas a region is a name: nu. and its own (=SUM(nu.r1)), which31+// stands for its table, header row included.32+33+// Region is a region's definition.34+type Region struct {35+ // Name identifies the region in the workbook, ignoring case: r1, or36+ // a name the user gave. Commands read other regions as $name.37+ Name string38+ // Command is what fills the region: for a notebook, a nushell39+ // pipeline.40+ Command string41+ // At is the label line's first cell; the table starts below it.42+ At Addr43+ // Rows and Cols are the table's size as last shown, header row44+ // included, so a region not run yet keeps its place.45+ Rows, Cols int46+ // Deps are the regions the command reads, in the order it names47+ // them.48+ Deps []string49+ // Input is the range the command reads as $in, qualified with its50+ // sheet ("Sheet1!A1:C9"), or "".51+ Input string52+ // Sort orders the table's rows below its header, by columns counted53+ // from the region's first.54+ Sort []SortKey55+}56+57+// regionState is what a sheet keeps of its regions in the undo history:58+// the definitions in run order and the data each shows, by name key.59+// Both are replaced whole, never changed in place.60+type regionState struct {61+ notebook bool62+ list []Region63+ data map[string]*RegionData64+}65+66+// regionMeta is what isn't undone: what the region's label says it's67+// doing, and the cells written last.68+type regionMeta struct {69+ status string70+ written Rect71+ has bool // written holds something72+ why string // why the table isn't shown, or ""73+}74+75+// RegionData is a region's table: Rows by Cols values, row by row, the76+// header row first, with the formats they come in (a file size's Size,77+// a date's Date time) by index.78+type RegionData struct {79+ Rows, Cols int80+ Values []Value81+ Formats map[int]Format82+ // Note says what was left out, e.g. rows past max-cells.83+ Note string84+}85+86+// At returns the value at row r, column c and its format.87+func (d *RegionData) At(r, c int) (Value, Format) {88+ if d == nil || r < 0 || c < 0 || r >= d.Rows || c >= d.Cols {89+ return Value{}, Format{}90+ }91+ i := r*d.Cols + c92+ return d.Values[i], d.Formats[i]93+}94+95+// RegionDataOf copies the used range of src, from A1, as a region's96+// table: how a table read by an importer becomes one.97+func RegionDataOf(src *Sheet) *RegionData {98+ used, ok := src.UsedRange()99+ if !ok {100+ return &RegionData{}101+ }102+ d := &RegionData{Rows: used.To.Row + 1, Cols: used.To.Col + 1}103+ d.Values = make([]Value, d.Rows*d.Cols)104+ for a := range src.cells.keysIn(Rect{To: used.To}) {105+ i := a.Row*d.Cols + a.Col106+ d.Values[i] = src.Value(a)107+ if f := src.DisplayFormat(a); !f.IsZero() {108+ if d.Formats == nil {109+ d.Formats = map[int]Format{}110+ }111+ d.Formats[i] = f112+ }113+ }114+ return d115+}116+117+// Errors of regions.118+var (119+ // ErrRegionEdit is what typing into a region's cell says.120+ ErrRegionEdit = errors.New("That cell is part of a shell region: change its command, or Freeze it to edit its values")121+ errNoRegion = errors.New("There's no region by that name")122+)123+124+// regionPrefix starts a region's name in formulas.125+const regionPrefix = "nu."126+127+// FormulaName is how formulas name the region: nu.r1.128+func (r Region) FormulaName() string { return regionPrefix + r.Name }129+130+// reservedRegionNames are nushell's own variables.131+var reservedRegionNames = []string{"in", "env", "nu", "it"}132+133+// ValidRegionName checks that name can name a region: a nushell134+// variable name of letters, digits and _, not starting with a digit.135+func ValidRegionName(name string) error {136+ switch {137+ case name == "":138+ return errors.New("Enter a name")139+ case len(name) > 64:140+ return errors.New("A region's name can be at most 64 characters")141+ case isDigit(name[0]):142+ return errors.New("A region's name can't start with a digit")143+ case slices.Contains(reservedRegionNames, strings.ToLower(name)):144+ return fmt.Errorf("$%s is nushell's own", name)145+ }146+ for i := range len(name) {147+ if c := name[i]; !isLetter(c) && !isDigit(c) && c != '_' {148+ return errors.New("A region's name can only have letters, digits and _")149+ }150+ }151+ return nil152+}153+154+// Notebook reports whether the sheet is a notebook: its regions stack155+// in run order.156+func (s *Sheet) Notebook() bool { return s.regions.notebook }157+158+// MakeNotebook turns an empty sheet into a notebook, as a loader does,159+// without an undo step.160+func (s *Sheet) MakeNotebook() { s.regions.notebook = true }161+162+// AddNotebook adds a notebook sheet named name ("" for the next Shell163+// N) after the active one, as one undo step.164+func (w *Workbook) AddNotebook(name string, after *Sheet) (*Sheet, error) {165+ if name == "" {166+ name = w.NextNotebookName()167+ }168+ if err := w.checkName(nil, name); err != nil {169+ return nil, err170+ }171+ s := w.newSheet(name)172+ s.regions.notebook = true173+ at := len(w.sheets)174+ if i := w.Index(after); i >= 0 {175+ at = i + 1176+ }177+ w.change(s, "insert sheet "+name, Rect{}, func() {178+ w.recordSheets()179+ w.insert(s, at)180+ })181+ return s, nil182+}183+184+// NextNotebookName is Shell 1, then 2 and so on.185+func (w *Workbook) NextNotebookName() string {186+ for n := 1; ; n++ {187+ if name := "Shell " + strconv.Itoa(n); w.Lookup(name) == nil {188+ return name189+ }190+ }191+}192+193+// HasRegions reports whether the sheet has regions, so what draws it can194+// skip looking for them.195+func (s *Sheet) HasRegions() bool { return len(s.regions.list) > 0 }196+197+// Regions returns the sheet's regions in run order.198+func (s *Sheet) Regions() []Region {199+ out := slices.Clone(s.regions.list)200+ for i := range out {201+ out[i].Deps, out[i].Sort = slices.Clone(out[i].Deps), slices.Clone(out[i].Sort)202+ }203+ return out204+}205+206+// Region finds a region by name, ignoring case, on any sheet.207+func (w *Workbook) Region(name string) (*Sheet, Region, bool) {208+ k := nameKey(name)209+ for _, s := range w.sheets {210+ if i := s.regionIndex(k); i >= 0 {211+ return s, s.Regions()[i], true212+ }213+ }214+ return nil, Region{}, false215+}216+217+func (s *Sheet) regionIndex(k string) int {218+ return slices.IndexFunc(s.regions.list, func(r Region) bool { return nameKey(r.Name) == k })219+}220+221+// NextRegionName is the first of r1, r2 and so on that no region or222+// named range has.223+func (w *Workbook) NextRegionName() string {224+ for n := 1; ; n++ {225+ name := "r" + strconv.Itoa(n)226+ if w.checkRegionName(name) == nil {227+ return name228+ }229+ }230+}231+232+// checkRegionName reports why name can't name a new region.233+func (w *Workbook) checkRegionName(name string) error {234+ if err := ValidRegionName(name); err != nil {235+ return err236+ }237+ if _, r, ok := w.Region(name); ok {238+ return fmt.Errorf("There's already a region named %s", r.Name)239+ }240+ if n, ok := w.LookupName(regionPrefix + name); ok {241+ return fmt.Errorf("%s already names %s", n.Name, n.Ref())242+ }243+ return nil244+}245+246+// RegionShown reports whether the region has data to show: it has run247+// since the file was opened.248+func (s *Sheet) RegionShown(name string) bool {249+ return s.regions.data[nameKey(name)] != nil250+}251+252+// RegionStatus is what the region's label says it's doing: "Running…",253+// "not run", why its table can't be shown, or "".254+func (s *Sheet) RegionStatus(name string) string {255+ k := nameKey(name)256+ if me := s.regionMeta[k]; me != nil {257+ switch {258+ case me.status != "":259+ return me.status260+ case me.why != "":261+ return me.why262+ }263+ }264+ if s.regions.data[k] == nil {265+ return "not run"266+ }267+ return ""268+}269+270+// RegionNote is what the data shown left out, or "".271+func (s *Sheet) RegionNote(name string) string {272+ if d := s.regions.data[nameKey(name)]; d != nil {273+ return d.Note274+ }275+ return ""276+}277+278+// AddRegion adds a region to the sheet as one undo step. On a notebook279+// its label goes below everything on the sheet, a row left free, and r.At280+// is ignored.281+func (s *Sheet) AddRegion(r Region) error {282+ if err := s.wb.checkRegionName(r.Name); err != nil {283+ return err284+ }285+ if err := s.wb.checkDeps(r.Name, r.Deps); err != nil {286+ return err287+ }288+ if s.regions.notebook {289+ r.At = Addr{Row: s.nextRegionRow()}290+ }291+ r.Deps, r.Sort = slices.Clone(r.Deps), slices.Clone(r.Sort)292+ s.change("add "+r.Name, Rect{From: r.At, To: r.At}, func() {293+ st := s.regionsCopy()294+ st.list = append(st.list, r)295+ s.putRegions(st)296+ })297+ return nil298+}299+300+// nextRegionRow is where a new region's label goes on a notebook: below301+// the last cell and region, a row left free, or row 1 on an empty sheet.302+func (s *Sheet) nextRegionRow() int {303+ next := 0304+ if used, ok := s.UsedRange(); ok {305+ next = used.To.Row + 2306+ }307+ for _, r := range s.regions.list {308+ next = max(next, r.At.Row+r.Rows+2)309+ }310+ return min(next, MaxRows-1)311+}312+313+// EditRegion changes a region's command, what it reads and its input,314+// as one undo step. The table stays until the command runs again.315+func (s *Sheet) EditRegion(name, command string, deps []string, input string) error {316+ i := s.regionIndex(nameKey(name))317+ if i < 0 {318+ return errNoRegion319+ }320+ if err := s.wb.checkDeps(s.regions.list[i].Name, deps); err != nil {321+ return err322+ }323+ s.change("edit "+s.regions.list[i].Name, Rect{From: s.regions.list[i].At, To: s.regions.list[i].At}, func() {324+ st := s.regionsCopy()325+ st.list[i].Command, st.list[i].Deps, st.list[i].Input = command, slices.Clone(deps), input326+ s.putRegions(st)327+ })328+ return nil329+}330+331+// DeleteRegion removes a region and its cells, as one undo step.332+func (s *Sheet) DeleteRegion(name string) error {333+ k := nameKey(name)334+ i := s.regionIndex(k)335+ if i < 0 {336+ return errNoRegion337+ }338+ r := s.regions.list[i]339+ s.change("delete "+r.Name, s.regionArea(r), func() {340+ st := s.regionsCopy()341+ st.list = slices.Delete(st.list, i, i+1)342+ delete(st.data, k)343+ s.putRegions(st)344+ })345+ return nil346+}347+348+// FreezeRegion turns a region's table into plain values, each in the349+// format it showed, and removes the region, as one undo step.350+func (s *Sheet) FreezeRegion(name string) error {351+ k := nameKey(name)352+ i := s.regionIndex(k)353+ if i < 0 {354+ return errNoRegion355+ }356+ r := s.regions.list[i]357+ table, ok := s.RegionTable(r.Name)358+ s.change("freeze "+r.Name, s.regionArea(r), func() {359+ if ok {360+ for a := range s.cells.anyKeysIn(table) {361+ if c := s.cells.get(a); c.Spilled() {362+ s.place(a, frozen(c))363+ }364+ }365+ }366+ st := s.regionsCopy()367+ st.list = slices.Delete(st.list, i, i+1)368+ delete(st.data, k)369+ s.putRegions(st)370+ })371+ return nil372+}373+374+// frozen is a region's cell as a plain one: its value typed in, in the375+// format it shows.376+func frozen(c *Cell) *Cell {377+ p := &Cell{Input: c.Input, Value: c.Value, Format: c.Format, Style: c.Style, Note: c.Note, auto: c.auto}378+ if p.Format.IsZero() {379+ p.Format = c.auto380+ }381+ return p.plain()382+}383+384+// SortRegion sorts a region's table below its header by keys, columns of385+// the sheet, as one undo step: the order is the region's, so the table386+// stays sorted when it runs again.387+func (s *Sheet) SortRegion(name string, keys []SortKey) error {388+ i := s.regionIndex(nameKey(name))389+ if i < 0 {390+ return errNoRegion391+ }392+ r := s.regions.list[i]393+ rel := make([]SortKey, len(keys))394+ for j, key := range keys {395+ rel[j] = SortKey{Col: key.Col - r.At.Col, Desc: key.Desc}396+ }397+ s.change("sort "+r.Name, s.regionArea(r), func() {398+ st := s.regionsCopy()399+ st.list[i].Sort = rel400+ s.putRegions(st)401+ })402+ return nil403+}404+405+// ShowRegion shows d in the region, as one undo step: the table grows or406+// shrinks, and on a notebook the rows under it move with it.407+func (s *Sheet) ShowRegion(name string, d *RegionData) error {408+ k := nameKey(name)409+ i := s.regionIndex(k)410+ if i < 0 {411+ return errNoRegion412+ }413+ if d == nil {414+ d = &RegionData{}415+ }416+ r := s.regions.list[i]417+ s.change("run "+r.Name, s.regionArea(r), func() {418+ if s.regions.notebook {419+ s.fitRegion(k, d.Rows)420+ }421+ st := s.regionsCopy()422+ i := s.regionIndex(k) // fitting may have moved it423+ if i < 0 {424+ return425+ }426+ st.list[i].Rows, st.list[i].Cols = d.Rows, d.Cols427+ st.data[k] = d428+ s.putRegions(st)429+ })430+ return nil431+}432+433+// fitRegion makes room under the region with key k for a table of rows434+// rows: inserting rows below its table, or deleting those it no longer435+// needs when nothing else is on them.436+func (s *Sheet) fitRegion(k string, rows int) {437+ r := s.regions.list[s.regionIndex(k)]438+ end := r.At.Row + r.Rows // the table's last row439+ switch {440+ case rows > r.Rows:441+ below := rowRect(end+1, MaxRows-1)442+ for range s.cells.anyKeysIn(below) {443+ _ = s.insert(true, end+1, rows-r.Rows) // rows pushed past the edge: the table is cut instead444+ return445+ }446+ case rows < r.Rows:447+ from := r.At.Row + rows + 1448+ spare := rowRect(from, end)449+ area := s.regionArea(r)450+ for a := range s.cells.anyKeysIn(spare) {451+ if !area.Contains(a) {452+ return // something else is on those rows453+ }454+ }455+ s.restructure(true, spanOf(from, -(end-from+1)))456+ }457+}458+459+// regionsCopy is a copy of the sheet's regions to change and put back.460+func (s *Sheet) regionsCopy() regionState {461+ return regionState{notebook: s.regions.notebook, list: slices.Clone(s.regions.list), data: maps.Clone(s.regions.data)}462+}463+464+// putRegions replaces the sheet's regions, recording them for undo, and465+// has their cells written again.466+func (s *Sheet) putRegions(st regionState) {467+ s.recordRegions()468+ if st.data == nil {469+ st.data = map[string]*RegionData{}470+ }471+ s.regions = st472+ s.regionsStale = true473+}474+475+// recordRegions saves the sheet's regions before their first change in476+// the open step.477+func (s *Sheet) recordRegions() {478+ if st := s.wb.hist.open; st != nil {479+ if _, seen := st.regions[s]; !seen {480+ st.regions[s] = s.regions481+ }482+ }483+}484+485+// sameRegions reports whether two states of a sheet's regions are the486+// same.487+func sameRegions(a, b regionState) bool {488+ if a.notebook != b.notebook || len(a.list) != len(b.list) || !maps.Equal(a.data, b.data) {489+ return false490+ }491+ for i := range a.list {492+ x, y := a.list[i], b.list[i]493+ if x.Name != y.Name || x.Command != y.Command || x.At != y.At || x.Rows != y.Rows || x.Cols != y.Cols ||494+ x.Input != y.Input || !slices.Equal(x.Deps, y.Deps) || !slices.Equal(x.Sort, y.Sort) {495+ return false496+ }497+ }498+ return true499+}500+501+// boundRegion is a name in a formula that isn't a named range: a502+// region's table (nu.r1), #REF! while it has none, or the name as503+// written, which shows #NAME?.504+func (s *Sheet) boundRegion(nn formula.Name) Node {505+ t, r, ok := s.wb.regionName(nameKey(nn.Name))506+ switch {507+ case !ok:508+ return nn509+ case r == (Rect{}):510+ return formula.RefErr{}511+ }512+ sheet := ""513+ if t != s {514+ sheet = t.name515+ }516+ const fixed = formula.AbsCol | formula.AbsRow517+ return formula.Range{Rect: r, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}518+}
A internal/sheet/region_test.go +189 -0
@@ -0,0 +1,189 @@
1+package sheet2+3+import (4+ "slices"5+ "testing"6+)7+8+// table makes region data of text rows: the first is the header, and9+// cells that read as numbers are numbers.10+func table(rows ...[]string) *RegionData {11+ d := &RegionData{Rows: len(rows)}12+ for _, r := range rows {13+ d.Cols = max(d.Cols, len(r))14+ }15+ d.Values = make([]Value, d.Rows*d.Cols)16+ for i, r := range rows {17+ for j, text := range r {18+ v, _, ok := ParseValue(text)19+ switch {20+ case text == "":21+ continue22+ case ok && i > 0:23+ d.Values[i*d.Cols+j] = Value{Kind: Number, Num: v}24+ default:25+ d.Values[i*d.Cols+j] = Value{Kind: Text, Str: text}26+ }27+ }28+ }29+ return d30+}31+32+func notebook(t *testing.T) *Sheet {33+ t.Helper()34+ s := New()35+ s.MakeNotebook()36+ return s37+}38+39+func addShown(t *testing.T, s *Sheet, name, command string, d *RegionData) {40+ t.Helper()41+ if err := s.AddRegion(Region{Name: name, Command: command, Deps: s.Book().RegionDeps(command)}); err != nil {42+ t.Fatal(err)43+ }44+ if err := s.ShowRegion(name, d); err != nil {45+ t.Fatal(err)46+ }47+}48+49+func TestRegionsStack(t *testing.T) {50+ s := notebook(t)51+ addShown(t, s, "r1", "ls", table([]string{"name", "size"}, []string{"a", "1"}, []string{"b", "2"}))52+ wantShown(t, s, map[string]string{"A1": "r1 ls", "A2": "name", "B2": "size", "A3": "a", "B4": "2", "A5": ""})53+ addShown(t, s, "r2", "$r1 | where size > 1", table([]string{"name", "size"}, []string{"b", "2"}))54+ // A gap row, then r2's label.55+ wantShown(t, s, map[string]string{"A5": "", "A6": "r2 $r1 | where size > 1", "A7": "name", "A8": "b"})56+ if r, ok := s.RegionTable("r2"); !ok || r != rect("A7:B8") {57+ t.Errorf("r2's table %v %v", r, ok)58+ }59+ // r1 growing pushes r2 down; shrinking pulls it back.60+ if err := s.ShowRegion("r1", table([]string{"name", "size"}, []string{"a", "1"}, []string{"b", "2"}, []string{"c", "3"})); err != nil {61+ t.Fatal(err)62+ }63+ wantShown(t, s, map[string]string{"A5": "c", "A6": "", "A7": "r2 $r1 | where size > 1", "A9": "b"})64+ s.ShowRegion("r1", table([]string{"name"}, []string{"a"}))65+ wantShown(t, s, map[string]string{"A3": "a", "B2": "", "A4": "", "A5": "r2 $r1 | where size > 1", "A7": "b"})66+ // Undo shows the table it replaced, with r2 where it was.67+ s.Book().Undo()68+ wantShown(t, s, map[string]string{"A5": "c", "A7": "r2 $r1 | where size > 1"})69+ s.Book().Undo()70+ s.Book().Undo() // r2's run71+ wantShown(t, s, map[string]string{"A6": "r2 $r1 | where size > 1 not run", "A7": ""})72+ s.Book().Undo() // r2 itself73+ wantShown(t, s, map[string]string{"A6": ""})74+}75+76+func TestRegionGuards(t *testing.T) {77+ s := notebook(t)78+ addShown(t, s, "r1", "ls", table([]string{"n"}, []string{"1"}))79+ for _, a := range []string{"A1", "A2", "A3"} {80+ if err := s.Set(at(a), "x"); err != ErrRegionEdit {81+ t.Errorf("Set %s: %v, want ErrRegionEdit", a, err)82+ }83+ }84+ if a, r, ok := s.InRegion(rect("A3:C9")); !ok || a != at("A3") || r.Name != "r1" {85+ t.Errorf("InRegion = %v %v %v", a, r.Name, ok)86+ }87+ // Formatting stays, and the value with it.88+ s.Batch(Change{Label: "bold"}, func() error {89+ c := s.Cell(at("A3")).clone()90+ c.Style.Bold = true91+ s.place(at("A3"), c)92+ return nil93+ })94+ if c := s.Cell(at("A3")); c == nil || !c.Style.Bold || c.Value.Num != 1 {95+ t.Errorf("A3 after bold: %+v", c)96+ }97+ // Saved: the definition, not the table.98+ t2 := roundTrip(t, s)99+ if !t2.Notebook() || len(t2.Regions()) != 1 || t2.RegionShown("r1") {100+ t.Fatalf("read back: notebook %v, regions %+v", t2.Notebook(), t2.Regions())101+ }102+ wantShown(t, t2, map[string]string{"A1": "r1 ls not run", "A3": ""})103+ if !t2.Cell(at("A3")).Style.Bold {104+ t.Error("the table's formatting wasn't kept")105+ }106+}107+108+func TestRegionFormulaName(t *testing.T) {109+ s := notebook(t)110+ w := s.Book()111+ other, _ := w.AddSheet("Sheet2", 1)112+ if err := other.Set(at("A1"), "=SUM(nu.r1)"); err != nil {113+ t.Fatal(err)114+ }115+ wantShown(t, other, map[string]string{"A1": "#NAME?"})116+ addShown(t, s, "r1", "ls", table([]string{"n"}, []string{"1"}, []string{"2"}))117+ wantShown(t, other, map[string]string{"A1": "3"})118+ s.ShowRegion("r1", table([]string{"n"}, []string{"5"}))119+ wantShown(t, other, map[string]string{"A1": "5"})120+ if err := w.DefineName("nu.r2", s, rect("A1")); err != nil {121+ t.Fatal(err)122+ }123+ if err := s.AddRegion(Region{Name: "r2", Command: "x"}); err == nil {124+ t.Error("a region took a named range's name")125+ }126+}127+128+func TestRegionGraph(t *testing.T) {129+ s := notebook(t)130+ w := s.Book()131+ addShown(t, s, "r1", "ls", table([]string{"n"}))132+ addShown(t, s, "r2", "$r1 | first", table([]string{"n"}))133+ addShown(t, s, "big", "$r2 | append $r1", table([]string{"n"}))134+ addShown(t, s, "r3", "$env.PATH | $in", table([]string{"n"}))135+ if got := w.RefreshOrder("r1"); !slices.Equal(got, []string{"r1", "r2", "big"}) {136+ t.Errorf("refresh r1: %v", got)137+ }138+ if got := w.RefreshOrder("r2"); !slices.Equal(got, []string{"r2", "big"}) {139+ t.Errorf("refresh r2: %v", got)140+ }141+ if got := w.RunOrder(); !slices.Equal(got, []string{"r1", "r2", "big", "r3"}) {142+ t.Errorf("run all: %v", got)143+ }144+ if err := s.EditRegion("r1", "$big", w.RegionDeps("$big"), ""); err == nil {145+ t.Error("a cycle was accepted")146+ }147+ if err := s.EditRegion("r1", "$r1", w.RegionDeps("$r1"), ""); err == nil {148+ t.Error("a region reading itself was accepted")149+ }150+ if got := RegionRefs("$r1 | where x == $in.a and $nu.home | $r_2.x"); !slices.Equal(got, []string{"r1", "r_2"}) {151+ t.Errorf("refs %v", got)152+ }153+}154+155+func TestRegionFreezeAndSort(t *testing.T) {156+ s := notebook(t)157+ addShown(t, s, "r1", "ls", table([]string{"n", "s"}, []string{"b", "2"}, []string{"a", "10"}, []string{"c", ""}))158+ if err := s.SortRegion("r1", []SortKey{{Col: 1, Desc: true}}); err != nil {159+ t.Fatal(err)160+ }161+ wantShown(t, s, map[string]string{"A3": "a", "A4": "b", "A5": "c"})162+ // Running again keeps the order.163+ s.ShowRegion("r1", table([]string{"n", "s"}, []string{"x", "1"}, []string{"y", "3"}))164+ wantShown(t, s, map[string]string{"A3": "y", "A4": "x"})165+ if err := s.FreezeRegion("r1"); err != nil {166+ t.Fatal(err)167+ }168+ wantShown(t, s, map[string]string{"A1": "", "A2": "n", "A3": "y", "B3": "3"})169+ if err := s.Set(at("A3"), "z"); err != nil {170+ t.Errorf("a frozen cell can't be edited: %v", err)171+ }172+ s.Book().Undo()173+ s.Book().Undo()174+ wantShown(t, s, map[string]string{"A1": "r1 ls", "A3": "y"})175+}176+177+func TestRegionBlocked(t *testing.T) {178+ s := New()179+ if err := s.Set(at("B3"), "mine"); err != nil {180+ t.Fatal(err)181+ }182+ if err := s.AddRegion(Region{Name: "feed", Command: "tail", At: at("A1")}); err != nil {183+ t.Fatal(err)184+ }185+ s.ShowRegion("feed", table([]string{"a", "b"}, []string{"1", "2"}, []string{"3", "4"}))186+ wantShown(t, s, map[string]string{"A1": "feed tail can't show: it would overwrite data in B3", "A2": ""})187+ s.Set(at("B3"), "")188+ wantShown(t, s, map[string]string{"A1": "feed tail", "A2": "a", "B3": "2", "B4": "4"})189+}
A internal/sheet/regionfile.go +109 -0
@@ -0,0 +1,109 @@
1+package sheet2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+)8+9+// fileRegion is a region in the file: its definition, one line per10+// region after the cells of its sheet, never its table, which its11+// command makes again when it runs. They need no version: earlier builds12+// ignore them and open the sheet without its regions.13+//14+// "notebook": true,15+// "regions": [16+// {"name": "r1", "command": "ls", "at": "A1", "rows": 12, "cols": 4},17+// {"name": "big", "command": "$r1 | where size > 1kb", "at": "A16", "rows": 3, "cols": 4, "reads": ["r1"]}18+// ]19+type fileRegion struct {20+ Name string `json:"name"`21+ Command string `json:"command"`22+ At string `json:"at"`23+ Rows int `json:"rows,omitempty"`24+ Cols int `json:"cols,omitempty"`25+ Reads []string `json:"reads,omitempty"`26+ Input string `json:"input,omitempty"`27+ Sort []fileRegionSort `json:"sort,omitempty"`28+}29+30+// fileRegionSort is a column a region's table is sorted by, counted from31+// 1 at the region's first column.32+type fileRegionSort struct {33+ Column int `json:"column"`34+ Desc bool `json:"desc,omitempty"`35+}36+37+// writeRegionDefs writes the notebook flag and the regions, one per line.38+func (s *Sheet) writeRegionDefs(b *bufio.Writer, indent string) error {39+ if s.regions.notebook {40+ b.WriteString(",\n" + indent + `"notebook": true`)41+ }42+ if len(s.regions.list) == 0 {43+ return nil44+ }45+ b.WriteString(",\n" + indent + `"regions": [`)46+ for i, r := range s.regions.list {47+ fr := fileRegion{Name: r.Name, Command: r.Command, At: r.At.String(), Rows: r.Rows, Cols: r.Cols, Reads: r.Deps, Input: r.Input}48+ for _, k := range r.Sort {49+ fr.Sort = append(fr.Sort, fileRegionSort{Column: k.Col + 1, Desc: k.Desc})50+ }51+ raw, err := json.Marshal(fr)52+ if err != nil {53+ return err54+ }55+ if i > 0 {56+ b.WriteByte(',')57+ }58+ fmt.Fprintf(b, "\n%s %s", indent, raw)59+ }60+ b.WriteString("\n" + indent + "]")61+ return nil62+}63+64+// readRegions reads a sheet's regions, not yet shown.65+func (s *Sheet) readRegions(notebook bool, frs []fileRegion) error {66+ s.regions.notebook = notebook67+ for _, fr := range frs {68+ r, err := decodeRegion(fr)69+ if err != nil {70+ return err71+ }72+ if _, _, dup := s.wb.Region(r.Name); dup || s.regionIndex(nameKey(r.Name)) >= 0 {73+ return fmt.Errorf("region %q is defined twice", r.Name)74+ }75+ s.regions.list = append(s.regions.list, r)76+ }77+ if len(s.regions.list) > 0 {78+ s.regions.data = map[string]*RegionData{}79+ s.regionsStale = true80+ }81+ return nil82+}83+84+func decodeRegion(fr fileRegion) (Region, error) {85+ if err := ValidRegionName(fr.Name); err != nil {86+ return Region{}, fmt.Errorf("region %q: %w", fr.Name, err)87+ }88+ at, ok := ParseAddr(fr.At)89+ if !ok {90+ return Region{}, fmt.Errorf("region %s: invalid cell %q", fr.Name, fr.At)91+ }92+ if fr.Rows < 0 || fr.Cols < 0 || at.Row+fr.Rows >= MaxRows || at.Col+fr.Cols > MaxCols {93+ return Region{}, fmt.Errorf("region %s: invalid size %d by %d", fr.Name, fr.Rows, fr.Cols)94+ }95+ r := Region{Name: fr.Name, Command: fr.Command, At: at, Rows: fr.Rows, Cols: fr.Cols, Input: fr.Input}96+ for _, d := range fr.Reads {97+ if err := ValidRegionName(d); err != nil {98+ return Region{}, fmt.Errorf("region %s reads %q: %w", fr.Name, d, err)99+ }100+ r.Deps = append(r.Deps, d)101+ }102+ for _, k := range fr.Sort {103+ if k.Column < 1 || k.Column > MaxCols {104+ return Region{}, fmt.Errorf("region %s: invalid sort column %d", fr.Name, k.Column)105+ }106+ r.Sort = append(r.Sort, SortKey{Col: k.Column - 1, Desc: k.Desc})107+ }108+ return r, nil109+}
A internal/sheet/regiongraph.go +149 -0
@@ -0,0 +1,149 @@
1+package sheet2+3+import (4+ "fmt"5+ "slices"6+ "strings"7+)8+9+// The regions' dependency graph: a command reading $r1 depends on region10+// r1, so refreshing r1 runs it again and then every region that reads11+// it, each after what it reads. A command can't read itself, directly12+// or through others.13+14+// RegionRefs returns the names a command reads as $name, in the order it15+// first names them, leaving out nushell's own variables.16+func RegionRefs(command string) []string {17+ var out []string18+ for i := 0; i < len(command); i++ {19+ if command[i] != '$' {20+ continue21+ }22+ j := i + 123+ for j < len(command) && (isLetter(command[j]) || isDigit(command[j]) || command[j] == '_') {24+ j++25+ }26+ name := command[i+1 : j]27+ if name != "" && ValidRegionName(name) == nil && !slices.Contains(out, name) {28+ out = append(out, name)29+ }30+ i = j - 131+ }32+ return out33+}34+35+// RegionDeps returns the regions a command reads: the names in it that36+// regions of the workbook have, as the regions spell them.37+func (w *Workbook) RegionDeps(command string) []string {38+ var out []string39+ for _, name := range RegionRefs(command) {40+ if _, r, ok := w.Region(name); ok && r.Name == name {41+ out = append(out, r.Name)42+ }43+ }44+ return out45+}46+47+// allRegions returns every region of the workbook's sheets, sheet by48+// sheet in run order.49+func (w *Workbook) allRegions() []Region {50+ var out []Region51+ for _, s := range w.sheets {52+ out = append(out, s.regions.list...)53+ }54+ return out55+}56+57+// checkDeps refuses deps for the region name when one of them reads it,58+// directly or through others.59+func (w *Workbook) checkDeps(name string, deps []string) error {60+ k := nameKey(name)61+ byKey := map[string]Region{}62+ for _, r := range w.allRegions() {63+ byKey[nameKey(r.Name)] = r64+ }65+ seen := map[string]bool{}66+ var reads func(d string) bool67+ reads = func(d string) bool {68+ dk := nameKey(d)69+ if dk == k {70+ return true71+ }72+ if seen[dk] {73+ return false74+ }75+ seen[dk] = true76+ return slices.ContainsFunc(byKey[dk].Deps, reads)77+ }78+ for _, d := range deps {79+ if reads(d) {80+ return fmt.Errorf("%s can't read $%s, which reads %s: that would be a cycle", name, d, name)81+ }82+ }83+ return nil84+}85+86+// RefreshOrder returns the regions to run when name is refreshed: name,87+// then every region that reads it, directly or through others, each88+// after the regions it reads.89+func (w *Workbook) RefreshOrder(name string) []string {90+ regions := w.allRegions()91+ want := map[string]bool{nameKey(name): true}92+ for changed := true; changed; {93+ changed = false94+ for _, r := range regions {95+ k := nameKey(r.Name)96+ if !want[k] && slices.ContainsFunc(r.Deps, func(d string) bool { return want[nameKey(d)] }) {97+ want[k], changed = true, true98+ }99+ }100+ }101+ return w.ordered(regions, want)102+}103+104+// RunOrder returns every region, each after the regions it reads, in run105+// order otherwise: what Run all runs.106+func (w *Workbook) RunOrder() []string {107+ regions := w.allRegions()108+ want := map[string]bool{}109+ for _, r := range regions {110+ want[nameKey(r.Name)] = true111+ }112+ return w.ordered(regions, want)113+}114+115+// ordered lists the regions want names, each after those it reads among116+// them, otherwise in run order.117+func (w *Workbook) ordered(regions []Region, want map[string]bool) []string {118+ var out []string119+ done := map[string]bool{}120+ for len(out) < len(want) {121+ progress := false122+ for _, r := range regions {123+ k := nameKey(r.Name)124+ if !want[k] || done[k] {125+ continue126+ }127+ ready := !slices.ContainsFunc(r.Deps, func(d string) bool { dk := nameKey(d); return want[dk] && !done[dk] })128+ if ready {129+ out, done[k], progress = append(out, r.Name), true, true130+ }131+ }132+ if !progress {133+ break // a cycle a file made: what's left doesn't run134+ }135+ }136+ return out137+}138+139+// DependsOn returns the regions that read name directly, for saying what140+// deleting or freezing it leaves without input.141+func (w *Workbook) DependsOn(name string) []string {142+ var out []string143+ for _, r := range w.allRegions() {144+ if slices.ContainsFunc(r.Deps, func(d string) bool { return strings.EqualFold(d, name) }) {145+ out = append(out, r.Name)146+ }147+ }148+ return out149+}
A internal/sheet/regionwrite.go +409 -0
@@ -0,0 +1,409 @@
1+package sheet2+3+import (4+ "slices"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// Writing regions' cells (see region.go): after a change that touched10+// regions, each stale sheet's labels and tables are written again, only11+// the cells that differ, and cells no region covers any more are12+// cleared. A table that would overwrite other contents isn't shown; its13+// label says where it's blocked, and clearing that cell shows it.14+15+// regionArea is the cells a region covers: its label line and table, at16+// least one column wide.17+func (s *Sheet) regionArea(r Region) Rect {18+ return Rect{From: r.At, To: Addr{Col: min(r.At.Col+max(r.Cols, 1)-1, MaxCols-1), Row: min(r.At.Row+r.Rows, MaxRows-1)}}19+}20+21+// RegionTable returns the cells of a region's table, header row22+// included, when it has one.23+func (s *Sheet) RegionTable(name string) (Rect, bool) {24+ i := s.regionIndex(nameKey(name))25+ if i < 0 {26+ return Rect{}, false27+ }28+ r := s.regions.list[i]29+ if r.Rows == 0 || r.Cols == 0 || r.At.Row+1 >= MaxRows {30+ return Rect{}, false31+ }32+ area := s.regionArea(r)33+ area.From.Row++34+ return area, true35+}36+37+// covered is the cells a region holds: those it wrote, or while it's38+// yet to write them, those its definition reserves.39+func (s *Sheet) covered(r Region) Rect {40+ if me := s.regionMeta[nameKey(r.Name)]; me != nil && me.has {41+ return me.written42+ }43+ return s.regionArea(r)44+}45+46+// RegionAt returns the region covering the cell at a, and whether a is on47+// its label line.48+func (s *Sheet) RegionAt(a Addr) (Region, bool, bool) {49+ for _, r := range s.regions.list {50+ if s.covered(r).Contains(a) {51+ return r, a.Row == r.At.Row, true52+ }53+ }54+ return Region{}, false, false55+}56+57+// InRegion returns a cell of r a region covers, and the region.58+func (s *Sheet) InRegion(r Rect) (Addr, Region, bool) {59+ for _, reg := range s.regions.list {60+ if overlap, ok := intersectRect(s.covered(reg), r); ok {61+ return overlap.From, reg, true62+ }63+ }64+ return Addr{}, Region{}, false65+}66+67+// SetRegionStatus sets what a region's label says it's doing, such as68+// "Running…", "" for nothing. It isn't an undo step.69+func (s *Sheet) SetRegionStatus(name, status string) {70+ k := nameKey(name)71+ if s.regionIndex(k) < 0 {72+ return73+ }74+ me := s.meta(k)75+ if me.status == status {76+ return77+ }78+ me.status = status79+ s.regionsStale = true80+ if s.wb.hist.open == nil {81+ s.wb.settleRegions()82+ }83+}84+85+// meta is the region with key k's meta, made when missing.86+func (s *Sheet) meta(k string) *regionMeta {87+ if s.regionMeta == nil {88+ s.regionMeta = map[string]*regionMeta{}89+ }90+ me := s.regionMeta[k]91+ if me == nil {92+ me = &regionMeta{}93+ s.regionMeta[k] = me94+ }95+ return me96+}97+98+// regionTouched has the regions write their cells again after the cell99+// at a is placed in their way or over their cells.100+func (s *Sheet) regionTouched(a Addr) {101+ for _, r := range s.regions.list {102+ if s.regionArea(r).Contains(a) || s.blockedAt(nameKey(r.Name), a) {103+ s.regionsStale = true104+ return105+ }106+ }107+}108+109+// blockedAt reports whether the region with key k isn't shown because of110+// the cell at a's row and columns.111+func (s *Sheet) blockedAt(k string, a Addr) bool {112+ me := s.regionMeta[k]113+ if me == nil || me.why == "" {114+ return false115+ }116+ i := s.regionIndex(k)117+ if i < 0 {118+ return false119+ }120+ r := s.regions.list[i]121+ d := s.regions.data[k]122+ if d == nil {123+ return false124+ }125+ need := Rect{From: r.At, To: Addr{Col: r.At.Col + max(d.Cols, 1) - 1, Row: r.At.Row + d.Rows}}126+ return need.Contains(a)127+}128+129+// settleRegions writes the cells of the sheets whose regions changed,130+// then recalculates what reads them.131+func (w *Workbook) settleRegions() {132+ if w.settling {133+ return134+ }135+ w.settling = true136+ defer func() { w.settling = false }()137+ var changed []loc138+ for _, s := range w.sheets {139+ if s.regionsStale {140+ s.regionsStale = false141+ changed = append(changed, s.writeRegions()...)142+ }143+ }144+ if len(changed) > 0 {145+ w.recalc(changed)146+ }147+}148+149+// regionCell is a cell a region writes: its value and the format it150+// comes in.151+type regionCell struct {152+ v Value153+ f Format154+}155+156+// writeRegions writes every region's cells on s and clears those no157+// region covers any more, returning the cells that changed and the158+// formulas naming a region whose table moved.159+func (s *Sheet) writeRegions() []loc {160+ var old []Rect161+ for k, me := range s.regionMeta {162+ if me.has {163+ old = append(old, me.written)164+ }165+ if s.regionIndex(k) < 0 {166+ delete(s.regionMeta, k)167+ }168+ }169+ want := map[Addr]regionCell{}170+ var changed []loc171+ for _, r := range s.regions.list {172+ changed = append(changed, s.planRegion(r, want)...)173+ }174+ for _, area := range old {175+ changed = append(changed, s.clearRegion(area, want)...)176+ }177+ for a, c := range want {178+ if s.writeRegionCell(a, c) {179+ changed = append(changed, loc{s, a})180+ }181+ }182+ return changed183+}184+185+// planRegion adds the cells r writes to want, recording where they are,186+// and returns the formulas to recalculate because its table moved.187+func (s *Sheet) planRegion(r Region, want map[Addr]regionCell) []loc {188+ k := nameKey(r.Name)189+ me := s.meta(k)190+ d := s.regions.data[k]191+ me.why = ""192+ area := s.regionArea(r)193+ if d != nil {194+ need := Rect{From: r.At, To: Addr{Col: r.At.Col + max(d.Cols, 1) - 1, Row: r.At.Row + d.Rows}}195+ if need.To.Row >= MaxRows || need.To.Col >= MaxCols {196+ me.why = "can't show: it would go past the edge of the sheet"197+ } else if at, ok := s.inTheWay(need, me); ok {198+ me.why = "can't show: it would overwrite data in " + at.String()199+ } else {200+ area = need201+ }202+ }203+ if me.why != "" {204+ area = Rect{From: r.At, To: r.At} // the label says why205+ }206+ var moved []loc207+ if !me.has || me.written != area {208+ for u := range s.wb.nameUsers[nameKey(r.FormulaName())] {209+ moved = append(moved, u)210+ }211+ }212+ me.written, me.has = area, true213+ if !s.writable(r.At, me) {214+ return moved215+ }216+ want[r.At] = regionCell{v: Value{Kind: Text, Str: s.regionLabel(r)}}217+ if d == nil || me.why != "" {218+ return moved219+ }220+ order := sortedTable(d, r.Sort)221+ for row := range d.Rows {222+ for col := range d.Cols {223+ v, f := d.At(order[row], col)224+ if v.Kind != Empty {225+ want[Addr{Col: r.At.Col + col, Row: r.At.Row + 1 + row}] = regionCell{v, f}226+ }227+ }228+ }229+ return moved230+}231+232+// regionLabel is what a region's label line says: its name and command,233+// and what it's doing.234+func (s *Sheet) regionLabel(r Region) string {235+ label := r.Name + " " + r.Command236+ if st := s.RegionStatus(r.Name); st != "" {237+ label += " " + st238+ }239+ return label240+}241+242+// inTheWay returns a cell of need holding something the region with243+// meta me didn't write.244+func (s *Sheet) inTheWay(need Rect, me *regionMeta) (Addr, bool) {245+ for a := range s.cells.anyKeysIn(need) {246+ if s.cells.filledAt(a) && !s.regionOwns(a, me) {247+ return a, true248+ }249+ }250+ return Addr{}, false251+}252+253+// writable reports whether the cell at a is free for the region with254+// meta me to write.255+func (s *Sheet) writable(a Addr, me *regionMeta) bool {256+ return !s.cells.filledAt(a) || s.regionOwns(a, me)257+}258+259+// regionOwns reports whether the cell at a is one the region with meta260+// me wrote: a derived cell of what it wrote last that no formula261+// spilled.262+func (s *Sheet) regionOwns(a Addr, me *regionMeta) bool {263+ if !me.has || !me.written.Contains(a) || s.cells.derivedAt(a) != slotSpill {264+ return false265+ }266+ _, spilled := s.SpillAnchor(a)267+ return !spilled268+}269+270+// clearRegion clears the cells a region wrote in area that want doesn't271+// cover.272+func (s *Sheet) clearRegion(area Rect, want map[Addr]regionCell) []loc {273+ var gone []Addr274+ for a := range s.cells.anyKeysIn(area) {275+ if _, ok := want[a]; ok || s.cells.derivedAt(a) != slotSpill {276+ continue277+ }278+ if _, spilled := s.SpillAnchor(a); !spilled {279+ gone = append(gone, a)280+ }281+ }282+ changed := make([]loc, len(gone))283+ for i, a := range gone {284+ s.setDerived(a, s.cells.get(a).leftover())285+ changed[i] = loc{s, a}286+ }287+ return changed288+}289+290+// writeRegionCell makes the cell at a show c as a region's, keeping its291+// formatting and note, and reports whether it changed.292+func (s *Sheet) writeRegionCell(a Addr, c regionCell) bool {293+ was, l, kind := s.cells.derivedOf(a)294+ if kind == slotSpill && was == c.v && l.auto == c.f {295+ return false296+ }297+ return s.writeSpilled(a, c.v, c.f)298+}299+300+// sortedTable is the order of d's rows: the header first, then the rest301+// by keys.302+func sortedTable(d *RegionData, keys []SortKey) []int {303+ order := make([]int, d.Rows)304+ for i := range order {305+ order[i] = i306+ }307+ if len(keys) == 0 || d.Rows < 3 {308+ return order309+ }310+ slices.SortStableFunc(order[1:], func(i, j int) int {311+ for _, k := range keys {312+ a, _ := d.At(i, k.Col)313+ b, _ := d.At(j, k.Col)314+ if c := compareKey(a, b, k.Desc); c != 0 {315+ return c316+ }317+ }318+ return 0319+ })320+ return order321+}322+323+// compareKey orders two values of a sort key, blanks last either way.324+func compareKey(a, b Value, desc bool) int {325+ switch {326+ case a.Kind == Empty && b.Kind == Empty:327+ return 0328+ case a.Kind == Empty:329+ return 1330+ case b.Kind == Empty:331+ return -1332+ case desc:333+ return -sortCompare(a, b)334+ }335+ return sortCompare(a, b)336+}337+338+// shiftRegions keeps the sheet's regions in step with rows or columns339+// inserted or deleted: a region moves and resizes as a range would, and340+// one whose label line was deleted goes.341+func (s *Sheet) shiftRegions(rows bool, sp formula.Span) {342+ if len(s.regions.list) == 0 {343+ return344+ }345+ cell, rng := formula.AxisMaps(rows, sp)346+ st := s.regionsCopy()347+ st.list = st.list[:0]348+ for _, r := range s.regions.list {349+ at, ok := cell(r.At)350+ area, ok2 := rng(s.regionArea(r))351+ if !ok || !ok2 {352+ delete(st.data, nameKey(r.Name))353+ continue354+ }355+ r.At, r.Rows = at, area.To.Row-at.Row356+ if r.Cols > 0 {357+ r.Cols = area.To.Col - at.Col + 1358+ }359+ st.list = append(st.list, r)360+ }361+ for _, me := range s.regionMeta {362+ if w, ok := rng(me.written); ok && me.has {363+ me.written = w364+ }365+ }366+ if !sameRegions(st, s.regions) {367+ s.putRegions(st)368+ }369+ s.regionsStale = true370+}371+372+// spanOf is n rows inserted at row at, or deleted when n is negative.373+func spanOf(at, n int) formula.Span { return formula.Span{At: at, N: n, Size: MaxRows} }374+375+// regionName resolves a name formulas use, nu.r1, to the region's sheet376+// and table.377+func (w *Workbook) regionName(k string) (*Sheet, Rect, bool) {378+ if len(k) <= len(regionPrefix) || k[:len(regionPrefix)] != nameKey(regionPrefix) {379+ return nil, Rect{}, false380+ }381+ for _, s := range w.sheets {382+ if i := s.regionIndex(k[len(regionPrefix):]); i >= 0 {383+ r, ok := s.RegionTable(s.regions.list[i].Name)384+ if !ok {385+ return s, Rect{}, true386+ }387+ return s, r, true388+ }389+ }390+ return nil, Rect{}, false391+}392+393+// regionsInUse are the regions formulas name, with their tables, for394+// finding what reads a changed cell.395+func (w *Workbook) regionsInUse() []namedUsers {396+ var out []namedUsers397+ for _, s := range w.sheets {398+ for _, r := range s.regions.list {399+ users := w.nameUsers[nameKey(r.FormulaName())]400+ if len(users) == 0 {401+ continue402+ }403+ if t, ok := s.RegionTable(r.Name); ok {404+ out = append(out, namedUsers{s, t, users})405+ }406+ }407+ }408+ return out409+}
M internal/sheet/sheet.go +13 -0
M internal/sheet/workbook.go +5 -0