- commit
- 462ac3b222a44a49ba13982565fa153529438d46
- parent
- c7e3b3ae736b3c31ddb0e66d0e625e5eb2ed0a03
- parent
- 85fd593027b2c8132193c416ff4403ea5ae894b4
- author
- Dami <dami.etoile@gmail.com>
@@ -65,7 +65,6 @@ shared editing in section 3.
6565 6666 | Item | Result | Size |6767 |---|---|---|68-| Named tables with structured references (`Sales[Amount]`); notebook regions are tables | Formulas that read by column name | M |6968 | A sheet or chart exported as a static HTML page in 012's look | Sharing a sheet with someone without 012 | S |7069 | Release archives and an install script served from the owner's nzip server | Installing without Go | S |7170 @@ -114,6 +113,7 @@ stream above rather than a design of their own.
114113 - Conditional formatting (single-color rules, color scales) and data validation (dropdowns, checkboxes, bounds), checked on pastes and fills and moving with cut and paste: [Conditional formatting and data validation](docs/sheets/rules.md#conditional-formatting)115114 - Data bars, icon sets, top values, averages, duplicates and date periods, dropdown chips and checkboxes of their own values, in XLSX both ways; rules moving to other sheets, and macros' pastes and fills checked: [Conditional formatting and data validation](docs/sheets/rules.md#conditional-formatting)116115 - Pivot tables and frequency tables, live, with subtotals and renamed values: [Pivot tables](docs/sheets/pivots.md)116+- Named tables read by column name (`Sales[Amount]`, `Sales[@Amount]`), growing with their rows, in `.012` and XLSX both ways; notebook outputs are tables too: [Tables](docs/sheets/tables.md)117117 - Notes on cells: [Notes and protection](docs/sheets/notes-protection.md#notes)118118 - Protected sheets and ranges that warn on edit: [Notes and protection](docs/sheets/notes-protection.md#protected-sheets-and-ranges)119119 - Charts (column, bar, line, area, pie, scatter; stacking, trend lines, axis and legend options), as images or text: [Charts](docs/sheets/charts.md)@@ -100,6 +100,7 @@ there.
100100 | Values an array spilled | Italic, and `Spilled from B2` on the context line |101101 | Linked files' rows, following, paused or failing | Italic; `●`, `‖` or `!` beside the tab's name and on the context line, with the state in words (`Following`, `Paused`, the error) |102102 | A notebook's cells and outputs | `❯` before the tab's name; `▌` left of the active cell and `▎` left of the others selected; a heavy box around the cell being edited; the prompt's and the box's words for its state (`[*]:` and `running`, `waiting`, `failed`, `stale`); `■` for `▶` while it runs; `output hidden` for a hidden output, and which rows show for one scrolled; `×` before an error; an output table's header bold and underlined; an output sent to a sheet in italic, with `Output of files` on the context line; a problem nu finds in the cell being written curly-underlined, in words on the context line with the caret on it |103+| A table's header row | Bold and underlined, when the table styles its header; `Table Sales, column Amount` on the context line |103104 | Pivot table results | Their headings (`SUM of Units`, `Grand Total`), and a note in words when an edit is refused |104105 | Protected ranges | A question in words before an edit (`A1:B2 is protected.`) |105106 | Notes | A `▝` in the cell's top-right corner |@@ -10,7 +10,7 @@ sidebar_position: 2
1010 number formats, bold, italic, underline, strikethrough, alignment,1111 [wrapped text, borders, row heights and merged cells](#layout-in-excel),1212 column widths, column and row styles, frozen panes, [filters](#filters-in-excel),13-notes (Excel's notes, its legacy comments; threaded comments aren't13+[tables](#tables-in-excel), notes (Excel's notes, its legacy comments; threaded comments aren't1414 read), [conditional formatting and data validation](#rules-in-excel),1515 [array formulas](#arrays-in-excel),1616 dates in the 1904 system, and hidden sheets (unless it's the one Excel@@ -20,7 +20,8 @@ showed).
2020 cached, column and row formats as column and row styles, frozen rows and2121 columns as frozen panes, a filter as Excel's with the rows it hides2222 hidden, notes as Excel's notes (which Sheets reads as notes), and23-formulas that spill as Excel 365's [dynamic array formulas](#arrays-in-excel).23+formulas that spill as Excel 365's [dynamic array formulas](#arrays-in-excel), and24+[tables](#tables-in-excel) as Excel's tables.2425 2526 What changes on the way:2627 @@ -32,6 +33,7 @@ What changes on the way:
3233 | Excel's sheet protection | Comes in unprotected, with a note saying so: Excel's protection locks cells where 012's only warns. Protected ranges aren't written |3334 | Array formulas (Excel 365's dynamic arrays and older `{=...}` ones) | Come in as formulas that spill again, without the values Excel kept in the cells they spill into |3435 | Functions only Sheets has, and array literals holding references | Go out as values, counted in the download's note: see [Arrays in Excel](#arrays-in-excel) |36+| Formulas reading a notebook output or a linked file by column (`app[status]`) | Go out as values: the file holds the output's cells but no table for Excel to read |3537 | JEV functions, `#AND#`, formulas naming a sheet that doesn't exist | Go out as values (their `#REF!`, for a missing sheet: Excel would refuse the reference). The download's result counts the formulas saved as values, with an example |3638 | A sheet name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) | Written as one it can: without the spaces, with a number when two would clash (`Plan (2)`); formulas and named ranges naming it name that |3739 | Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) | Refused, with a message saying which |@@ -70,8 +72,26 @@ with a note saying how many criteria and which: two conditions in one
7072 column (`and`, `or`), wildcards other than at the ends of the text (`a?c`,7173 `a*c`), top 10, dynamic filters (above average, this month), dates7274 grouped by year or month, and filtering by color or icon. The filter73-itself still comes in over its range. Filters of Excel tables (as74-opposed to the sheet's AutoFilter) aren't read.75+itself still comes in over its range. A table's filter is its own in76+Excel: see [Tables in Excel](#tables-in-excel).77+78+## Tables in Excel79+80+A [table](../sheets/tables.md) goes out as an Excel table over the same81+range, with its columns' names, and an Excel table comes in as a table:82+83+| 012 | Excel |84+|---|---|85+| The table's name | Its `name` and `displayName` |86+| Structured references (`Sales[@Amount]`) | The same, with `@` written as Excel's files spell it: `Sales[[#This Row],[Amount]]` |87+| Header style | Excel's default table style, `TableStyleMedium2`; coming in, any table style |88+| Banded rows | Row stripes (`showRowStripes`) |89+| A filter over the table's range | The table's filter, rather than the sheet's |90+| A number in the header row | Written as the text it shows, which Excel needs of a header |91+92+Coming in, a totals row stays as cells below the table, and formulas93+reading `[#Totals]` show `#REF!`; a table without a header row, or named94+as a named range or another table is, is left out with a note.7595 7696 ## Arrays in Excel7797 @@ -76,6 +76,7 @@ still load.
7676 | 3 | A sheet has named ranges, frozen panes or a filter | `names` (each name's range, `#REF!` once its cells were deleted), `freeze` (`rows`, `cols`), `filter` (its `range`, and `columns` by letter, each with `hidden` values or a `condition` and its `value`) |7777 | 4 | Several sheets, or a formula naming a sheet | `sheets`, a list; see [Several sheets](#several-sheets) |7878 | 5 | A pivot table | a sheet's `pivot`; see [Pivot tables](#pivot-tables) |79+| 6 | A table | a sheet's `tables`; see [Tables](#tables) |7980 8081 What stays the same from release to release:8182 @@ -186,6 +187,26 @@ with its data.
186187 `condition` with its `value`.187188 - `rowTotals` and `columnTotals` are the grand total row and column.188189 190+## Tables191+192+A sheet's [tables](../sheets/tables.md) are a `tables` list among its193+view's fields, before its cells:194+195+```json196+ "tables": [{"name":"Sales","range":"A1:C20","columns":["Region","Month","Amount"],"banded":true,"header":true}],197+```198+199+- `name` is what formulas name it by (`Sales[Amount]`).200+- `range` is its cells, header row included.201+- `columns` are its columns' names, left to right, as its header row202+ shows them. Left out, or of another number than the range's columns,203+ they're read from the header row.204+- `banded` shades every other data row, and `header` styles the header205+ row.206+207+Tables need version 6: a build without them can't read the formulas208+that read them, and refuses the file rather than show them as errors.209+189210 ## Regions190211 191212 A sheet's regions, [linked files](following.md) and notebook cells'@@ -42,6 +42,7 @@ What it compares:
4242 | Cells | `input` (what was typed: a formula or an entry), `value` (what a formula computes, or an array spilled), `format` (the cell's formatting, by its fields in the file), `note` |4343 | Notebooks | Each [notebook](../nushell/notebooks.md) cell, lined up by its source: `added`, `removed`, or which field changed (`source`, `output`, `error`) |4444 | Regions | Each [linked file](following.md) or notebook output sent to a sheet, by name: added, removed, or which field changed |45+| Tables | Each [table](../sheets/tables.md), by name, the same way: `Q3 table Sales range A1:C9 → A1:C10` |4546 | Layout | Every other field of a sheet, by its name in [the file](format.md): `widths`, `heights` and `lines` key by key, the rest (`charts`, `conditionalFormats`, `freeze`) whole |4647 | Workbook | Named ranges, macros, the locale and decimal arithmetic |4748 @@ -105,7 +106,7 @@ on its own:
105106 106107 ```mermaid107108 flowchart TD108- part["A part: a cell's input, format or note; a sheet's name; a column's width; a region; a notebook's cells; a named range"]109+ part["A part: a cell's input, format or note; a sheet's name; a column's width; a region; a table; a notebook's cells; a named range"]109110 part --> same{"Did both sides change it?"}110111 same -- "One side, or neither" --> take["Take the side that changed it"]111112 same -- "Both, alike" --> take@@ -30,7 +30,8 @@ it isn't the one shown when the file was saved:
3030 |---|---|3131 | `B7`, `A1:C9`, `A:A`, `$B$7` | Cells of the sheet shown when the file was saved |3232 | `Q3!B7`, `'Q3 plan'!A1:C9` | Cells of the sheet named, quoted as in formulas when the name has spaces (the shell needs quotes around it too) |33-| `Sales` | A [named range](../formulas/references.md) |33+| `Sales` | A [named range](../formulas/references.md), or a [table](../sheets/tables.md) or notebook output, header row included, so JSON and NUON name its columns |34+| `Sales[Amount]`, `Sales[[#Headers],[Amount]]` | A table's cells by a [structured reference](../formulas/references.md#tables-by-column-name), as a formula outside the table reads them: `Sales[Amount]` is the column's data, without its header |3435 | `Q3`, `Q3!` | The whole sheet, from A1 to its last cell with contents |3536 3637 A reference that names nothing says what the workbook has: `no sheet@@ -8,13 +8,20 @@ sidebar_position: 3
88 - After an operator or `(`, arrow keys (or a click) pick a cell and insert99 its reference; Shift+arrows (or a drag) pick a range. Keep typing to go on.1010 - While you type a name, suggestions drop down: named ranges first, then11- the other sheets, then functions with their arguments. Tab or Enter11+ [tables](../sheets/tables.md), the other sheets, then functions with12+ their arguments. Tab or Enter1213 inserts `NAME(`, or a sheet with its `!`, quoted when it needs to be1314 (`=Su` offers `Summary!`; `=Q3` or `='Q3` offers `'Q3 plan'!`). An arrow1415 after it points into that sheet, as clicking its tab does. Hidden1516 sheets aren't offered, though formulas still read them.1617 - Inside a function's parentheses the context line shows its signature with1718 the current argument marked, e.g. `SUMIF(range, criterion, [sum_range])`.19+- Inside a table's brackets, `=SUM(Sales[a`, suggestions offer its20+ columns (after `#`, the items `#All`, `#Data`, `#Headers`, `#This Row`;21+ after `@`, the columns again), and Tab or Enter inserts one with its22+ `]`. The context line lists the table's columns, the one being typed23+ marked: `Sales[Region, Units, Amount]`24+ ([Tables by column name](references.md#tables-by-column-name)).1825 - Alt+; marks what the active cell's formula reads and the formulas that1926 read it, as the pointer moves; Alt+= steps through a formula one part2027 at a time. See [Tracing formulas](tracing.md).@@ -16,6 +16,8 @@ sidebar_position: 2
1616 - Named ranges: `=SUM(Sales)`. Define them from Data > Named ranges or Data >1717 Define named range. Names are case-insensitive, follow the selection when1818 rows move, and renaming one rewrites the formulas that use it.19+- Tables by column name: `=SUM(Sales[Amount])`, `=Sales[@Amount]*2`;20+ see [Tables by column name](#tables-by-column-name).1921 - Other sheets: `=Sheet2!A1`, `=SUM('Q3 plan'!B2:B9)`; names with spaces or2022 punctuation, or that look like a cell, go in single quotes. While typing a2123 formula, Ctrl+PgDn or clicking a tab points into another sheet and inserts@@ -31,6 +33,43 @@ sidebar_position: 2
3133 ten cells, and `ROWS(A:A)` is still 1,048,576. See [Bounds of support](../contributing/limits.md)3234 for what that means in practice.3335 36+## Tables by column name37+38+A [table](../sheets/tables.md), or a notebook output sent to a sheet, is39+read by its columns' names with a structured reference: the table's40+name, then in brackets what to read of it.41+42+| Reference | Reads |43+|---|---|44+| `Sales[Amount]` | The Amount column's data rows, under the header |45+| `Sales` or `Sales[]` | Every data row, header left out |46+| `Sales[#All]` | The whole table, header row included |47+| `Sales[#Headers]`, `Sales[#Data]` | The header row; the data rows |48+| `Sales[@Amount]` | The Amount cell in the formula's own row (`Sales[[#This Row],[Amount]]`, as Excel's files write it); outside the table's rows, `#REF!` |49+| `Sales[[#Headers],[Amount]]` | The Amount column's header cell |50+| `Sales[[Units]:[Amount]]` | The columns from Units to Amount |51+| `Sales[[#Headers],[#Data],[Units]]` | The Units column, header and data |52+53+Names match in any case. A column whose name has characters other than54+letters, digits, `_` and spaces goes in brackets of its own,55+`Sales[[Total, EUR]]`, and `[`, `]`, `#` and `'` in a name take a `'`56+before them: `Sales['#Items]`. In a locale that separates arguments with57+`;`, so are the items: `Sales[[#Headers];[Amount]]`.58+59+A structured reference is kept as written and read when the formula60+computes, so it follows the table: rows added at its end, columns61+inserted or moved, the table moved or resized. Renaming the table or a62+column's header rewrites the formulas that read it; a deleted column, or63+a deleted table, reads as `#REF!`. Copying, pasting and filling keep64+structured references as they are, since they name columns rather than65+positions: filled down a table, `Sales[@Units]*Sales[@Amount]` reads66+each row's own cells.67+68+While typing, the table's name is offered with the named ranges, and69+after its `[` its columns, or after `#` the items; the context line lists70+the columns with the one being typed marked71+([Building formulas](building.md)).72+3473 ## A range where one value is wanted3574 3675 A range used where a formula wants one value (with an operator, or as@@ -28,7 +28,7 @@ Alt+; again stops. What counts as a link:
2828 2929 | The active cell | Its precedents | Its dependents |3030 |---|---|---|31-| A formula | the cells and ranges it reads, on any sheet; a named range or a region (`nu.sales`) by its name | the formulas that read it through a reference, a range, a named range or a region's name, on any sheet |31+| A formula | the cells and ranges it reads, on any sheet; a named range, a region (`nu.sales`) or a [table's](../sheets/tables.md) cells (`Sales[Amount]`) by its name | the formulas that read it through a reference, a range, a named range, a table or a region's name, on any sheet |3232 | A formula whose array spills | as any formula | the cells it spills into, and the formulas reading any of them |3333 | A value an array spilled | the formula that spilled it | the formulas reading it |3434 | A cell of a linked file or of a notebook output sent to the sheet ([regions](../nushell/notebooks.md)) | the file, or the notebook cell | the formulas reading it, `nu.name` included |@@ -276,8 +276,8 @@ sooner.
276276 277277 A cell that starts `name =` gives its output that name; `n` names a278278 cell for you. Later cells read the output as `$name`, a nushell279-variable holding the value, and formulas on any sheet as `nu.name` once280-it's [sent to a sheet](#send-to-a-sheet):279+variable holding the value, and formulas on any sheet as `nu.name`, or280+a column of it as `name[column]`, once it's [sent to a sheet](#send-to-a-sheet):281281 282282 ```nu283283 files = ls@@ -338,7 +338,10 @@ with the notebook) runs them again whenever a cell they read runs.
338338 the cell, or a cell of a sheet you pick. There it's a region named after339339 the cell, italic as a linked file's rows are, that formulas read as340340 `nu.name`, header row included (`=SUM(nu.big)`, `=VLOOKUP("go.mod",341-nu.files, 3, FALSE)`), and charts and pivot tables use. A cell without a341+nu.files, 3, FALSE)`), and charts and pivot tables use. It's also a342+[table](../sheets/tables.md#notebook-outputs-and-linked-files) by the343+cell's name, read by column: `=SUM(big[size])`, `=COUNTIF(app[status],344+500)`, with `big` alone its rows under the header. A cell without a342345 name is given one first.343346 344347 ```mermaid@@ -22,7 +22,7 @@ mouse and the vim keymap added.
2222 | PgUp, PgDn | A screen up, down |2323 | Alt+PgUp, Alt+PgDn | A screen left, right |2424 | Home, Ctrl+Home, Ctrl+End | Column A, cell A1, the last used cell |25-| Ctrl+G, F5 | Go to a cell, a range, a named range or a region's table (`nu.r1`), on any sheet (`Sheet2!B3`) |25+| Ctrl+G, F5 | Go to a cell, a range, a named range, a [table](../sheets/tables.md) or a region's table (`r1` or `nu.r1`), on any sheet (`Sheet2!B3`) |2626 2727 ## Entering data2828 @@ -102,6 +102,7 @@ The rest of the formats are in the Format menu.
102102 | Alt+' | Go to a precedent or dependent, picked from a list | [Tracing formulas](../formulas/tracing.md#going-to-one) |103103 | Alt+= | Evaluate formula: step through the active cell's formula one part at a time; Enter evaluates, → steps into a reference, ← steps out | [Tracing formulas](../formulas/tracing.md#evaluating-a-formula-step-by-step) |104104 | Alt+Shift+F | Frequency table of the active column on a new sheet, as VisiData's Shift+F | [Pivot tables](../sheets/pivots.md#frequency-tables) |105+| Ctrl+Alt+T | Make the selection, or the data around the active cell, a table whose first row names its columns (Format > Convert to table) | [Tables](../sheets/tables.md) |105106 | Shift+F2 | Add or edit the active cell's note; Alt+Enter or Shift+Enter starts a new line | [Notes and protection](../sheets/notes-protection.md#notes) |106107 107108 Tools with no key of their own have their keys on screen while they're@@ -113,7 +114,9 @@ open, and in their docs: sorting by several columns
113114 ([Charts](../sheets/charts.md)) and linked files (Data > Linked file, where114115 Enter keeps every row and L the last ones:115116 [Following files](../files/following.md)). In Data > Named ranges, Enter goes to a116-range, F2 renames or repoints it and Ctrl+D deletes it.117+range, F2 renames or repoints it and Ctrl+D deletes it; in Data > Table >118+Tables, Enter goes to a table, F2 renames it and Ctrl+D removes it,119+keeping its cells.117120 118121 ## Files, menus and help119122 @@ -86,7 +86,7 @@ width, a name, a confirmation) needs `answer`: the text you'd type, a range
8686 for a range question, the key of a choice (`"enter"`, `"d"`) or its8787 label, or the item picked from a list (View > Hidden sheets takes the8888 sheet's name). A command that opens a dialog takes what you'd choose in89-it as a dict (see [Dialogs](#dialogs)). Other pickers (Named ranges) can't89+it as a dict (see [Dialogs](#dialogs)). Other pickers (Named ranges, Tables) can't9090 run in a script; nor can files, menus, help, undo or macros. The ids are9191 those in the recorded scripts; a few:9292 @@ -102,6 +102,7 @@ those in the recorded scripts; a few:
102102 | `column.width` (answer: the width), `column.reset` | Column widths |103103 | `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |104104 | `data.define_name` (answer: the name) | Name the selection |105+| `table.create` (answer: the name), `table.rename` (answer: the name), `table.resize` (answer: the range), `table.banded`, `table.header`, `table.remove` | [Tables](../sheets/tables.md): make the selection one, or change the one the active cell is in |105106 | `format.conditional_add`, `data.validation_add` (answer: the rule as a line of the file, see [The .012 format](../files/format.md#conditional-formats-and-data-validation)), `insert.checkbox`, `format.conditional_clear`, `data.validation_clear`, `data.checkbox_toggle` | Conditional formats, data validation and checkboxes |106107 | `format.conditional_set`, `data.validation_set` (answer: `{"rule": 2, ...}`, the rule's number and its new line), `format.conditional_remove`, `data.validation_remove` (answer: the rule's number), `format.conditional_move` (answer: `{"rule": 2, "to": 1}`) | Change the rules of the sheet shown, numbered from 1 as the panel lists them |107108 | `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks), `sheet.hide`, `sheet.unhide` (answer: the sheet's name) | Sheets |@@ -16,6 +16,7 @@ working with its data.
1616 | [Formatting](formatting.md) | Number formats, text styles, column and row formats, widths |1717 | [Sheets and tabs](sheets-and-tabs.md) | Several sheets: adding, moving, hiding |1818 | [Freeze, sort and filter](sort-filter.md) | Keeping headers on screen, sorting, filtering by values or conditions |19+| [Tables](tables.md) | Named ranges with a header row that formulas read by column name, and that grow with their rows |1920 | [Find and replace](find-replace.md) | Searching one sheet, all of them or a range |2021 | [Conditional formatting and data validation](rules.md) | Coloring cells by their values; dropdowns, checkboxes and entry rules |2122 | [Pivot tables](pivots.md) | Summaries of a table on a sheet of their own, and frequency tables |@@ -15,7 +15,8 @@ does.
1515 1616 Data > Sort sheet sorts by the active column A to Z or Z to A, keeping frozen1717 rows on top. Data > Sort range sorts the selection, or the block of data18-around the active cell; its advanced option opens a bar to sort by several18+around the active cell (in a [table](tables.md), the table, its header19+row kept in place); its advanced option opens a bar to sort by several1920 columns (Left/Right picks a column, Space flips the order, Alt+A adds a2021 column, Alt+H toggles the header row). The order follows Sheets: numbers,2122 then text ignoring case, then booleans, then errors, with blanks last.@@ -23,7 +24,8 @@ then text ignoring case, then booleans, then errors, with blanks last.
2324 ## Filter2425 2526 Data > Create a filter puts a filter on the selection or the table around26-the active cell; headers show `▾`. Alt+Down or a click on `▾` opens the27+the active cell (a [table](tables.md)'s range, which the filter then28+follows as the table grows); headers show `▾`. Alt+Down or a click on `▾` opens the2729 column's filter: check values in a list (Space checks one, typing2830 narrows the list), or pick a condition such as "greater than" or "text2931 contains". Filtered-out rows are hidden, not@@ -0,0 +1,109 @@
1+---2+title: "Tables"3+sidebar_position: 134+---5+6+# Tables7+8+A table is a named range whose first row names its columns, as in9+Sheets' and Excel's tables. Formulas read it by those names,10+`=SUM(Sales[Amount])`, so they keep reading the right cells as the table11+grows and its columns move ([Structured references](../formulas/references.md#tables-by-column-name)).12+13+## Making one14+15+Format > Convert to table (Ctrl+Alt+T) makes a table of the selection, or16+of the data around the active cell, and asks for its name on the context17+line (`Table1` to start). A name follows a named range's rules, letters,18+digits, `_` and `.`, except that `Table1` is fine: only names Excel reads19+as a cell are refused. Tables, named ranges and20+[notebook outputs](#notebook-outputs-and-linked-files) share one set of21+names, so a table can't take a name one of them has.22+23+The first row is the header row: each cell's text is its column's name.24+A blank header is named after its place (`Column3`) and a repeat25+numbered (`Amount2`), in the cell too, so the header shows what formulas26+use. A selection of one row gains an empty row of data below it.27+28+```29+ A B C30+ 1 Region Units Amount31+ 2 North 2 1032+ 3 South 3 2033+ 4 East 5 3034+```35+36+Made a table named `Sales`, this reads `Sales[Amount]` as C2:C4 and37+`Sales[#All]` as A1:C4; a value typed in row 5 joins it.38+39+On a table's cell the context line names the table and the column, and40+how a formula reads it: `Table Sales, column Amount: Sales[Amount] in41+formulas`.42+43+## Growing, resizing and removing44+45+A table grows by the rows typed or pasted just below it, in the same46+undo step as the entry, so Ctrl+Z takes the row back out. It grows by47+rows only: a column added at its right joins it through Data > Table >48+Resize table, which asks for the new range, pointed at from the49+table's own, header row first.50+51+Rows and columns inserted inside a table widen it, and deleting some52+narrows it; a column inserted comes in named from its (blank) header,53+`Column2`. Deleting a table's header row removes the table. Cutting the54+whole table and pasting it elsewhere, on this sheet or another, moves55+it.56+57+Changing a header renames the column in every formula that reads it:58+typing `Revenue` over `Amount` turns `Sales[Amount]` into59+`Sales[Revenue]`. Columns moved, inserted or deleted rename nothing;60+formulas reading a deleted column show `#REF!`.61+62+Data > Table > Rename table renames the table and every formula that63+reads it. Data > Table > Remove table makes it plain cells again: the64+cells stay, and formulas that read it read the same cells by address65+(`Sales[Amount]` becomes `$C$2:$C$4`, `Sales[@Amount]` the cell in the66+formula's own row, `$C3`). Data > Table > Tables lists every table and67+notebook output: Enter goes to one, F2 renames it and Ctrl+D removes it.68+69+## Banding and the header style70+71+Data > Table > Banded rows shades every other data row, and Header style72+draws the header row bold, underlined and in the theme's accent. A new73+table has its header styled and no bands: Sheets bands its tables, but74+the terminal's own palette has no shade subtle enough for every other75+row, so bands are there when you want them. They are the `TableBand` and `TableHeader` roles of the76+[theme](../terminal/themes.md): a scheme draws the band a shade off its77+background. The header's bold and underline read without color78+([UX](../contributing/ux.md#reading-without-color)); the band is79+decoration only.80+81+## Sorting and filtering a table82+83+Sorting or filtering with the active cell in a table takes the table's84+range, its header row kept in place: Data > Sort range sorts its rows by85+the active column, and Data > Create a filter puts the filter on it. A86+filter on a table's range follows the table as it grows or is resized.87+88+## Notebook outputs and linked files89+90+A notebook cell's output sent to a sheet, and a linked file, is a table91+by the region's name, with no table of its own to make: `app[status]`92+reads the `status` column of the output of the cell `app`, and `app`93+alone its rows under the header. `nu.app` stays what it was, the whole94+table header row included, the same cells as `app[#All]`. Its source95+names and shapes it, so the table commands leave it alone, and a table96+can't take its name ([Notebooks](../nushell/notebooks.md#send-to-a-sheet)).97+98+## Where 012 differs from Sheets and Excel99+100+- Copying, pasting and filling keep structured references as written:101+ they name columns, not positions. Excel shifts `[Amount]` to the next102+ column when a formula is filled right.103+- A table has no totals row and no calculated columns: a formula typed in104+ a table's column isn't filled down the column for you. An Excel table's105+ totals row comes in as cells below the table ([Excel files](../files/excel.md#tables-in-excel)).106+- A structured reference names its table, `Sales[@Amount]`, even inside107+ it; Excel's `[@Amount]` alone isn't read.108+- `[@Amount]` outside the table's data rows is `#REF!`, where Excel gives109+ `#VALUE!`.@@ -102,7 +102,7 @@ var screens = []screen{
102102 }},103103 {name: "error-goto", setup: func(s *session) {104104 s.keys("<f5>", "nope", "<enter>")105- s.waitFor("Not a cell, range, named range or region")105+ s.waitFor("Not a cell, range, named range, table or region")106106 }},107107 {name: "help", setup: func(s *session) {108108 s.keys("<f1>")@@ -0,0 +1,74 @@
1+package e2e2+3+// Golden screens for tables: a banded table with its header styled and4+// a formula reading it by column, the name asked for on converting, the5+// column suggestions inside a table's brackets, and the list of tables;6+// on a light terminal and in the high-contrast theme too.7+8+// sales types six rows of sales under a header, makes them the table9+// Sales with banded rows, and sums its Amount column beside it, the10+// cursor left on a cell of the table.11+func sales(s *session) {12+ s.keys("Region", "<tab>", "Month", "<tab>", "Units", "<tab>", "Amount", "<enter>")13+ for _, row := range [][]string{14+ {"North", "Jan", "12", "480"}, {"South", "Jan", "7", "280"}, {"East", "Feb", "20", "800"},15+ {"North", "Feb", "9", "360"}, {"West", "Mar", "15", "600"}, {"South", "Mar", "4", "160"},16+ } {17+ s.keys(row[0], "<tab>", row[1], "<tab>", row[2], "<tab>", row[3], "<enter>")18+ }19+ s.keys("<ctrl+home>", "<ctrl+alt+t>")20+ s.waitFor("Name for the table of A1:D7")21+ s.keys("Sales", "<enter>")22+ s.waitFor("Sales is a table")23+ s.keys("<esc>", "<ctrl+home>", "<right>", "<right>", "<right>", "<right>", "<right>")24+ s.keys("Total", "<enter>", "=SUM(Sales[Amount])", "<enter>")25+ s.keys("Per unit", "<enter>", "=F2/SUM(Sales[Units])", "<enter>")26+ s.keys("<ctrl+home>", "<down>", "<down>", "<right>", "<right>", "<right>")27+ s.waitFor("Table Sales, column Amount")28+ s.keys("<ctrl+k>", "banded rows", "<enter>")29+ s.waitFor("Table Sales, column Amount")30+}31+32+var tableScreens = []screen{33+ {name: "table", setup: sales},34+ {name: "table-name", setup: func(s *session) {35+ s.keys("Item", "<tab>", "Cost", "<enter>", "Rent", "<tab>", "1450", "<enter>", "<up>", "<ctrl+alt+t>")36+ s.waitFor("Name for the table of A1:B2: Table1")37+ }},38+ {name: "table-suggest", setup: func(s *session) {39+ sales(s)40+ s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<right>", "<right>", "<right>", "<right>", "<right>")41+ s.keys("=AVERAGE(Sales[u")42+ s.waitFor("Sales[Region, Month, Units, Amount]")43+ }},44+ {name: "tables-picker", setup: func(s *session) {45+ sales(s)46+ s.keys("<alt+d>")47+ s.waitFor("Named ranges")48+ s.keys("<esc>")49+ s.keys("<ctrl+k>", "tables")50+ s.waitFor("│ › tables")51+ s.keys("<enter>")52+ s.waitFor("F2 rename")53+ }},54+ {name: "table-narrow", opts: options{cols: 60, rows: 16}, setup: sales},55+}56+57+func init() {58+ light := map[string]bool{"table": true, "table-suggest": true, "tables-picker": true}59+ for _, sc := range tableScreens {60+ screens = append(screens, sc)61+ if light[sc.name] {62+ l := sc63+ l.name += "-light"64+ l.opts.light = true65+ screens = append(screens, l)66+ }67+ if sc.name == "table" {68+ hc := sc69+ hc.name += "-high-contrast"70+ hc.opts.config = highContrastConfig71+ screens = append(screens, hc)72+ }73+ }74+}@@ -0,0 +1,51 @@
1+package e2e2+3+import (4+ "strings"5+ "testing"6+)7+8+// A table made from the data around the cursor is read by column name,9+// grows as rows are typed below it, renames its column in formulas when10+// its header changes, and is saved with the file.11+func TestTables(t *testing.T) {12+ dir := t.TempDir()13+ writeTable(t, dir, "stock.012", 4) // Item, Qty over A1:B5; Qty 1 2 3 014+ s := start(t, dir, "stock.012")15+16+ s.keys("<down>", "<ctrl+alt+t>")17+ s.waitFor("Name for the table of A1:B5: Table1")18+ s.keys("Stock", "<enter>")19+ s.waitFor("Stock is a table: read it by column in formulas, e.g. =SUM(Stock[Qty])")20+21+ // Suggestions offer the table, then its columns.22+ s.keys("<esc>", "<ctrl+home>", "<right>", "<right>", "<right>", "=SUM(st")23+ s.waitFor("table, A1:B5")24+ s.keys("<tab>", "[q")25+ s.waitFor("Stock[Item, Qty]")26+ s.keys("<tab>", ")", "<enter>")27+ s.keys("<up>")28+ s.waitForBar("D1", "=SUM(Stock[Qty])")29+ s.waitFor("6")30+31+ // A row typed just below joins the table.32+ s.keys("<ctrl+home>", "<ctrl+down>", "<down>", "Row 5", "<tab>", "10", "<enter>")33+ s.keys("<ctrl+home>", "<right>", "<right>", "<right>")34+ s.waitForBar("D1", "=SUM(Stock[Qty])")35+ s.eventually("the sum reads the new row", func() bool { return strings.Contains(s.line(gridRow1), "16") })36+37+ // Renaming the header renames the column in the formula.38+ s.keys("<left>", "<left>", "Count", "<enter>")39+ s.keys("<up>", "<right>", "<right>")40+ s.waitForBar("D1", "=SUM(Stock[Count])")41+ s.keys("<left>", "<left>", "<down>")42+ s.waitFor("Table Stock, column Count: Stock[Count] in formulas")43+44+ // The table and its formula survive saving and opening again.45+ s.keys("<ctrl+s>")46+ s.eventually("saved", func() bool { return !strings.Contains(s.line(29), "modified") })47+ s2 := start(t, dir, "stock.012")48+ s2.keys("<right>", "<down>")49+ s2.waitFor("Table Stock, column Count")50+ s2.waitFor("16")51+}@@ -27,4 +27,4 @@
2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>2828 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30-<span style="color:var(--vt-palette-9);font-weight:bold">Not</span> <span style="color:var(--vt-palette-9);font-weight:bold">a</span> <span style="color:var(--vt-palette-9);font-weight:bold">cell,</span> <span style="color:var(--vt-palette-9);font-weight:bold">range,</span> <span style="color:var(--vt-palette-9);font-weight:bold">named</span> <span style="color:var(--vt-palette-9);font-weight:bold">range</span> <span style="color:var(--vt-palette-9);font-weight:bold">or</span> <span style="color:var(--vt-palette-9);font-weight:bold">region:</span> <span style="color:var(--vt-palette-9);font-weight:bold">nope</span> <span style="color:var(--vt-palette-8)">press</span> <span style="color:var(--vt-palette-8)">any</span> <span style="color:var(--vt-palette-8)">key</span></pre>\ No newline at end of file30+<span style="color:var(--vt-palette-9);font-weight:bold">Not</span> <span style="color:var(--vt-palette-9);font-weight:bold">a</span> <span style="color:var(--vt-palette-9);font-weight:bold">cell,</span> <span style="color:var(--vt-palette-9);font-weight:bold">range,</span> <span style="color:var(--vt-palette-9);font-weight:bold">named</span> <span style="color:var(--vt-palette-9);font-weight:bold">range,</span> <span style="color:var(--vt-palette-9);font-weight:bold">table</span> <span style="color:var(--vt-palette-9);font-weight:bold">or</span> <span style="color:var(--vt-palette-9);font-weight:bold">region:</span> <span style="color:var(--vt-palette-9);font-weight:bold">nope</span> <span style="color:var(--vt-palette-8)">press</span> <span style="color:var(--vt-palette-8)">any</span> <span style="color:var(--vt-palette-8)">key</span></pre>\ No newline at end of file@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">129</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>3030 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">scroll</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -11,6 +11,6 @@
1111 <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">129</span> <span style="color:var(--vt-palette-8)">─┘</span>14+<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">129</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">124</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>3030 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -13,18 +13,18 @@
1313 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Named ranges <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Define named range <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">│</span>18-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validation</span> <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>20-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Formula tracing <span style="color:var(--vt-palette-8)">┌──────────────────────────────────────────┐</span>21-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Linked file <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────├──────────────────────────────────────────┤</span>26-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>27-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────│</span> Manage macros <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Write a macro <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Table › <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validation</span> <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────┌──────────────────────────────────────────┐</span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Formula tracing <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Linked file <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────┤</span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span> Manage macros <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────│</span> Write a macro <span style="color:var(--vt-palette-8)">│</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────┘</span>3030 Record what you do as a macro that selects the same ce… <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Arrows</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">choose</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -13,18 +13,18 @@
1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Named ranges <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Define named range <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">│</span>18-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validation</span> <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>20-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Formula tracing <span style="color:var(--vt-palette-8)">┌──────────────────────────────────────────┐</span>21-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Linked file <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────├──────────────────────────────────────────┤</span>26-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>27-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────│</span> Manage macros <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Write a macro <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Table › <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Data validation <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">data</span> <span style="color:var(--vt-palette-8)">validation</span> <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────┌──────────────────────────────────────────┐</span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Formula tracing <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Record</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">macro</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> Record macro with relative references <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Macros</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">and</span> <span style="color:var(--vt-palette-8)">save</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Shell <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Discard</span> <span style="color:var(--vt-palette-8)">recording</span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Linked file <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────┤</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────│</span> <span style="color:var(--vt-palette-8)">Run</span> <span style="color:var(--vt-palette-8)">macro</span> <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span> Manage macros <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────│</span> Write a macro <span style="color:var(--vt-palette-8)">│</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────┘</span>3030 Record what you do as a macro that selects the same ce… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -19,12 +19,12 @@
1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Row height <span style="color:var(--vt-palette-8)">│</span> Bottom <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+3</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Fit rows to data <span style="color:var(--vt-palette-8)">│</span> Left <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2121 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────│</span> Right <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">├──────────────────────────────┤</span>23-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span> None <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+6</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────├──────────────────────────────┤</span>25-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+\</span><span style="color:var(--vt-palette-8)">│</span> Thin lines ✓ <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────│</span> Thick lines <span style="color:var(--vt-palette-8)">│</span>27-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Double lines <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Border color <span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Convert to table <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+T</span><span style="color:var(--vt-palette-8)">├──────────────────────────────┤</span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────│</span> None <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shift+6</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">├──────────────────────────────┤</span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span> Thin lines ✓ <span style="color:var(--vt-palette-8)">│</span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────│</span> Thick lines <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+\</span><span style="color:var(--vt-palette-8)">│</span> Double lines <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────│</span> Border color <span style="color:var(--vt-palette-8)">│</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────┘</span>3030 Draw every edge of the selected cells <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Arrows</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">choose</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -19,12 +19,12 @@
1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Row height <span style="color:var(--vt-palette-8)">│</span> Bottom <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+3</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Fit rows to data <span style="color:var(--vt-palette-8)">│</span> Left <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2121 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────│</span> Right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">├──────────────────────────────┤</span>23-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span> None <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────├──────────────────────────────┤</span>25-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+\</span><span style="color:var(--vt-palette-8)">│</span> Thin lines ✓ <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────│</span> Thick lines <span style="color:var(--vt-palette-8)">│</span>27-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Double lines <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Border color <span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Convert to table <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+T</span><span style="color:var(--vt-palette-8)">├──────────────────────────────┤</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────│</span> None <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">├──────────────────────────────┤</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span> Thin lines ✓ <span style="color:var(--vt-palette-8)">│</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├─────────────────────────────────────────│</span> Thick lines <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+\</span><span style="color:var(--vt-palette-8)">│</span> Double lines <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────│</span> Border color <span style="color:var(--vt-palette-8)">│</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────┘</span>3030 Draw every edge of the selected cells <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -19,12 +19,12 @@
1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Row height <span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Fit rows to data <span style="color:var(--vt-palette-8)">│</span> Size <span style="color:var(--vt-palette-8)">│</span>2121 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────└───────────────────────────────────┘</span>22-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────────────────┤</span>25-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+\</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────┘</span>27-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Convert to table <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+T</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────────────────┤</span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────────────────┤</span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+\</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────┘</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>3030 Show numbers as typed, or as formulas infer <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Arrows</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">choose</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -19,12 +19,12 @@
1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Row height <span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Fit rows to data <span style="color:var(--vt-palette-8)">│</span> Size <span style="color:var(--vt-palette-8)">│</span>2121 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────└───────────────────────────────────┘</span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────────────────┤</span>25-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+\</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────┘</span>27-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Convert to table <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+T</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────────────────┤</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Conditional formatting <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Clear</span> <span style="color:var(--vt-palette-8)">conditional</span> <span style="color:var(--vt-palette-8)">formats</span> <span style="color:var(--vt-palette-8)">│</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├───────────────────────────────────────────┤</span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Clear formatting <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+\</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────┘</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>3030 Show numbers as typed, or as formulas infer <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -1,6 +1,6 @@
11 <pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">READY</span><span style="background:#40e0ff"> </span>22 <span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span>3-<span style="color:#959595;background:#000000">Output</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">of</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">Notebook,</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nu.files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">formulas</span><span style="background:#000000"> </span>3+<span style="color:#959595;background:#000000">Output</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">of</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">Notebook,</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files[column]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">formulas</span><span style="background:#000000"> </span>44 <span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">A</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>55 <span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000;font-style:italic">name</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000;font-style:italic">type</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000;font-style:italic">size</span><span style="background:#000000"> </span>66 <span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">2</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">012</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000;font-style:italic">file</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000;font-style:italic">4.2</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000;font-style:italic">kB</span><span style="background:#000000"> </span>@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A2</span><span style="background:var(--vt-palette-7)"> </span> 0123-<span style="color:var(--vt-palette-0)">Output</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-0)">from</span> <span style="color:var(--vt-palette-0)">Notebook,</span> <span style="color:var(--vt-palette-0)">nu.files</span> <span style="color:var(--vt-palette-0)">in</span> <span style="color:var(--vt-palette-0)">formulas</span>3+<span style="color:var(--vt-palette-0)">Output</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-0)">from</span> <span style="color:var(--vt-palette-0)">Notebook,</span> <span style="color:var(--vt-palette-0)">files[column]</span> <span style="color:var(--vt-palette-0)">in</span> <span style="color:var(--vt-palette-0)">formulas</span>44 <span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>55 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">name</span> <span style="color:var(--vt-palette-4);font-style:italic">type</span> <span style="color:var(--vt-palette-4);font-style:italic">size</span>66 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">012</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-4);font-style:italic">file</span> <span style="color:var(--vt-palette-4);font-style:italic">4.2</span> <span style="color:var(--vt-palette-4);font-style:italic">kB</span>@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A2</span><span style="background:var(--vt-palette-8)"> </span> 0123-<span style="color:var(--vt-palette-8)">Output</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">from</span> <span style="color:var(--vt-palette-8)">Notebook,</span> <span style="color:var(--vt-palette-8)">nu.files</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">formulas</span>3+<span style="color:var(--vt-palette-8)">Output</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">from</span> <span style="color:var(--vt-palette-8)">Notebook,</span> <span style="color:var(--vt-palette-8)">files[column]</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">formulas</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>55 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">name</span> <span style="color:var(--vt-palette-6);font-style:italic">type</span> <span style="color:var(--vt-palette-6);font-style:italic">size</span>66 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">012</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-6);font-style:italic">file</span> <span style="color:var(--vt-palette-6);font-style:italic">4.2</span> <span style="color:var(--vt-palette-6);font-style:italic">kB</span>@@ -10,7 +10,7 @@
1010 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort range Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1111 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet <span style="color:var(--vt-palette-3);font-weight:bold">A</span> to Z <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>13+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Show</span> <span style="color:var(--vt-palette-8)">all</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">output</span> <span style="color:var(--vt-palette-8)">o</span> <span style="color:var(--vt-palette-8)">│</span>1314 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">60</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">58</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">47</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">44</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">47</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">44</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -12,9 +12,9 @@
1212 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt columns <span style="color:var(--vt-palette-3);font-weight:bold">l</span>eft <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Code</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">y</span> <span style="color:var(--vt-palette-8)">│</span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Paste</span> <span style="color:var(--vt-palette-8)">cell</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">v</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">57</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">53</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">218</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">READY</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">280</span><span style="background:#000000"> </span>3+<span style="color:#959595;background:#000000">Table</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">Sales,</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">column</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">Amount</span><span style="color:#959595;background:#000000">:</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">Sales[Amount]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">formulas</span><span style="background:#000000"> </span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">D</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#40e0ff;background:#000000;font-weight:bold;text-decoration:underline">Region</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#40e0ff;background:#000000;font-weight:bold;text-decoration:underline">Month</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#40e0ff;background:#000000;font-weight:bold;text-decoration:underline">Units</span><span style="background:#000000;text-decoration:underline"> </span><span style="color:#40e0ff;background:#000000;font-weight:bold;text-decoration:underline">Amount</span><span style="background:#000000;text-decoration:underline"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Total</span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">North</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Jan</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">12</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">480</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2680</span><span style="background:#000000"> </span>7+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">3</span><span style="background:#40e0ff"> </span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">South</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">Jan</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">7</span><span style="background:#1a1a1a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">280</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Per</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">unit</span><span style="background:#000000"> </span>8+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">East</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Feb</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">20</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">800</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">40</span><span style="background:#000000"> </span>9+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">5</span><span style="background:#3a3a3a"> </span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">North</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">Feb</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">9</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">360</span><span style="background:#1a1a1a"> </span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">West</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Mar</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">15</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">600</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">South</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">Mar</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">4</span><span style="background:#1a1a1a"> </span><span style="color:#ffffff;background:#1a1a1a">160</span><span style="background:#1a1a1a"> </span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">9</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Sheet1</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D3</span><span style="background:var(--vt-palette-7)"> </span> 2803+<span style="color:var(--vt-palette-0)">Table</span> <span style="color:var(--vt-palette-0)">Sales,</span> <span style="color:var(--vt-palette-0)">column</span> <span style="font-weight:bold">Amount</span><span style="color:var(--vt-palette-0)">:</span> <span style="color:var(--vt-palette-0)">Sales[Amount]</span> <span style="color:var(--vt-palette-0)">in</span> <span style="color:var(--vt-palette-0)">formulas</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">D</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Units</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Amount</span><span style="text-decoration:underline"> </span> Total6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> North Jan 12 480 26807+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">South</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Jan</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">280</span><span style="background:var(--vt-palette-6)"> </span> Per unit8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> East Feb 20 800 409+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">North</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Feb</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">360</span><span style="background:var(--vt-palette-7)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">South</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Mar</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">160</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NAME</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A2</span><span style="background:var(--vt-palette-8)"> </span> Rent3+Name for the table of A1:B2: Table1 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">apply</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Item Cost6+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Rent</span><span style="background:var(--vt-palette-6)"> </span> 14507+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,16 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D3</span><span style="background:var(--vt-palette-8)"> </span> 2803+<span style="color:var(--vt-palette-8)">Table</span> <span style="color:var(--vt-palette-8)">Sales,</span> <span style="color:var(--vt-palette-8)">column</span> <span style="font-weight:bold">Amount</span><span style="color:var(--vt-palette-8)">:</span> <span style="color:var(--vt-palette-8)">Sales[Amount]</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">formulas</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">D</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Units</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Amount</span><span style="text-decoration:underline"> </span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> North Jan 12 4807+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Jan</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">280</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> East Feb 20 8009+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">North</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Feb</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">360</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Mar</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">160</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F9</span><span style="background:var(--vt-palette-7)"> </span> =AVERAGE(Sales[u3+<span style="font-weight:bold">Sales</span>[Region, Month, <span style="font-weight:bold;text-decoration:underline">Units</span>, Amount] <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">cancel</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">┌──────────────────┐</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">F</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Units</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">column</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">C</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span><span style="text-decoration:underline"> </span> Total6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> North Jan <span style="color:var(--vt-palette-8)">└──────────────────┘</span> 26807+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">South</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Jan</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">280</span><span style="background:var(--vt-palette-7)"> </span> Per unit8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> East Feb 20 800 409+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">North</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Feb</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">360</span><span style="background:var(--vt-palette-7)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">South</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Mar</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">160</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">9</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">…E(Sales[u</span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+The Units column of Sales <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">insert</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">hide</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">ENTER</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F9</span><span style="background:var(--vt-palette-8)"> </span> =AVERAGE(Sales[u3+<span style="font-weight:bold">Sales</span>[Region, Month, <span style="font-weight:bold;text-decoration:underline">Units</span>, Amount] <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">cancel</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">┌──────────────────┐</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">F</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Units</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">column</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">C</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span><span style="text-decoration:underline"> </span> Total6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> North Jan <span style="color:var(--vt-palette-8)">└──────────────────┘</span> 26807+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Jan</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">280</span><span style="background:var(--vt-palette-8)"> </span> Per unit8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> East Feb 20 800 409+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">North</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Feb</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">360</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Mar</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">160</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">9</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">…E(Sales[u</span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+The Units column of Sales <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">insert</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">hide</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D3</span><span style="background:var(--vt-palette-8)"> </span> 2803+<span style="color:var(--vt-palette-8)">Table</span> <span style="color:var(--vt-palette-8)">Sales,</span> <span style="color:var(--vt-palette-8)">column</span> <span style="font-weight:bold">Amount</span><span style="color:var(--vt-palette-8)">:</span> <span style="color:var(--vt-palette-8)">Sales[Amount]</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">formulas</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">D</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Units</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Amount</span><span style="text-decoration:underline"> </span> Total6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> North Jan 12 480 26807+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Jan</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">280</span><span style="background:var(--vt-palette-6)"> </span> Per unit8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> East Feb 20 800 409+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">North</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Feb</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">360</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Mar</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">160</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D3</span><span style="background:var(--vt-palette-7)"> </span> 280 <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Tables</span> <span style="color:var(--vt-palette-8)">───────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-0)">Type</span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────┤</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-4);font-weight:bold;text-decoration:underline">Un</span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Sales</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:D7</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> North Jan <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-8)">─┘</span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">South</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Jan</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">280</span><span style="background:var(--vt-palette-6)"> </span> Per unit8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> East Feb 20 800 409+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">North</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Feb</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">360</span><span style="background:var(--vt-palette-7)"> </span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">South</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Mar</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">160</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+Go to it; columns Region, Month, Units, … <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">go</span> <span style="color:var(--vt-palette-0)">to</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">rename</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+D</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">remove</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D3</span><span style="background:var(--vt-palette-8)"> </span> 280 <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Tables</span> <span style="color:var(--vt-palette-8)">───────────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Type</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Region</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Month</span><span style="text-decoration:underline"> </span><span style="color:var(--vt-palette-6);font-weight:bold;text-decoration:underline">Un</span><span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Sales</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A1:D7</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> North Jan <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">─┘</span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Jan</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">280</span><span style="background:var(--vt-palette-6)"> </span> Per unit8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> East Feb 20 800 409+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">North</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Feb</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">360</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> West Mar 15 60011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">South</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Mar</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">160</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Go to it; columns Region, Month, Units, … <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">go</span> <span style="color:var(--vt-palette-8)">to</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">rename</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+D</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">remove</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -92,12 +92,13 @@ func formatText(raw json.RawMessage) string {
9292 return strings.Join(parts, ", ")9393 }9494 95-// sheetDiff compares a sheet's cells, regions and other fields; name is95+// sheetDiff compares a sheet's cells, regions, tables and other fields; name is9696 // what the changes call the sheet.9797 func sheetDiff(a, b side, name string) []Change {9898 out := cellChanges(a, b, name)9999 out = append(out, notebookChanges(name, field(a.raw, "notebookCells"), field(b.raw, "notebookCells"))...)100100 out = append(out, listChanges(KindRegion, name, field(a.raw, "regions"), field(b.raw, "regions"))...)101+ out = append(out, listChanges(KindTable, name, field(a.raw, "tables"), field(b.raw, "tables"))...)101102 keys := map[string]bool{}102103 for _, s := range []*rawSheet{a.raw, b.raw} {103104 for k := range s.fields {@@ -105,7 +106,7 @@ func sheetDiff(a, b side, name string) []Change {
105106 }106107 }107108 for _, k := range slices.Sorted(maps.Keys(keys)) {108- if k != "regions" && k != "notebookCells" {109+ if k != "regions" && k != "tables" && k != "notebookCells" {109110 out = append(out, fieldChanges(KindLayout, name, k, a.raw.fields[k], b.raw.fields[k])...)110111 }111112 }@@ -52,6 +52,7 @@ const (
5252 KindSheet = "sheet" // a sheet added, removed, renamed or moved5353 KindCell = "cell" // a cell's input, value, format or note5454 KindRegion = "region" // a notebook region or linked file55+ KindTable = "table" // a table5556 KindLayout = "layout" // another field of a sheet: widths, rules, charts5657 KindName = "name" // a named range5758 KindMacro = "macro" // a macro@@ -75,7 +76,7 @@ type Change struct {
7576 }7677 7778 // Where names the change's place as the text output does: Q3!B7,78-// sheet Q3, Q3 region r1, Q3 widths, name Sales.79+// sheet Q3, Q3 region r1, Q3 table Sales, Q3 widths, name Sales.7980 func (c Change) Where() string {8081 switch c.Kind {8182 case KindCell:@@ -84,6 +85,8 @@ func (c Change) Where() string {
8485 return "sheet " + c.Sheet8586 case KindRegion:8687 return c.Sheet + " region " + c.Item88+ case KindTable:89+ return c.Sheet + " table " + c.Item8790 case KindLayout, KindNotebook:8891 return c.Sheet + " " + c.Item8992 }@@ -143,6 +143,24 @@ workbook locale en-US → de-DE
143143 }144144 }145145 146+func TestCompareTables(t *testing.T) {147+ cells := []map[string]string{{"A1": "Item", "B1": "Cost", "A2": "Rent", "B2": "5"}}148+ a := file(t, []string{"S"}, cells, func(w *sheet.Workbook) {149+ w.Sheet(0).CreateTable("Costs", sheet.NewRect(addr("A1"), addr("B2")))150+ w.Sheet(0).CreateTable("Old", sheet.NewRect(addr("D1"), addr("D2")))151+ })152+ b := file(t, []string{"S"}, cells, func(w *sheet.Workbook) {153+ w.Sheet(0).CreateTable("Costs", sheet.NewRect(addr("A1"), addr("B3")))154+ })155+ want := `S!D1 input - Column1156+S table Costs range A1:B2 → A1:B3157+S table Old removed - D1:D2158+`159+ if got := lines(t, a, b); got != want {160+ t.Errorf("got\n%s\nwant\n%s", got, want)161+ }162+}163+146164 func TestCompareNotebooks(t *testing.T) {147165 nb := func(cells string) []byte {148166 return []byte(`{"version": 4, "sheets": [{"name": "Sheet1", "cells": {}}, {"name": "Notebook", "tab": "notebook", "notebookCells": [` + cells + `], "cells": {}}]}`)@@ -114,9 +114,9 @@ func named(raw json.RawMessage) map[string]json.RawMessage {
114114 return out115115 }116116 117-// summary is what the text output shows of a region or macro added or118-// removed: the file a region follows or that it's a notebook's output;119-// a macro's script's first line.117+// summary is what the text output shows of a region, table or macro118+// added or removed: the file a region follows or that it's a notebook's119+// output; a table's range; a macro's script's first line.120120 func summary(raw json.RawMessage) string {121121 fields, _ := object(raw)122122 var s string@@ -125,6 +125,8 @@ func summary(raw json.RawMessage) string {
125125 return s126126 case string(fields["output"]) == "true":127127 return "a notebook cell's output"128+ case json.Unmarshal(fields["range"], &s) == nil && s != "":129+ return s128130 case json.Unmarshal(fields["source"], &s) == nil && s != "":129131 return firstLine(s)130132 }@@ -38,6 +38,35 @@ func input(w *sheet.Workbook, ref string) string {
3838 return c.Input3939 }4040 41+// Tables merge one by one: one side resizing a table and the other42+// adding one keeps both.43+func TestMergeTables(t *testing.T) {44+ cells := []map[string]string{{"A1": "Item", "B1": "Cost", "A2": "Rent", "B2": "5", "D1": "Tag", "D2": "x"}}45+ withTables := func(ts map[string]string) func(w *sheet.Workbook) {46+ return func(w *sheet.Workbook) {47+ for name, r := range ts {48+ rr, _ := sheet.ParseRange(r)49+ if err := w.Sheet(0).CreateTable(name, rr); err != nil {50+ t.Fatal(err)51+ }52+ }53+ }54+ }55+ base := file(t, []string{"S"}, cells, withTables(map[string]string{"Costs": "A1:B2"}))56+ ours := file(t, []string{"S"}, cells, withTables(map[string]string{"Costs": "A1:B3"}))57+ theirs := file(t, []string{"S"}, cells, withTables(map[string]string{"Costs": "A1:B2", "Tags": "D1:D2"}))58+ w, said := merge(t, base, ours, theirs)59+ if len(said) > 0 {60+ t.Errorf("conflicts %v", said)61+ }62+ s := w.Sheet(0)63+ costs, _ := s.TableAt(addr("A1"))64+ tags, _ := s.TableAt(addr("D1"))65+ if costs.Range.String() != "A1:B3" || tags.Name != "Tags" {66+ t.Errorf("tables %+v %+v", costs, tags)67+ }68+}69+4170 func TestMergeCells(t *testing.T) {4271 base := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "1", "B2": "2", "B3": "3", "B4": "=SUM(B1:B3)"}}, nil)4372 ours := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "10", "B2": "2", "B3": "30", "B4": "=SUM(B1:B3)", "C1": "ours"}}, nil)@@ -150,6 +150,8 @@ func (m *merger) sheet(b, o, t *rawSheet) *rawSheet {
150150 switch k {151151 case "regions":152152 v = m.list(b.fields[k], o.fields[k], t.fields[k], func(key string) string { return out.name + " region " + key })153+ case "tables":154+ v = m.list(b.fields[k], o.fields[k], t.fields[k], func(key string) string { return out.name + " table " + key })153155 case "notebookCells":154156 v = m.notebook(out.name, b.fields[k], o.fields[k], t.fields[k])155157 case "widths", "heights", "lines":@@ -133,21 +133,7 @@ func Dump(w io.Writer, b *Book) error {
133133 sd := b.side(s)134134 q := sheet.QuoteSheet(s.name)135135 fmt.Fprintf(bw, "sheet %s\n", s.name)136- for _, k := range slices.Sorted(maps.Keys(s.fields)) {137- if k == "notebookCells" {138- for _, line := range dumpNotebook(s.name, s.fields[k]) {139- bw.WriteString(line + "\n")140- }141- continue142- }143- if k == "regions" {144- for _, name := range slices.Sorted(maps.Keys(named(s.fields[k]))) {145- fmt.Fprintf(bw, "%s region %s %s\n", s.name, name, named(s.fields[k])[name])146- }147- continue148- }149- fmt.Fprintf(bw, "%s %s %s\n", s.name, k, s.fields[k])150- }136+ dumpFields(bw, s)151137 for _, at := range sortedAddrs(s.cells) {152138 bw.WriteString(q + "!" + cellLine(sd, at, s.cells[at.String()]) + "\n")153139 }@@ -168,6 +154,26 @@ func Dump(w io.Writer, b *Book) error {
168154 return bw.Flush()169155 }170156 157+// dumpFields writes a sheet's fields as Dump does: a notebook's cells,158+// regions and tables one line each, and each other field whole.159+func dumpFields(bw *bufio.Writer, s *rawSheet) {160+ for _, k := range slices.Sorted(maps.Keys(s.fields)) {161+ switch k {162+ case "notebookCells":163+ for _, line := range dumpNotebook(s.name, s.fields[k]) {164+ bw.WriteString(line + "\n")165+ }166+ case "regions", "tables":167+ what := strings.TrimSuffix(k, "s")168+ for _, name := range slices.Sorted(maps.Keys(named(s.fields[k]))) {169+ fmt.Fprintf(bw, "%s %s %s %s\n", s.name, what, name, named(s.fields[k])[name])170+ }171+ default:172+ fmt.Fprintf(bw, "%s %s %s\n", s.name, k, s.fields[k])173+ }174+ }175+}176+171177 // cellLine is a cell as Dump writes it after its sheet: B7 =SUM(A1:A6)172178 // = 21 [currency, decimals 2] note: …173179 func cellLine(sd side, at sheet.Addr, raw json.RawMessage) string {@@ -131,6 +131,9 @@ func excelName(b *strings.Builder, src string, i int, renamed map[string]string)
131131 j++132132 }133133 name := src[start:j]134+ if !at && j < len(src) && src[j] == '[' {135+ return excelTableRef(b, src, start, j)136+ }134137 k := j135138 for k < len(src) && src[k] == ' ' {136139 k++@@ -154,6 +157,22 @@ func excelName(b *strings.Builder, src string, i int, renamed map[string]string)
154157 return j, true155158 }156159 160+// excelTableRef writes the structured reference whose table's name161+// starts at src[start] and whose brackets start at src[open] as Excel's162+// files hold it: Sales[[#This Row],[Amount]] for Sales[@Amount].163+func excelTableRef(b *strings.Builder, src string, start, open int) (int, bool) {164+ end := formula.StructuredEnd(src, open)165+ if end < 0 {166+ return 0, false167+ }168+ x, ok := formula.ExcelTableRef(src[start:end])169+ if !ok {170+ return 0, false171+ }172+ b.WriteString(x)173+ return end, true174+}175+157176 // excelSheetRef is text, a sheet name as written (name, unquoted) when158177 // sheet says it is followed by !, with the name the sheet is written159178 // under when that differs: always quoted, as Excel accepts.@@ -56,6 +56,10 @@ type Snapshot struct {
5656 // the merged cells in the range, for formats that keep them (XLSX).5757 Heights map[int]int5858 Merges []sheet.Rect59+60+ // Tables are the tables wholly in the range, for formats that keep61+ // them (XLSX).62+ Tables []sheet.Table5963 }6064 6165 // SnapName is a named range: its name, and the range on the sheet@@ -74,6 +78,7 @@ type SnapCell struct {
7478 Style sheet.Style7579 Formula bool7680 Sheets []string // the sheets a formula names, as written81+ Tables []string // the tables a formula reads, as written7782 }7883 7984 // Text is the cell as displayed, without a width limit.@@ -86,8 +91,8 @@ func (c SnapCell) Text() string {
8691 // row and column with contents, so exporting whole columns writes their8792 // data rather than a million blank lines.8893 func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {89- notes := r90- if r == (sheet.Rect{}) {94+ notes, whole := r, r == (sheet.Rect{})95+ if whole {9196 notes = sheet.Rect{To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}9297 r, _ = s.UsedRange()9398 } else if data, ok := s.FilledBounds(r); ok {@@ -100,6 +105,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
100105 CondFormats: s.CondFormats(), Validations: s.Validations()}101106 snap.FrozenRows, snap.FrozenCols = s.Frozen()102107 snap.Heights, snap.Merges = heightsMergesIn(s, r)108+ snapTables(snap, s, whole)103109 for _, a := range s.NotesIn(notes) {104110 if snap.Notes == nil {105111 snap.Notes = map[sheet.Addr]string{}@@ -133,11 +139,25 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
133139 Style: s.CellStyle(a),134140 Formula: c.IsFormula(),135141 Sheets: s.NamedSheets(a),142+ Tables: s.NamedTables(a),136143 }137144 }138145 return snap139146 }140147 148+// snapTables adds the sheet's tables in the snapshot's range to it; a149+// whole sheet's range grows to hold its tables, blank rows and all.150+func snapTables(snap *Snapshot, s *sheet.Sheet, whole bool) {151+ for _, t := range s.Tables() {152+ if whole {153+ snap.Range.To.Col, snap.Range.To.Row = max(snap.Range.To.Col, t.Range.To.Col), max(snap.Range.To.Row, t.Range.To.Row)154+ }155+ if snap.Range.Contains(t.Range.From) && snap.Range.Contains(t.Range.To) {156+ snap.Tables = append(snap.Tables, t)157+ }158+ }159+}160+141161 // heightsMergesIn are the heights of the rows of r and the merges inside142162 // it.143163 func heightsMergesIn(s *sheet.Sheet, r sheet.Rect) (map[int]int, []sheet.Rect) {@@ -41,7 +41,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
4141 if len(sheets) == 0 {4242 sheets = []*Snapshot{snap}4343 }44- w := &xlsxWriter{styles: newXLSXStyleTable(snap.Locale), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}}44+ w := &xlsxWriter{styles: newXLSXStyleTable(snap.Locale), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}, tables: tableKeys(sheets)}4545 res := &ExportResult{}4646 names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 04747 used := map[string]bool{}@@ -70,15 +70,16 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7070 res.Rows += rows7171 if len(sn.Notes) > 0 {7272 withNotes = append(withNotes, i+1)73- if err := writeNotes(zw, i+1, sn.Notes); err != nil {74- return err75- }73+ }74+ if err := w.writeSheetParts(zw, i+1, sn); err != nil {75+ return err7676 }7777 }7878 if err := w.writeShared(zw); err != nil {7979 return err8080 }81- if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names), notesTypes(withNotes), w.dynamic); err != nil {81+ types := notesTypes(withNotes) + strings.Join(w.tableTypes, "")82+ if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names), types, w.dynamic); err != nil {8283 return err8384 }8485 return zw.Close()@@ -98,6 +99,27 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
9899 return res, nil99100 }100101 102+// writeSheetParts writes the parts worksheet part n relates to, its103+// notes and tables, and the relationships to them.104+func (w *xlsxWriter) writeSheetParts(zw *zip.Writer, n int, sn *Snapshot) error {105+ var rels []string106+ if len(sn.Notes) > 0 {107+ if err := writeNotes(zw, n, sn.Notes); err != nil {108+ return err109+ }110+ rels = append(rels, notesRels(n)...)111+ }112+ tables, err := w.writeTables(zw, sn)113+ if err != nil {114+ return err115+ }116+ if rels = append(rels, tables...); len(rels) == 0 {117+ return nil118+ }119+ return writePart(zw, "xl/worksheets/_rels/sheet"+strconv.Itoa(n)+".xml.rels",120+ xmlHead+`<Relationships xmlns="`+relsNS+`">`+strings.Join(rels, "")+`</Relationships>`)121+}122+101123 // writeShared writes the parts the worksheets share: the styles, and the102124 // cell metadata of dynamic array formulas when any was written.103125 func (w *xlsxWriter) writeShared(zw *zip.Writer) error {@@ -150,7 +172,7 @@ const (
150172 func filterRanges(sheets []*Snapshot, names []string) []string {151173 out := make([]string, len(sheets))152174 for i, sn := range sheets {153- if sn.Filter != nil {175+ if sn.Filter != nil && tableFilter(sn) < 0 { // a table's filter is its own154176 out[i] = excelRange(names[i], sn.Filter.Range)155177 }156178 }@@ -23,6 +23,8 @@ type xlsxBook struct {
2323 // protected names the sheets read so far that Excel protects.2424 protected []string2525 skips ruleSkips // rules left out, see xlsxrulesimport.go26+ // tablesLeft counts the tables left out, see xlsxtables.go.27+ tablesLeft int2628 }2729 2830 // xlsxSheetInfo is a sheet as the workbook lists it.@@ -73,6 +73,7 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
7373 done += rows7474 }7575 notes := append(bk.importNames(book), bk.protectionNote()...)76+ notes = append(notes, bk.tablesNote()...)7677 active := book.Sheet(clamp(bk.active, 0, book.Len()-1))7778 book.SetActive(active)7879 for i, info := range bk.sheets {@@ -133,6 +134,9 @@ func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, af **xls
133134 if err := bk.readNotes(b.s, bk.sheets[i].part); err != nil {134135 return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)135136 }137+ if err := bk.readTables(b.s, bk.sheets[i].part, af); err != nil {138+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)139+ }136140 loadColStyles(b, width, func(c int) (xlsxStyle, bool) {137141 st := bk.styles.style(r.lineStyle(c + 1))138142 return st, st != (xlsxStyle{})@@ -26,8 +26,8 @@ const (
2626 vmlRelID = "rIdVML"2727 )2828 29-// writeNotes writes worksheet part n's notes: its relationships, the30-// comments part and the VML drawing.29+// writeNotes writes worksheet part n's notes: the comments part and the30+// VML drawing, which notesRels relate to it.3131 func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {3232 num := strconv.Itoa(n)3333 addrs := slices.SortedFunc(maps.Keys(notes), func(a, b sheet.Addr) int {@@ -55,12 +55,7 @@ func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {
5555 }5656 comments.WriteString(`</commentList></comments>`)5757 vml.WriteString(`</xml>`)58- rels := xmlHead + `<Relationships xmlns="` + relsNS + `">` +59- `<Relationship Id="` + notesRelID + `" Type="` + officeRel + `/comments" Target="../comments` + num + `.xml"/>` +60- `<Relationship Id="` + vmlRelID + `" Type="` + officeRel + `/vmlDrawing" Target="../drawings/vmlDrawing` + num + `.vml"/>` +61- `</Relationships>`6258 for _, p := range []struct{ name, body string }{63- {"xl/worksheets/_rels/sheet" + num + ".xml.rels", rels},6459 {"xl/comments" + num + ".xml", comments.String()},6560 {"xl/drawings/vmlDrawing" + num + ".vml", vml.String()},6661 } {@@ -71,6 +66,16 @@ func writeNotes(zw *zip.Writer, n int, notes map[sheet.Addr]string) error {
7166 return nil7267 }7368 69+// notesRels are the relationships from worksheet part n to its notes'70+// parts.71+func notesRels(n int) []string {72+ num := strconv.Itoa(n)73+ return []string{74+ `<Relationship Id="` + notesRelID + `" Type="` + officeRel + `/comments" Target="../comments` + num + `.xml"/>`,75+ `<Relationship Id="` + vmlRelID + `" Type="` + officeRel + `/vmlDrawing" Target="../drawings/vmlDrawing` + num + `.vml"/>`,76+ }77+}78+7479 // notesTypes are the content types of the notes of the worksheet parts7580 // numbered in withNotes.7681 func notesTypes(withNotes []int) string {@@ -240,11 +240,23 @@ func mergeParts(p map[string]string, name, content string) map[string]string {
240240 return p241241 }242242 243+// tableParts is a workbook of one sheet with a table over A1:B3 and a244+// formula reading it.245+func tableParts() map[string]string {246+ p := oneSheetParts("", `<row r="1"><c r="A1" t="inlineStr"><is><t>Item</t></is></c><c r="B1" t="inlineStr"><is><t>Cost</t></is></c></row>`+247+ `<row r="2"><c r="B2"><v>5</v></c><c r="C2"><f>SUM(T[Cost])+T[[#This Row],[Cost]]</f></c></row>`)248+ p["xl/worksheets/sheet1.xml"] = strings.Replace(p["xl/worksheets/sheet1.xml"], `</worksheet>`, `<tableParts count="1"><tablePart r:id="rT"/></tableParts></worksheet>`, 1)249+ p["xl/worksheets/_rels/sheet1.xml.rels"] = `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rT" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/></Relationships>`250+ p["xl/tables/table1.xml"] = `<table ` + mainNS + ` id="1" name="T" displayName="T" ref="A1:B3" totalsRowCount="1"><autoFilter ref="A1:B2"/><tableColumns count="2"><tableColumn id="1" name="Item"/><tableColumn id="2" name="Cost"/></tableColumns><tableStyleInfo name="TableStyleLight1" showRowStripes="1"/></table>`251+ return p252+}253+243254 // FuzzReadXLSX feeds the reader arbitrary files: it must fail cleanly,244255 // never panic or run past its limits.245256 func FuzzReadXLSX(f *testing.F) {246257 f.Add(zipParts(f, kitchenParts()))247258 f.Add(zipParts(f, oneSheetParts(`<workbookPr date1904="1"/>`, `<row><c r="B2" t="d"><v>2026-01-01</v></c></row>`)))259+ f.Add(zipParts(f, tableParts()))248260 f.Fuzz(func(t *testing.T, data []byte) {249261 lim := defaultXLSXLimits250262 lim.part, lim.total = 4<<20, 8<<20@@ -0,0 +1,236 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "bufio"6+ "encoding/xml"7+ "fmt"8+ "io"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// Tables travel as Excel's tables: a table part per table, related to16+// its worksheet, which lists them in <tableParts>. A filter on a table's17+// range goes in the table part, as Excel keeps a table's filter, rather18+// than on the sheet. A banded table has row stripes, and a styled header19+// Excel's default table style; a table with neither has no style.20+// Excel needs a table's header cells to hold text, so a number there is21+// written as the text it shows.22+23+const (24+ tableType = mlType + "table+xml"25+ tableRelID = "rIdTable"26+ tableStyle = "TableStyleMedium2"27+ maxTableCol = 16384 // tableColumns Excel reads, a sheet's width28+)29+30+// tableFilter is the index of the table whose range the sheet's filter31+// has, or -1.32+func tableFilter(snap *Snapshot) int {33+ if snap.Filter == nil {34+ return -135+ }36+ for i, t := range snap.Tables {37+ if t.Range == snap.Filter.Range {38+ return i39+ }40+ }41+ return -142+}43+44+// headerCells are the cells of the header rows of the snapshot's45+// tables.46+func headerCells(snap *Snapshot) map[sheet.Addr]bool {47+ if len(snap.Tables) == 0 {48+ return nil49+ }50+ out := map[sheet.Addr]bool{}51+ for _, t := range snap.Tables {52+ for c := t.Range.From.Col; c <= t.Range.To.Col; c++ {53+ out[sheet.Addr{Col: c, Row: t.Range.From.Row}] = true54+ }55+ }56+ return out57+}58+59+// writeTableParts lists the snapshot's tables in its worksheet, by60+// their relationships.61+func writeTableParts(bw *bufio.Writer, snap *Snapshot) {62+ if len(snap.Tables) == 0 {63+ return64+ }65+ fmt.Fprintf(bw, `<tableParts count="%d">`, len(snap.Tables))66+ for i := range snap.Tables {67+ fmt.Fprintf(bw, `<tablePart r:id="%s%d"/>`, tableRelID, i+1)68+ }69+ bw.WriteString(`</tableParts>`)70+}71+72+// writeTables writes the table parts of a worksheet's tables, numbered73+// on from the tables written before, and returns their relationships74+// from the worksheet.75+func (w *xlsxWriter) writeTables(zw *zip.Writer, snap *Snapshot) ([]string, error) {76+ var rels []string77+ onFilter := tableFilter(snap)78+ for i, t := range snap.Tables {79+ w.tableParts++80+ n := strconv.Itoa(w.tableParts)81+ var body strings.Builder82+ bw := bufio.NewWriter(&body)83+ fmt.Fprintf(bw, xmlHead+`<table xmlns="%s" id="%s" name="%s" displayName="%s" ref="%s" totalsRowShown="0">`,84+ sheetMain, n, escapeXML(t.Name, true), escapeXML(t.Name, true), excelRect(t.Range))85+ if i == onFilter {86+ writeAutoFilter(bw, snap)87+ }88+ fmt.Fprintf(bw, `<tableColumns count="%d">`, len(t.Cols))89+ for j, col := range t.Cols {90+ fmt.Fprintf(bw, `<tableColumn id="%d" name="%s"/>`, j+1, escapeXML(col, true))91+ }92+ bw.WriteString(`</tableColumns><tableStyleInfo`)93+ if t.Header {94+ bw.WriteString(` name="` + tableStyle + `"`)95+ }96+ fmt.Fprintf(bw, ` showFirstColumn="0" showLastColumn="0" showRowStripes="%d" showColumnStripes="0"/></table>`, b2i(t.Banded))97+ if err := bw.Flush(); err != nil {98+ return nil, err99+ }100+ if err := writePart(zw, "xl/tables/table"+n+".xml", body.String()); err != nil {101+ return nil, err102+ }103+ w.tableTypes = append(w.tableTypes, `<Override PartName="/xl/tables/table`+n+`.xml" ContentType="`+tableType+`"/>`)104+ rels = append(rels, fmt.Sprintf(`<Relationship Id="%s%d" Type="%s/table" Target="../tables/table%s.xml"/>`, tableRelID, i+1, officeRel, n))105+ }106+ return rels, nil107+}108+109+func b2i(b bool) int {110+ if b {111+ return 1112+ }113+ return 0114+}115+116+// tableKeys are the keys of the tables the snapshots hold, which117+// formulas may read in the file.118+func tableKeys(sheets []*Snapshot) map[string]bool {119+ out := map[string]bool{}120+ for _, sn := range sheets {121+ for _, t := range sn.Tables {122+ out[strings.ToUpper(t.Name)] = true123+ }124+ }125+ return out126+}127+128+// unknownTable reports whether c's formula reads a table the file129+// doesn't hold, such as a notebook output's, which Excel would refuse.130+func (w *xlsxWriter) unknownTable(c SnapCell) bool {131+ for _, name := range c.Tables {132+ if !w.tables[strings.ToUpper(name)] {133+ return true134+ }135+ }136+ return false137+}138+139+// tablesNote says how many tables an import left out, or nothing.140+func (bk *xlsxBook) tablesNote() []string {141+ if bk.tablesLeft == 0 {142+ return nil143+ }144+ return []string{count(bk.tablesLeft, "table", "tables") + " left out: without a header row, or named as a range or another table is"}145+}146+147+// readTables loads the tables of worksheet part, from the table parts148+// its relationships name, into s, whose cells are read. A table's149+// filter goes in af when the sheet has none of its own. Tables 012150+// can't hold (no header row, a name taken) are left out, and counted.151+func (bk *xlsxBook) readTables(s *sheet.Sheet, part string, af **xlsxAutoFilter) error {152+ rels, err := bk.pkg.rels(part)153+ if err != nil {154+ return err155+ }156+ for _, rel := range rels {157+ if rel.typ != "table" || !bk.pkg.has(rel.target) {158+ continue159+ }160+ t, tf, err := bk.readTable(rel.target)161+ if err != nil {162+ return fmt.Errorf("table: %w", err)163+ }164+ if t == nil || s.LoadTable(*t) != nil {165+ bk.tablesLeft++166+ continue167+ }168+ if *af == nil && tf != nil {169+ *af = tf170+ }171+ }172+ return nil173+}174+175+// readTable reads a table part: its name, range, columns and style,176+// and its filter; nil for a table without a header row. A totals row177+// is left as cells below the table.178+func (bk *xlsxBook) readTable(part string) (*sheet.Table, *xlsxAutoFilter, error) {179+ x, err := bk.pkg.open(part)180+ if x == nil || err != nil {181+ return nil, nil, err182+ }183+ defer x.close()184+ var t sheet.Table185+ var af *xlsxAutoFilter186+ for {187+ tok, err := x.next()188+ if err == io.EOF {189+ break190+ }191+ if err != nil {192+ return nil, nil, err193+ }194+ se, ok := tok.(xml.StartElement)195+ if !ok {196+ continue197+ }198+ switch se.Name.Local {199+ case "table":200+ if !tableHead(&t, se) {201+ return nil, nil, x.skip()202+ }203+ case "autoFilter":204+ if af, err = readAutoFilter(x, se); err != nil {205+ return nil, nil, err206+ }207+ case "tableColumn":208+ if len(t.Cols) < maxTableCol {209+ t.Cols = append(t.Cols, attrOr(se, "name", ""))210+ }211+ case "tableStyleInfo":212+ t.Header = attrOr(se, "name", "") != ""213+ t.Banded = boolAttr(se, "showRowStripes", false)214+ }215+ }216+ if t.Range.To.Row == t.Range.From.Row {217+ return nil, nil, nil218+ }219+ return &t, af, nil220+}221+222+// tableHead reads a <table>'s name and range into t, the range less a223+// totals row, and reports false for a table 012 can't hold: no header224+// row, or no range. LoadTable checks the name.225+func tableHead(t *sheet.Table, se xml.StartElement) bool {226+ t.Name = attrOr(se, "displayName", attrOr(se, "name", ""))227+ r, ok := sheet.ParseRange(strings.ReplaceAll(attrOr(se, "ref", ""), "$", ""))228+ if !ok || attrOr(se, "headerRowCount", "1") == "0" {229+ return false230+ }231+ if n, err := strconv.Atoi(attrOr(se, "totalsRowCount", "0")); err == nil && n > 0 {232+ r.To.Row = max(r.To.Row-n, r.From.Row)233+ }234+ t.Range = r235+ return true236+}@@ -0,0 +1,140 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Tables go out as Excel's tables, with their filters and style, and15+// formulas reading them in Excel's syntax; excelize and 012 read them16+// back.17+func TestXLSXTables(t *testing.T) {18+ src := build(t, map[string]string{19+ "A1": "Region", "B1": "2024", "C1": "Amount",20+ "A2": "North", "B2": "2", "C2": "10",21+ "A3": "South", "B3": "3", "C3": "20",22+ "E1": "=SUM(Sales[Amount])", "D2": "=Sales[@Amount]*2",23+ })24+ if err := src.CreateTable("Sales", rect(t, "A1:C3")); err != nil {25+ t.Fatal(err)26+ }27+ src.Book().SetTableStyle("Sales", false, true)28+ src.CreateFilter(rect(t, "A1:C3"))29+ src.FilterColumn(0, sheet.Criteria{Hidden: []string{"South"}})30+ name := filepath.Join(t.TempDir(), "tables.xlsx")31+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {32+ t.Fatal(err)33+ }34+ x, err := excelize.OpenFile(name)35+ if err != nil {36+ t.Fatal(err)37+ }38+ ts, err := x.GetTables("Sheet1")39+ if err != nil || len(ts) != 1 || ts[0].Name != "Sales" || ts[0].Range != "A1:C3" || ts[0].StyleName != "TableStyleMedium2" {40+ t.Errorf("excelize tables %+v, %v", ts, err)41+ }42+ if f, _ := x.GetCellFormula("Sheet1", "D2"); f != "Sales[[#This Row],[Amount]]*2" {43+ t.Errorf("formula %q", f)44+ }45+ if v, _ := x.GetCellValue("Sheet1", "B1"); v != "2024" {46+ t.Errorf("header %q", v)47+ }48+ x.Close()49+ got, err := Import(context.Background(), name, Options{})50+ if err != nil {51+ t.Fatal(err)52+ }53+ s := got.Sheet54+ tb, ok := s.TableAt(addr(t, "B2"))55+ if !ok || tb.Name != "Sales" || tb.Range != rect(t, "A1:C3") || tb.Banded || !tb.Header || strings.Join(tb.Cols, ",") != "Region,2024,Amount" {56+ t.Fatalf("table read back %+v", tb)57+ }58+ if r, ok := s.FilterRange(); !ok || r != rect(t, "A1:C3") || !s.RowHidden(2) {59+ t.Errorf("filter %v %v, row 3 hidden %v", r, ok, s.RowHidden(2))60+ }61+ for a, want := range map[string]string{"E1": "30", "D2": "20"} {62+ if v := s.Value(addr(t, a)).String(); v != want {63+ t.Errorf("%s = %s, want %s", a, v, want)64+ }65+ }66+}67+68+// A formula reading a table the file doesn't hold, a notebook output's,69+// is written as its value, as Excel would refuse it.70+func TestXLSXUnknownTable(t *testing.T) {71+ src := build(t, map[string]string{"A1": "=SUM(app[ms])+1"})72+ name := filepath.Join(t.TempDir(), "outputs.xlsx")73+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})74+ if err != nil {75+ t.Fatal(err)76+ }77+ if !strings.Contains(strings.Join(res.Notes, "; "), "saved as values") {78+ t.Errorf("notes %q", res.Notes)79+ }80+ x, _ := excelize.OpenFile(name)81+ defer x.Close()82+ if f, _ := x.GetCellFormula("Sheet1", "A1"); f != "" {83+ t.Errorf("formula kept: %q", f)84+ }85+}86+87+// Tables Excel makes, a totals row and all, come in as tables.88+func TestXLSXTablesFromExcelize(t *testing.T) {89+ x := excelize.NewFile()90+ for a, v := range map[string]any{"A1": "Item", "B1": "Cost", "A2": "Rent", "B2": 1450, "A3": "Food", "B3": 300, "A4": "Total", "B4": 1750} {91+ x.SetCellValue("Sheet1", a, v)92+ }93+ x.SetCellFormula("Sheet1", "D1", "SUM(Costs[Cost])")94+ stripes := true95+ if err := x.AddTable("Sheet1", &excelize.Table{Range: "A1:B3", Name: "Costs", StyleName: "TableStyleLight9", ShowRowStripes: &stripes}); err != nil {96+ t.Fatal(err)97+ }98+ name := filepath.Join(t.TempDir(), "excelize.xlsx")99+ if err := x.SaveAs(name); err != nil {100+ t.Fatal(err)101+ }102+ x.Close()103+ got, err := Import(context.Background(), name, Options{})104+ if err != nil {105+ t.Fatal(err)106+ }107+ s := got.Sheet108+ tb, ok := s.TableAt(addr(t, "A2"))109+ if !ok || tb.Name != "Costs" || !tb.Banded || !tb.Header || strings.Join(tb.Cols, ",") != "Item,Cost" {110+ t.Fatalf("table %+v", tb)111+ }112+ if v := s.Value(addr(t, "D1")).String(); v != "1750" {113+ t.Errorf("D1 = %s", v)114+ }115+}116+117+// A table's totals row stays cells below it.118+func TestXLSXTableTotalsRow(t *testing.T) {119+ b, err := readParts(t, tableParts(), defaultXLSXLimits)120+ if err != nil {121+ t.Fatal(err)122+ }123+ s := b.Sheet124+ tb, ok := s.TableAt(addr(t, "A1"))125+ if !ok || tb.Range != rect(t, "A1:B2") || !tb.Banded || !tb.Header {126+ t.Fatalf("table %+v", tb)127+ }128+ if v := s.Value(addr(t, "C2")).String(); v != "10" {129+ t.Errorf("C2 = %s", v)130+ }131+}132+133+func rect(t *testing.T, s string) sheet.Rect {134+ t.Helper()135+ r, ok := sheet.ParseRange(s)136+ if !ok {137+ t.Fatalf("bad range %s", s)138+ }139+ return r140+}@@ -94,21 +94,27 @@ func (r *xlsxSheetReader) readTail() (*xlsxAutoFilter, error) {
9494 9595 // readAutoFilter reads an <autoFilter>, just started.9696 func (r *xlsxSheetReader) readAutoFilter(se xml.StartElement) (*xlsxAutoFilter, error) {97+ return readAutoFilter(r.x, se)98+}99+100+// readAutoFilter reads an <autoFilter> of a worksheet or a table, just101+// started in x.102+func readAutoFilter(x *xmlStream, se xml.StartElement) (*xlsxAutoFilter, error) {97103 af := &xlsxAutoFilter{}98104 af.ref, _ = attr(se, "ref")99- for depth := r.x.depth; ; {100- t, err := r.x.next()105+ for depth := x.depth; ; {106+ t, err := x.next()101107 if err != nil {102108 return nil, eofAsUnexpected(err)103109 }104- if _, ok := t.(xml.EndElement); ok && r.x.depth < depth {110+ if _, ok := t.(xml.EndElement); ok && x.depth < depth {105111 return af, nil106112 }107113 se, ok := t.(xml.StartElement)108114 if !ok {109115 continue110116 }111- if se.Name.Local == "filterColumn" && r.x.depth == depth+1 {117+ if se.Name.Local == "filterColumn" && x.depth == depth+1 {112118 af.cols = append(af.cols, xlsxFilterColumn{col: intAttr(se, "colId", -1)})113119 } else if n := len(af.cols); n > 0 {114120 af.cols[n-1].add(se)@@ -35,6 +35,13 @@ type xlsxWriter struct {
3535 dynamic bool // a dynamic array formula was written: see xlsxarray.go3636 spills map[sheet.Addr]sheet.Rect // the sheet being written's Snapshot.Spills3737 rules ruleNotes // rules left out, see xlsxrules.go38+ // Tables: see xlsxtables.go. tables are the keys of those written,39+ // headers the header cells of the sheet being written, tableParts40+ // how many parts are written and tableTypes their content types.41+ tables map[string]bool42+ headers map[sheet.Addr]bool43+ tableParts int44+ tableTypes []string3845 }3946 4047 // valueCount counts formulas written as values, keeping the first one's@@ -68,7 +75,7 @@ func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {
6875 // Errors stick in bw.6976 func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active bool) int {7077 r := snap.Range71- w.spills = snap.Spills78+ w.spills, w.headers = snap.Spills, headerCells(snap)7279 bw.WriteString(xmlHead + `<worksheet xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">`)7380 if len(snap.HiddenRows) > 0 {7481 bw.WriteString(`<sheetPr filterMode="1"/>`) // a filter is hiding rows@@ -101,12 +108,15 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
101108 }102109 w.styledRows(bw, snap, styled, sheet.MaxRows)103110 bw.WriteString(`</sheetData>`)104- writeAutoFilter(bw, snap)111+ if tableFilter(snap) < 0 {112+ writeAutoFilter(bw, snap)113+ }105114 writeMerges(bw, snap)106115 w.writeRules(bw, ws, snap)107116 if len(snap.Notes) > 0 {108117 bw.WriteString(`<legacyDrawing r:id="` + vmlRelID + `"/>`)109118 }119+ writeTableParts(bw, snap)110120 bw.WriteString(`</worksheet>`)111121 return r.To.Row - r.From.Row + 1112122 }@@ -129,6 +139,9 @@ func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte
129139 b = append(b, '"')130140 }131141 typ, v, inline := cellValue(c, fx != "")142+ if w.headers[a] && fx == "" && v != "" {143+ typ, v, inline = "inlineStr", c.Text(), true // a table's header is text144+ }132145 if typ != "" {133146 b = append(b, ` t="`...)134147 b = append(b, typ...)@@ -182,6 +195,9 @@ func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) (string, bool)
182195 return "", false183196 }184197 fx, arrays, ok := excelFormula(c.Input, w.renamed)198+ if w.unknownTable(c) {199+ fx, ok = "", false200+ }185201 if !ok {186202 w.values.add(w.multi, ws, a)187203 }@@ -16,6 +16,7 @@ const (
1616 tokOp // operators and punctuation1717 tokRefErr // #REF!, left behind when a referenced cell is deleted1818 tokSheet // a sheet name and its "!", as in Sheet2!A1 or 'My Sheet'!A1; text is the name19+ tokTable // a structured reference, Sales[Amount]; text is as written1920 )2021 2122 type token struct {@@ -180,6 +181,9 @@ func (lx *lexer) ident() error {
180181 lx.i++181182 return nil182183 }184+ if !at && lx.i < len(src) && src[lx.i] == '[' {185+ return lx.structured(start)186+ }183187 name := src[start:lx.i]184188 if at {185189 name = name[1:]@@ -195,6 +199,18 @@ func (lx *lexer) ident() error {
195199 return nil196200 }197201 202+// structured reads a structured reference: the table's name from203+// start, and its brackets from the lexer's position.204+func (lx *lexer) structured(start int) error {205+ end := bracketEnd(lx.src, lx.i)206+ if end < 0 {207+ return &ParseError{Pos: lx.i, Msg: "Missing ] in a table reference"}208+ }209+ lx.i = end210+ lx.emit(tokTable, lx.src[start:end], start)211+ return nil212+}213+198214 // parenFollows reports whether "(" comes next, after any spaces.199215 func (lx *lexer) parenFollows() bool {200216 j := lx.i@@ -98,6 +98,8 @@ func translate(src string, loc *locale.Locale, out bool) string {
9898 i = skipIdent(b, i+1)9999 case isDigit(c) || c == sep.dec && i+1 < len(b) && isDigit(b[i+1]):100100 i = sep.number(b, i)101+ case c == '[':102+ i = sep.brackets(b, i)101103 case c == '(' || c == '{':102104 open = append(open, c)103105 i++@@ -120,6 +122,45 @@ func translate(src string, loc *locale.Locale, out bool) string {
120122 return string(b)121123 }122124 125+// brackets translates the separators between the items of a126+// structured reference, Sales[[#Headers],[Amount]], starting at the "["127+// at i, and returns where it ends. Columns' names are left alone.128+func (sep separators) brackets(b []byte, i int) int {129+ end := bracketEnd(string(b), i)130+ if end < 0 {131+ end = len(b)132+ }133+ for j := i + 1; j < end; j++ {134+ switch {135+ case b[j] == '\'':136+ j++137+ case b[j] == sep.arg && prevByte(b, j) == ']' && nextByte(b, j, end) == '[':138+ b[j] = sep.toArg139+ }140+ }141+ return end142+}143+144+// prevByte is the byte before i, past spaces, or 0.145+func prevByte(b []byte, i int) byte {146+ for i--; i >= 0 && b[i] == ' '; i-- {147+ }148+ if i < 0 {149+ return 0150+ }151+ return b[i]152+}153+154+// nextByte is the byte after i, past spaces, before end, or 0.155+func nextByte(b []byte, i, end int) byte {156+ for i++; i < end && b[i] == ' '; i++ {157+ }158+ if i >= end {159+ return 0160+ }161+ return b[i]162+}163+123164 // skipQuoted skips a string or quoted sheet name starting at i, with its124165 // quote doubled inside; an unclosed one runs to the end.125166 func skipQuoted(b []byte, i int) int {@@ -151,6 +151,8 @@ func (p *parser) prefix() (Node, error) {
151151 return nil, &ParseError{Pos: ref.pos, Msg: "Expected a cell after " + QuoteSheet(t.text) + "!"}152152 }153153 return p.ident(ref, t.text)154+ case tokTable:155+ return tableRef(t.text, t.pos)154156 case tokFunc:155157 return p.call(t)156158 case tokRefErr:@@ -63,6 +63,8 @@ func printNode(b *strings.Builder, n Node) {
6363 b.WriteString(refErrorText)6464 case Name:6565 b.WriteString(n.Name)66+ case TableRef:67+ b.WriteString(n.String())6668 case Unary:6769 printUnary(b, n)6870 case Binary:@@ -12,13 +12,14 @@ type Rewriter struct {
1212 Range func(Range) Node1313 Name func(Name) Node1414 Local func(Local) Node15+ Table func(TableRef) Node1516 }1617 1718 // Rewrite returns n with its references mapped, and whether anything1819 // changed. Unchanged subtrees are shared.1920 func Rewrite(n Node, rw Rewriter) (Node, bool) {2021 switch n := n.(type) {21- case Ref, Range, Name, Local:22+ case Ref, Range, Name, Local, TableRef:2223 out := rw.leaf(n)2324 return out, out != n2425 case Unary:@@ -80,6 +81,10 @@ func (rw Rewriter) leaf(n Node) Node {
8081 if rw.Local != nil {8182 return rw.Local(n)8283 }84+ case TableRef:85+ if rw.Table != nil {86+ return rw.Table(n)87+ }8388 }8489 return n8590 }@@ -11,7 +11,7 @@ func SheetKey(name string) string { return strings.ToUpper(name) }
1111 // as a plain identifier (Sheet2), otherwise in single quotes with quotes1212 // doubled, e.g. 'Q3 plan'.1313 func QuoteSheet(name string) string {14- bare := name != "" && (isLetter(name[0]) || name[0] == '_') && !looksLikeCell(strings.ToUpper(name))14+ bare := name != "" && (isLetter(name[0]) || name[0] == '_') && !LooksLikeCell(strings.ToUpper(name))1515 for i := 0; bare && i < len(name); i++ {1616 c := name[i]1717 bare = isLetter(c) || isDigit(c) || c == '_' || c == '.'@@ -22,10 +22,10 @@ func QuoteSheet(name string) string {
2222 return "'" + strings.ReplaceAll(name, "'", "''") + "'"2323 }2424 25-// looksLikeCell reports whether an upper-case name reads as a cell in25+// LooksLikeCell reports whether an upper-case name reads as a cell in2626 // Excel, A1 to XFD1048576 or R1C1, so a sheet of that name is quoted2727 // everywhere its formulas may go. Sheet1 doesn't: SHEET is no column.28-func looksLikeCell(k string) bool {28+func LooksLikeCell(k string) bool {2929 digits := strings.TrimLeft(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")3030 if n := len(k) - len(digits); n >= 1 && n <= 3 && digits != "" && strings.Trim(digits, "0123456789") == "" {3131 return true@@ -0,0 +1,378 @@
1+package formula2+3+import (4+ "strings"5+)6+7+// Structured references name a table's cells by its columns' names, as8+// Excel's and Sheets' tables do: Sales[Amount] is the Amount column's9+// data rows, Sales[#All] the whole table, header row included,10+// Sales[@Amount] the Amount cell in the formula's own row, and11+// Sales[[#Headers],[Amount]:[Units]] the headers of three columns. The12+// parser only reads them; the engine finds the table and turns the13+// reference into a range when it evaluates the formula, so tables that14+// grow or columns that move are followed without rewriting formulas.15+16+// TableItems are the rows of a table a structured reference names.17+type TableItems uint818+19+const (20+ ItemHeaders TableItems = 1 << iota // the header row: [#Headers]21+ ItemData // the data rows: [#Data], or nothing22+ ItemTotals // a totals row: [#Totals]23+ ItemThisRow // the formula's own row: [#This Row] or @24+ // ItemAll is the whole table: [#All].25+ ItemAll = ItemHeaders | ItemData | ItemTotals26+)27+28+// itemWords are the item keywords, in the order they're printed.29+var itemWords = []struct {30+ item TableItems31+ word string32+}{{ItemAll, "#All"}, {ItemHeaders, "#Headers"}, {ItemData, "#Data"}, {ItemTotals, "#Totals"}, {ItemThisRow, "#This Row"}}33+34+// TableRef is a structured reference: Table[Column], Table[#All],35+// Table[@Column], Table[[#Headers],[First]:[Last]].36+type TableRef struct {37+ Table string // the table's name as written38+ // Items are the rows named; zero means the data rows, as when no39+ // item is written.40+ Items TableItems41+ // From and To are the first and last column named, as written: ""42+ // for every column, To "" for From alone.43+ From, To string44+}45+46+// Rows are the rows the reference names, with no item meaning the47+// data rows.48+func (t TableRef) Rows() TableItems {49+ if t.Items == 0 {50+ return ItemData51+ }52+ return t.Items53+}54+55+// Cols are the first and last column the reference names, the same56+// for one column, or "" for every column.57+func (t TableRef) Cols() (string, string) {58+ if t.To == "" {59+ return t.From, t.From60+ }61+ return t.From, t.To62+}63+64+// tableRef parses a structured reference token: the table's name and65+// its bracketed part, written as src, which starts at pos.66+func tableRef(src string, pos int) (TableRef, error) {67+ i := strings.IndexByte(src, '[')68+ t := TableRef{Table: src[:i]}69+ inner := strings.TrimSpace(src[i+1 : len(src)-1])70+ fail := func(msg string) (TableRef, error) {71+ return TableRef{}, &ParseError{Pos: pos + i, Msg: msg}72+ }73+ switch {74+ case inner == "":75+ case inner[0] == '@':76+ t.Items = ItemThisRow77+ rest := strings.TrimSpace(inner[1:])78+ if rest == "" {79+ break80+ }81+ if rest[0] != '[' {82+ t.From = unescapeColumn(rest)83+ break84+ }85+ items, from, to, msg := specList(rest)86+ if msg != "" || items != 0 {87+ return fail(cmpOr(msg, "Only columns can follow @"))88+ }89+ t.From, t.To = from, to90+ case inner[0] == '#':91+ it, ok := itemWord(inner)92+ if !ok {93+ return fail("Unknown table item " + inner + ": use #All, #Data, #Headers, #Totals or #This Row")94+ }95+ t.Items = it96+ case inner[0] == '[':97+ items, from, to, msg := specList(inner)98+ if msg != "" {99+ return fail(msg)100+ }101+ t.Items, t.From, t.To = items, from, to102+ default:103+ t.From = unescapeColumn(inner)104+ }105+ if msg := t.check(); msg != "" {106+ return fail(msg)107+ }108+ return t, nil109+}110+111+func cmpOr(a, b string) string {112+ if a != "" {113+ return a114+ }115+ return b116+}117+118+// check reports why the items can't go together, or "".119+func (t TableRef) check() string {120+ switch it := t.Items; {121+ case it&ItemThisRow != 0 && it != ItemThisRow:122+ return "#This Row can't be combined with other items"123+ case it&ItemHeaders != 0 && it&ItemTotals != 0 && it&ItemData == 0:124+ return "#Headers and #Totals need #Data between them"125+ }126+ if t.From == "" && t.To != "" {127+ return "Expected a column before :"128+ }129+ return ""130+}131+132+// itemWord reads an item keyword, ignoring case and spaces around it.133+func itemWord(s string) (TableItems, bool) {134+ s = strings.TrimSpace(s)135+ for _, w := range itemWords {136+ if strings.EqualFold(s, w.word) {137+ return w.item, true138+ }139+ }140+ return 0, false141+}142+143+// specList reads a list of bracketed items, [#Headers],[Amount]:[Units],144+// separated by , or ; and returns the items, the columns, and what's145+// wrong with it, if anything.146+func specList(s string) (items TableItems, from, to, msg string) {147+ cols := 0148+ for i := 0; ; {149+ i = skipSpaces(s, i)150+ text, next, ok := bracketed(s, i)151+ if !ok {152+ return 0, "", "", "Expected [ and ] around each item"153+ }154+ if strings.HasPrefix(strings.TrimSpace(text), "#") {155+ it, ok := itemWord(text)156+ if !ok {157+ return 0, "", "", "Unknown table item " + strings.TrimSpace(text) + ": use #All, #Data, #Headers, #Totals or #This Row"158+ }159+ items |= it160+ } else {161+ if cols++; cols > 1 {162+ return 0, "", "", "Name one column, or a range of them as [First]:[Last]"163+ }164+ from = unescapeColumn(text)165+ if j := skipSpaces(s, next); j < len(s) && s[j] == ':' {166+ var last string167+ if last, next, ok = bracketed(s, skipSpaces(s, j+1)); !ok {168+ return 0, "", "", "Expected [column] after :"169+ }170+ to = unescapeColumn(last)171+ }172+ }173+ i = skipSpaces(s, next)174+ if i >= len(s) {175+ return items, from, to, ""176+ }177+ if s[i] != ',' && s[i] != ';' {178+ return 0, "", "", "Expected , between a table's items"179+ }180+ i++181+ }182+}183+184+// bracketed reads [text] at s[i], with ' escaping the character after185+// it, and returns the text as written and the index past the "]".186+func bracketed(s string, i int) (string, int, bool) {187+ if i >= len(s) || s[i] != '[' {188+ return "", i, false189+ }190+ for j := i + 1; j < len(s); j++ {191+ switch s[j] {192+ case '\'':193+ j++194+ case '[':195+ return "", j, false196+ case ']':197+ return s[i+1 : j], j + 1, true198+ }199+ }200+ return "", len(s), false201+}202+203+func skipSpaces(s string, i int) int {204+ for i < len(s) && s[i] == ' ' {205+ i++206+ }207+ return i208+}209+210+// bracketEnd returns the index just past the "]" closing the "[" at211+// src[i], or -1: brackets nest, and ' escapes the character after it.212+func bracketEnd(src string, i int) int {213+ depth := 0214+ for j := i; j < len(src); j++ {215+ switch src[j] {216+ case '\'':217+ j++218+ case '[':219+ depth++220+ case ']':221+ if depth--; depth == 0 {222+ return j + 1223+ }224+ }225+ }226+ return -1227+}228+229+// StructuredEnd returns the index just past the brackets of a230+// structured reference whose "[" is at src[i], or -1 when they aren't231+// closed.232+func StructuredEnd(src string, i int) int { return bracketEnd(src, i) }233+234+// escaped are the characters a column's name escapes with ' in a235+// structured reference.236+const escaped = "[]#'"237+238+// unescapeColumn reads a column's name as written, trimmed, with its239+// escapes removed.240+func unescapeColumn(s string) string {241+ s = strings.TrimSpace(s)242+ if !strings.Contains(s, "'") {243+ return s244+ }245+ var b strings.Builder246+ for i := 0; i < len(s); i++ {247+ if s[i] == '\'' && i+1 < len(s) {248+ i++249+ }250+ b.WriteByte(s[i])251+ }252+ return b.String()253+}254+255+// EscapeColumn writes a column's name as a structured reference holds256+// it, with ' before each of [ ] # and '.257+func EscapeColumn(name string) string {258+ if !strings.ContainsAny(name, escaped) {259+ return name260+ }261+ var b strings.Builder262+ for i := 0; i < len(name); i++ {263+ if strings.IndexByte(escaped, name[i]) >= 0 {264+ b.WriteByte('\'')265+ }266+ b.WriteByte(name[i])267+ }268+ return b.String()269+}270+271+// plainColumn reports whether a column's name can stand alone in the272+// brackets, Sales[Unit Price], rather than in brackets of its own,273+// Sales[[Total, EUR]]: nothing but letters, digits, _, spaces inside274+// and characters beyond ASCII.275+func plainColumn(name string) bool {276+ if name == "" || name[0] == ' ' || name[len(name)-1] == ' ' {277+ return false278+ }279+ for i := 0; i < len(name); i++ {280+ if c := name[i]; !isLetter(c) && !isDigit(c) && c != '_' && c != ' ' && c < 0x80 {281+ return false282+ }283+ }284+ return true285+}286+287+// String writes the reference as 012 prints it: Sales[Amount],288+// Sales[@Amount], Sales[[#Headers],[Amount]].289+func (t TableRef) String() string { return t.write(false) }290+291+// Excel writes the reference as Excel's files hold it, where the292+// formula's own row is [#This Row] rather than @.293+func (t TableRef) Excel() string { return t.write(true) }294+295+func (t TableRef) write(excel bool) string {296+ var b strings.Builder297+ b.WriteString(t.Table)298+ b.WriteByte('[')299+ cols := t.columns()300+ switch {301+ case t.Items == ItemThisRow && !excel:302+ b.WriteByte('@')303+ if cols != "" && t.To == "" && plainColumn(t.From) && !strings.Contains(t.From, " ") {304+ b.WriteString(t.From)305+ } else {306+ b.WriteString(cols)307+ }308+ case t.Items == 0 && cols == "":309+ case t.Items == 0 && t.To == "" && plainColumn(t.From):310+ b.WriteString(t.From)311+ case t.Items == 0:312+ b.WriteString(cols)313+ case cols == "" && t.single():314+ b.WriteString(t.words()[0])315+ default:316+ for i, w := range t.words() {317+ if i > 0 {318+ b.WriteByte(',')319+ }320+ b.WriteString("[" + w + "]")321+ }322+ if cols != "" {323+ b.WriteString("," + cols)324+ }325+ }326+ b.WriteByte(']')327+ return b.String()328+}329+330+// columns writes the columns named in brackets, [From] or331+// [From]:[To], or "" for every column.332+func (t TableRef) columns() string {333+ if t.From == "" {334+ return ""335+ }336+ s := "[" + EscapeColumn(t.From) + "]"337+ if t.To != "" {338+ s += ":[" + EscapeColumn(t.To) + "]"339+ }340+ return s341+}342+343+// single reports whether the reference names one item keyword.344+func (t TableRef) single() bool { return len(t.words()) == 1 }345+346+// words are the item keywords the reference names.347+func (t TableRef) words() []string {348+ var out []string349+ left := t.Items350+ for _, w := range itemWords {351+ if left&w.item == w.item && w.item != 0 {352+ out = append(out, w.word)353+ left &^= w.item354+ }355+ }356+ return out357+}358+359+// ExcelTableRef writes a structured reference, the table's name and360+// its brackets as written in src, in Excel's file syntax, or reports361+// false when it doesn't parse.362+func ExcelTableRef(src string) (string, bool) {363+ t, err := tableRef(src, 0)364+ if err != nil {365+ return "", false366+ }367+ return t.Excel(), true368+}369+370+// WalkTables calls fn for every structured reference in n.371+func WalkTables(n Node, fn func(TableRef)) {372+ switch n := n.(type) {373+ case TableRef:374+ fn(n)375+ case Unary, Binary, Call, Array, Invoke:376+ EachChild(n, func(k Node) { WalkTables(k, fn) })377+ }378+}@@ -0,0 +1,113 @@
1+package formula2+3+import (4+ "strings"5+ "testing"6+)7+8+// Structured references parse into what they name and print back in9+// 012's spelling; Excel's file form spells the formula's row #This Row.10+func TestTableRefParse(t *testing.T) {11+ cases := []struct {12+ in string13+ want TableRef14+ prints string15+ excel string16+ }{17+ {"Sales[Amount]", TableRef{Table: "Sales", From: "Amount"}, "Sales[Amount]", "Sales[Amount]"},18+ {"sales[ Amount ]", TableRef{Table: "sales", From: "Amount"}, "sales[Amount]", "sales[Amount]"},19+ {"Sales[]", TableRef{Table: "Sales"}, "Sales[]", "Sales[]"},20+ {"Sales[#All]", TableRef{Table: "Sales", Items: ItemAll}, "Sales[#All]", "Sales[#All]"},21+ {"Sales[#headers]", TableRef{Table: "Sales", Items: ItemHeaders}, "Sales[#Headers]", "Sales[#Headers]"},22+ {"Sales[#This Row]", TableRef{Table: "Sales", Items: ItemThisRow}, "Sales[@]", "Sales[#This Row]"},23+ {"Sales[@Amount]", TableRef{Table: "Sales", Items: ItemThisRow, From: "Amount"}, "Sales[@Amount]", "Sales[[#This Row],[Amount]]"},24+ {"Sales[@[Unit Price]]", TableRef{Table: "Sales", Items: ItemThisRow, From: "Unit Price"}, "Sales[@[Unit Price]]", "Sales[[#This Row],[Unit Price]]"},25+ {"Sales[[#This Row],[Amount]]", TableRef{Table: "Sales", Items: ItemThisRow, From: "Amount"}, "Sales[@Amount]", "Sales[[#This Row],[Amount]]"},26+ {"Sales[[#Headers],[Amount]]", TableRef{Table: "Sales", Items: ItemHeaders, From: "Amount"}, "Sales[[#Headers],[Amount]]", "Sales[[#Headers],[Amount]]"},27+ {"Sales[[#Headers];[#Data];[Amount]:[Units]]", TableRef{Table: "Sales", Items: ItemHeaders | ItemData, From: "Amount", To: "Units"}, "Sales[[#Headers],[#Data],[Amount]:[Units]]", "Sales[[#Headers],[#Data],[Amount]:[Units]]"},28+ {"Sales[[Amount]:[Units]]", TableRef{Table: "Sales", From: "Amount", To: "Units"}, "Sales[[Amount]:[Units]]", "Sales[[Amount]:[Units]]"},29+ {"Sales[[Total, EUR]]", TableRef{Table: "Sales", From: "Total, EUR"}, "Sales[[Total, EUR]]", "Sales[[Total, EUR]]"},30+ {"Sales['#Items]", TableRef{Table: "Sales", From: "#Items"}, "Sales[['#Items]]", "Sales[['#Items]]"},31+ {"Sales[[Q'[1']]]", TableRef{Table: "Sales", From: "Q[1]"}, "Sales[[Q'[1']]]", "Sales[[Q'[1']]]"},32+ {"Sales[Größe]", TableRef{Table: "Sales", From: "Größe"}, "Sales[Größe]", "Sales[Größe]"},33+ {"app[status]", TableRef{Table: "app", From: "status"}, "app[status]", "app[status]"},34+ }35+ for _, c := range cases {36+ n, err := Parse("="+c.in, testFuncs)37+ if err != nil {38+ t.Errorf("%s: %v", c.in, err)39+ continue40+ }41+ got, ok := n.(TableRef)42+ if !ok || got != c.want {43+ t.Errorf("%s = %#v, want %#v", c.in, n, c.want)44+ continue45+ }46+ if p := Text(n); p != "="+c.prints {47+ t.Errorf("%s prints %s, want %s", c.in, p, c.prints)48+ }49+ if back, err := Parse(Text(n), testFuncs); err != nil || back != n {50+ t.Errorf("%s: printed %s parses as %#v, %v", c.in, Text(n), back, err)51+ }52+ if x := got.Excel(); x != c.excel {53+ t.Errorf("%s in Excel's form is %s, want %s", c.in, x, c.excel)54+ }55+ }56+}57+58+// Structured references take part in expressions like any reference.59+func TestTableRefInFormulas(t *testing.T) {60+ n, err := Parse("=SUM(Sales[Amount])*Sales[@Qty]+nu.app", testFuncs)61+ if err != nil {62+ t.Fatal(err)63+ }64+ var tables []string65+ WalkTables(n, func(r TableRef) { tables = append(tables, r.String()) })66+ if strings.Join(tables, " ") != "Sales[Amount] Sales[@Qty]" {67+ t.Errorf("walked %v", tables)68+ }69+ out, changed := Rewrite(n, Rewriter{Table: func(r TableRef) Node {70+ r.Table = "Revenue"71+ return r72+ }})73+ if !changed || Text(out) != "=SUM(Revenue[Amount])*Revenue[@Qty]+nu.app" {74+ t.Errorf("renamed to %s", Text(out))75+ }76+}77+78+func TestTableRefErrors(t *testing.T) {79+ for in, msg := range map[string]string{80+ "=Sales[Amount": "Missing ]",81+ "=Sales[#Everything]": "Unknown table item #Everything",82+ "=Sales[[#Nope],[A]]": "Unknown table item #Nope",83+ "=Sales[[#This Row],[#Data]]": "#This Row can't be combined",84+ "=Sales[[#Headers],[#Totals]]": "#Headers and #Totals need #Data",85+ "=Sales[[A],[B]]": "Name one column",86+ "=Sales[[A] [B]]": "Expected , between",87+ "=Sales[[A]:B]": "Expected [column] after :",88+ "=Sales[@[#Headers]]": "Only columns can follow @",89+ "=Sales[[A]]x": "Unexpected",90+ "=Sales[[A]]]": "Unexpected",91+ "=SUM(Sales[[#Data],[A]:[B]]": "Expected , or )",92+ "=Sales[Amount]:Sales[Units]+1": "Unexpected",93+ } {94+ _, err := Parse(in, testFuncs)95+ if err == nil || !strings.Contains(err.Error(), msg) {96+ t.Errorf("%s: %v, want %q", in, err, msg)97+ }98+ }99+}100+101+// Separators between a reference's items follow the locale; names in102+// brackets are left as they are.103+func TestTableRefLocale(t *testing.T) {104+ de := mustLocale(t, "de-DE")105+ typed := "=SUMME(Sales[[#Headers];[Total, EUR]];1,5)"106+ stored := Delocalize(typed, de)107+ if stored != "=SUMME(Sales[[#Headers],[Total, EUR]],1.5)" {108+ t.Errorf("stored %s", stored)109+ }110+ if back := Localize(stored, de); back != typed {111+ t.Errorf("shown %s", back)112+ }113+}@@ -63,7 +63,7 @@ func TestResolve(t *testing.T) {
6363 }6464 for ref, want := range map[string]string{6565 "Q4!A1": `no sheet named "Q4" (sheets: Sheet1, Q3 plan)`,66- "what": `"what" isn't a cell, a range, a named range or a sheet`,66+ "what": `"what" isn't a cell, a range, a named range, a table or a sheet`,6767 } {6868 if _, err := Resolve(w, ref); err == nil || !strings.Contains(err.Error(), want) {6969 t.Errorf("Resolve(%q): %v, want %q", ref, err, want)@@ -74,6 +74,43 @@ func TestResolve(t *testing.T) {
7474 }7575 }7676 77+// Tables are found by name, whole, or by structured references as78+// formulas read them; set types into a cell one names.79+func TestTables(t *testing.T) {80+ w := book(t)81+ if err := w.Sheet(0).CreateTable("Fruit", sheet.NewRect(addr("A1"), addr("B3"))); err != nil {82+ t.Fatal(err)83+ }84+ for ref, want := range map[string]string{85+ "Fruit": "Sheet1!A1:B3",86+ "fruit[Price]": "Sheet1!B2:B3",87+ "Fruit[#All]": "Sheet1!A1:B3",88+ "Fruit[[#Headers],[Price]]": "Sheet1!B1",89+ } {90+ if got, err := Resolve(w, ref); err != nil || got.String() != want {91+ t.Errorf("Resolve(%q) = %s, %v; want %s", ref, got, err, want)92+ }93+ }94+ for ref, want := range map[string]string{95+ "Fruit[Cost]": "Fruit has no such column (columns: Item, Price)",96+ "Fruit[@Item]": "names no rows of Fruit here",97+ "Veg[Item]": "there's no table named Veg",98+ } {99+ if _, err := Resolve(w, ref); err == nil || !strings.Contains(err.Error(), want) {100+ t.Errorf("Resolve(%q): %v, want %q", ref, err, want)101+ }102+ }103+ if got := get(t, w, "Fruit", GetOptions{Format: "nuon"}); got != "[[Item, Price]; [Apple, 1.5],\n[Pear, 2]]\n" {104+ t.Errorf("get Fruit %q", got)105+ }106+ if _, err := Set(w, []Entry{{"Fruit[[#Headers],[Price]]", "Cost"}}, SetOptions{}); err != nil {107+ t.Fatal(err)108+ }109+ if got := get(t, w, "Fruit[[#Headers],[Cost]]", GetOptions{}); got != "Cost\n" {110+ t.Errorf("the header set %q", got)111+ }112+}113+77114 func get(t *testing.T, w *sheet.Workbook, ref string, o GetOptions) string {78115 t.Helper()79116 target, err := Resolve(w, ref)@@ -28,8 +28,10 @@ func (t Target) String() string {
2828 2929 // Resolve finds what ref names in w: a cell or range (B7, A1:C9, A:A),3030 // on a sheet (Q3!B7, 'Q3 plan'!A1:C9) or else the sheet shown when the31-// file was saved; a named range (Sales); or a sheet by name (Q3, or32-// Q3!), meaning all of it. "" is the whole sheet shown.31+// file was saved; a named range (Sales); a table by name, all of it32+// with its header row, or by a structured reference as formulas read it33+// (Sales[Amount]); or a sheet by name (Q3, or Q3!), meaning all of it.34+// "" is the whole sheet shown.3335 func Resolve(w *sheet.Workbook, ref string) (Target, error) {3436 ref = strings.TrimSpace(ref)3537 shown := w.Sheet(w.Active())@@ -56,11 +58,14 @@ func Resolve(w *sheet.Workbook, ref string) (Target, error) {
5658 }5759 return Target{Sheet: n.Sheet, Range: n.Range}, nil5860 }61+ if s, r, ok, err := w.TableRange(rest); ok {62+ return Target{Sheet: s, Range: r}, err63+ }5964 if s := w.Lookup(rest); s != nil {6065 return Target{Sheet: s, Whole: true}, nil6166 }6267 }63- return Target{}, fmt.Errorf("%q isn't a cell, a range, a named range or a sheet (sheets: %s)", ref, sheetList(w))68+ return Target{}, fmt.Errorf("%q isn't a cell, a range, a named range, a table or a sheet (sheets: %s)", ref, sheetList(w))6469 }6570 6671 // ResolveCell is Resolve for a reference that must be one cell.@@ -129,7 +129,7 @@ func (e *evaluator) formula(s *Sheet, a Addr, c *Cell) {
129129 w := e.w130130 s.calc[a] = visiting131131 w.depth++132- expr := s.bound(c)132+ expr := s.bound(a, c)133133 outer, l := w.evaluating, loc{s, a}134134 w.evaluating = l135135 e.stack = append(e.stack, l)@@ -22,8 +22,9 @@ const FileExt = ".012"
2222 // name no sheet is still written as version 2 or 3, its sheet's name in2323 // a "name" field that earlier builds ignore.2424 // Version 5 is version 4 with pivot tables; a workbook without one is25-// still written as version 4.26-const fileVersion = 525+// still written as version 4. Version 6 is version 5 with tables26+// (tablefile.go), written only for a workbook with one.27+const fileVersion = 62728 2829 // The file is JSON with one entry per cell, keyed by address. A cell2930 // without formatting is just its input, as in version 1; a formatted cell@@ -112,8 +113,11 @@ func (w *Workbook) Write(out io.Writer) error {
112113 b.WriteString("\n}\n")113114 } else {114115 version := 4115- if w.hasPivots() {116- version = fileVersion116+ switch {117+ case w.hasTables():118+ version = 6119+ case w.hasPivots():120+ version = 5117121 }118122 fmt.Fprintf(b, "{\n \"version\": %d,\n", version)119123 if head := w.headLines(); head != "" {@@ -142,10 +146,10 @@ func (w *Workbook) Write(out io.Writer) error {
142146 }143147 144148 // single reports whether the workbook fits the single-sheet format of145-// versions 2 and 3: one sheet, not a notebook, and no formula naming a146-// sheet.149+// versions 2 and 3: one sheet, not a notebook, no pivot table nor table,150+// and no formula naming a sheet.147151 func (w *Workbook) single() bool {148- return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook()152+ return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook() && !w.hasTables()149153 }150154 151155 func jsonString(s string) string {@@ -217,6 +217,7 @@ func streamSeeds(t testing.TB) [][]byte {
217217 []byte(`{"sheets": [{"cells": {"A1": "1"}, "name": "S"}], "version": 4}`),218218 []byte(`{"version": 4, "sheets": [{"name": "A", "cells": {"A1": "1"}}, null], "sheets": [{"name": "B", "cells": {"B2": "A\n"}}]}`),219219 []byte(`{"version": 2, "cells": {"A1": "x", "a1": "y"}}`),220+ []byte(`{"version": 6, "sheets": [{"name": "S", "tables": [{"name":"Sales","range":"A1:B3","columns":["Item","Amount"],"banded":true}], "cells": {"A1": "Item", "B1": "Amount", "B2": "5", "C1": "=SUM(Sales[Amount])+Sales[[#Headers],[Item]]"}}]}`),220221 []byte(`{"version": 2, "cells": {"A1": "=(", "A1": "1", "B1": "2", "B1": ""}}`),221222 []byte(`{"version": 2, "cells": null, "sheets": 5}`),222223 []byte(`{"version": 4, "cells": "x", "sheets": []}`),@@ -50,6 +50,10 @@ func SplitSheet(s string) (sheet, rest string) { return formula.SplitSheet(s) }
5050 // Qualified writes r on sheet as a formula would: Sheet2!A1:B3.5151 func Qualified(sheet string, r Rect) string { return formula.Qualified(sheet, r) }5252 53+// EscapeColumn writes a table column's name as a structured reference54+// holds it, with ' before each of [ ] # and '.55+func EscapeColumn(name string) string { return formula.EscapeColumn(name) }56+5357 // Parse parses a formula with this engine's functions. src may start5458 // with "="; error positions are relative to src.5559 func Parse(src string) (Node, error) { return formula.Parse(src, parserFuncs) }@@ -156,6 +156,7 @@ func (w *Workbook) finish() {
156156 if h.depth > 0 {157157 return158158 }159+ w.settleTables()159160 st, dirty := h.open, h.dirty160161 h.open, h.dirty = nil, nil161162 if w.structural {@@ -45,13 +45,14 @@ func (w *Workbook) restore(st *step) (inv *step, changed []loc) {
4545 inv.names[k] = w.namePtr(k)4646 changed = append(changed, w.putName(k, n)...)4747 }48- w.restoreSheetParts(st, inv)48+ changed = append(changed, w.restoreSheetParts(st, inv)...)4949 return inv, changed5050 }5151 5252 // restoreSheetParts is restore's part for what a sheet keeps whole: its5353 // charts, pivot, rules and view, and the workbook's settings and macros.54-func (w *Workbook) restoreSheetParts(st, inv *step) {54+// It returns the formulas reading the tables it changed.55+func (w *Workbook) restoreSheetParts(st, inv *step) (changed []loc) {5556 for s, charts := range st.charts {5657 inv.charts[s] = s.charts5758 s.charts = slices.Clone(charts)@@ -82,6 +83,7 @@ func (w *Workbook) restoreSheetParts(st, inv *step) {
8283 for s, v := range st.views {8384 cur := s.view8485 inv.views[s] = &cur86+ changed = append(changed, w.tableUsers(cur.tables, v.tables)...)8587 s.view = *v8688 s.hidden.valid = false8789 if !slices.Equal(cur.merges, v.merges) {@@ -89,6 +91,7 @@ func (w *Workbook) restoreSheetParts(st, inv *step) {
8991 s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})9092 }9193 }94+ return changed9295 }9396 9497 // Try runs fn, a change, so that it can be taken back whole: after fn@@ -37,6 +37,7 @@ func (s *Sheet) Move(src Rect, to Addr) (Rect, error) {
3737 s.change(label, dst, func() {3838 s.remap(label, dst, cell, rng)3939 s.remapNames(rng) // a name for exactly the moved cells follows them40+ s.remapTables(src, rng)4041 s.moveFormats(s, &f, src, dst)4142 s.moveMerges(s, src, dst)4243 s.moveRules(src, dst, cell, rng) // rulemove.go@@ -65,6 +66,7 @@ func (s *Sheet) MoveTo(dst *Sheet, src Rect, to Addr) (Rect, error) {
6566 label := "move " + src.String() + " to " + formula.QuoteSheet(dst.name) + "!" + d.String()6667 dst.change(label, d, func() {6768 mv.apply(moved, readers)69+ s.moveTablesTo(dst, src, mv.shift)6870 dst.moveFormats(s, &f, src, d)6971 dst.moveMerges(s, src, d)7072 mv.moveRules() // rulemove.go@@ -51,7 +51,11 @@ func nameKey(name string) string { return strings.ToUpper(name) }
5151 // ValidName checks that name can be a named range, following Sheets'5252 // rules: letters, digits, _ and ., starting with a letter or _, and not5353 // something a formula would read as a cell or a boolean.54-func ValidName(name string) error {54+func ValidName(name string) error { return checkName(name, formula.LooksLikeRef) }55+56+// checkName checks name against the rules of names, isRef telling57+// which read as a cell reference.58+func checkName(name string, isRef func(string) bool) error {5559 switch {5660 case name == "":5761 return errors.New("Enter a name")@@ -68,7 +72,7 @@ func ValidName(name string) error {
6872 switch k := nameKey(name); {6973 case k == "TRUE" || k == "FALSE":7074 return errors.New("TRUE and FALSE can't be names")71- case formula.LooksLikeRef(k):75+ case isRef(k):7276 return errors.New("A name can't look like a cell reference, e.g. A1 or R1C1")7377 }7478 return nil@@ -107,6 +111,9 @@ func (w *Workbook) DefineName(name string, s *Sheet, r Rect) error {
107111 if _, _, ok := w.regionName(nameKey(name)); ok {108112 return fmt.Errorf("%s names a shell region", name)109113 }114+ if _, t, ok := w.Table(name); ok {115+ return fmt.Errorf("%s names a table", t.Name)116+ }110117 w.change(s, "name "+r.String()+" "+name, r, func() { w.putName(nameKey(name), &Name{Name: name, Sheet: s, Range: r}) })111118 return nil112119 }@@ -219,32 +226,3 @@ func (s *Sheet) remapNames(rng func(Rect) (Rect, bool)) {
219226 }220227 }221228 }222-223-// bound returns the cell's formula with its names replaced by the ranges224-// they stand for: what is evaluated and what dependencies are traced225-// through. Undefined names stay, and evaluate to #NAME?.226-func (s *Sheet) bound(c *Cell) Node {227- if len(c.names) == 0 {228- return c.expr229- }230- const fixed = formula.AbsCol | formula.AbsRow231- n, _ := formula.Rewrite(c.expr, formula.Rewriter{Name: func(nn formula.Name) Node {232- nm, ok := s.wb.names[nameKey(nn.Name)]233- if !ok {234- return s.boundRegion(nn)235- }236- switch {237- case nm.Gone():238- return formula.RefErr{}239- }240- sheet := ""241- if nm.Sheet != s {242- sheet = nm.Sheet.name243- }244- if nm.Range.From == nm.Range.To {245- return formula.Ref{Addr: nm.Range.From, Abs: fixed, Sheet: sheet}246- }247- return formula.Range{Rect: nm.Range, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}248- }})249- return n250-}@@ -0,0 +1,45 @@
1+package sheet2+3+import "fmt"4+5+// Tables among the random edits (randedit_test.go): made, renamed,6+// resized, styled and removed, and formulas reading them by column,7+// so their growth, header renames and bindings meet every other edit.8+9+func init() { randEdits = append(randEdits, (*randBook).editTable) }10+11+// randTables are the names the edits give tables.12+var randTables = []string{"Sales", "Costs"}13+14+func (r *randBook) editTable(e *edits) string {15+ s, name := r.sheet(e), randTables[e.n(len(randTables))]16+ w := s.wb17+ switch e.n(6) {18+ case 0:19+ rg := e.rect()20+ err := s.CreateTable(name, rg)21+ return fmt.Sprintf("table %s on %s!%s (%v)", name, s.name, rg, err)22+ case 1:23+ to := randTables[e.n(len(randTables))]24+ err := w.RenameTable(name, to)25+ return fmt.Sprintf("rename table %s to %s (%v)", name, to, err)26+ case 2:27+ rg := e.rect()28+ err := w.ResizeTable(name, rg)29+ return fmt.Sprintf("resize table %s to %s (%v)", name, rg, err)30+ case 3:31+ err := w.RemoveTable(name)32+ return fmt.Sprintf("remove table %s (%v)", name, err)33+ case 4:34+ banded, header := e.n(2) == 0, e.n(2) == 035+ err := w.SetTableStyle(name, banded, header)36+ return fmt.Sprintf("style table %s %v %v (%v)", name, banded, header, err)37+ }38+ a := e.addr()39+ in := []string{40+ "=SUM(" + name + "[Column1])", "=" + name + "[@Column2]*2", "=ROWS(" + name + "[#All])",41+ "=COUNTA(" + name + ")", "=SUM(" + name + "[[#Headers],[#Data],[Column1]:[Column2]])",42+ }[e.n(5)]43+ err := s.Set(a, in)44+ return fmt.Sprintf("%s!%s = %q (%v)", s.name, a, in, err)45+}@@ -137,7 +137,8 @@ type namedUsers struct {
137137 users map[loc]struct{}138138 }139139 140-// namedInUse returns the defined named ranges that formulas use.140+// namedInUse returns the defined named ranges, tables and regions that141+// formulas use.141142 func (w *Workbook) namedInUse() []namedUsers {142143 var named []namedUsers143144 for k, users := range w.nameUsers {@@ -145,6 +146,7 @@ func (w *Workbook) namedInUse() []namedUsers {
145146 named = append(named, namedUsers{nm.Sheet, nm.Range, users})146147 }147148 }149+ named = append(named, w.tablesInUse()...)148150 return append(named, w.regionsInUse()...)149151 }150152 @@ -47,6 +47,25 @@ func (s *Sheet) NamedSheets(a Addr) []string {
4747 return out4848 }4949 50+// NamedTables returns the tables the formula at a reads by structured51+// references, as written and without repeats (in any case), or nil for52+// anything else. Exporters check that they write them.53+func (s *Sheet) NamedTables(a Addr) []string {54+ c := s.cells.get(a)55+ if c == nil || c.expr == nil || len(c.names) == 0 {56+ return nil57+ }58+ var out []string59+ seen := map[string]bool{}60+ formula.WalkTables(c.expr, func(t formula.TableRef) {61+ if k := nameKey(t.Table); !seen[k] {62+ seen[k] = true63+ out = append(out, t.Table)64+ }65+ })66+ return out67+}68+5069 // ShiftEntry returns an entry as if it were typed in one cell and copied5170 // dc columns and dr rows away: a formula's relative references move and5271 // $absolute ones stay, as in a paste. Other entries come back unchanged.@@ -136,6 +136,9 @@ func (w *Workbook) checkRegionName(name string, output bool) error {
136136 if n, ok := w.LookupName(regionPrefix + name); ok {137137 return fmt.Errorf("%s already names %s", n.Name, n.Ref())138138 }139+ if _, t, ok := w.Table(name); ok {140+ return fmt.Errorf("There's already a table named %s", t.Name)141+ }139142 if !output && w.cellNamed(name) {140143 return fmt.Errorf("A notebook cell is named %s", name)141144 }@@ -143,8 +143,10 @@ func (s *Sheet) writeTable(r Region, me *regionMeta, header LiveRow, rows []Live
143143 s.freedFor(c.a)144144 }145145 if !had || was != me.written || shape != [2]int{me.rows, me.cols} { // moved, or its table changed shape146- for u := range s.wb.nameUsers[nameKey(r.FormulaName())] {147- changed = append(changed, u)146+ for _, k := range []string{nameKey(r.FormulaName()), nameKey(r.Name)} {147+ for u := range s.wb.nameUsers[k] {148+ changed = append(changed, u)149+ }148150 }149151 }150152 return changed@@ -68,7 +68,7 @@ func (s *Sheet) RemoteCalls(a Addr) []RemoteCall {
6868 }6969 formula.EachChild(n, walk)7070 }71- walk(s.bound(c))71+ walk(s.bound(a, c))7272 return calls7373 }7474 @@ -42,7 +42,7 @@ type Cell struct {
4242 refs []Addr // single-cell references in expr to its own sheet4343 ranges []Rect // range references in expr to its own sheet4444 xrefs []xref // references that name a sheet, e.g. Sheet2!A145- names []string // names in expr, as keys of Workbook.names45+ names []string // names and tables in expr, as keys of Workbook.names and tables4646 Style Style4747 volatile bool // expr calls TODAY, NOW, RAND...4848 derived bool // a pivot table's result, owned by the engine: see pivot.go@@ -367,6 +367,7 @@ func (c *Cell) setExpr(n Node) {
367367 c.ranges = append(c.ranges, r)368368 })369369 formula.WalkNames(n, func(nn formula.Name) { c.names = append(c.names, nameKey(nn.Name)) })370+ formula.WalkTables(n, func(t formula.TableRef) { c.names = append(c.names, nameKey(t.Table)) })370371 c.volatile = functions.IsVolatile(n)371372 }372373 }@@ -235,7 +235,8 @@ func TestParseNumber(t *testing.T) {
235235 }236236 237237 func FuzzParse(f *testing.F) {238- for _, s := range []string{"=A1*2", "=SUM(A1:B3)", "=IF(AND(A1>2,B1),1,NA())", `="a"&"b"`, "=1.5E-3^2", "$1,200", "12%", "@SUM(A1..B3)", "=$A$1+A$2*$B3", "=SUM($A1:B$3)", "=#REF!+1", "=A$$1"} {238+ for _, s := range []string{"=A1*2", "=SUM(A1:B3)", "=IF(AND(A1>2,B1),1,NA())", `="a"&"b"`, "=1.5E-3^2", "$1,200", "12%", "@SUM(A1..B3)", "=$A$1+A$2*$B3", "=SUM($A1:B$3)", "=#REF!+1", "=A$$1",239+ "=SUM(Sales[Amount])", "=Sales[[#Headers],[#Data],[A]:[B]]", "=T[@[Unit Price]]*T[@Qty]", "=T['#a']]", "=T[[#This Row],[x]]", "=T[#All]", "=T[[A]"} {239240 f.Add(s)240241 }241242 // Around the nesting cap: parentheses, calls and prefix operators.@@ -46,7 +46,7 @@ func (s *Sheet) EvaluateSteps(a Addr) *Steps {
4646 st.parts = append(st.parts, part{path: p, node: n})4747 paths = append(paths, p)4848 })49- run := s.wb.arith(s.bound(c))49+ run := s.wb.arith(s.bound(a, c))5050 var parts []functions.Part5151 st.top, parts = functions.EvalParts(run, paths, s.wb.values(s).lib, a)5252 for i := range st.parts {@@ -78,6 +78,10 @@ func (st *Steps) collect(n Node, path []int, fn func([]int, Node)) {
7878 if nm, ok := st.sheet.wb.names[nameKey(n.Name)]; ok && !nm.Gone() && nm.Range.From == nm.Range.To {7979 fn(path, n)8080 }81+ case formula.TableRef:82+ if _, ok := st.sheet.bindTable(st.at, n, 0).(formula.Ref); ok {83+ fn(path, n) // one cell, as Sales[@Amount] is84+ }8185 }8286 }8387 @@ -102,7 +106,8 @@ func (st *Steps) readRefs() {
102106 }103107 }104108 105-// refCell is the cell a reference or single-cell name points at, and109+// refCell is the cell a reference, single-cell name or table reference110+// points at, and106111 // its sheet (nil for a sheet no sheet has the name of).107112 func (st *Steps) refCell(n Node) (*Sheet, Addr, bool) {108113 s := st.sheet@@ -112,6 +117,10 @@ func (st *Steps) refCell(n Node) (*Sheet, Addr, bool) {
112117 case formula.Name:113118 nm := s.wb.names[nameKey(n.Name)]114119 return nm.Sheet, nm.Range.From, true120+ case formula.TableRef:121+ if r, ok := s.bindTable(st.at, n, 0).(formula.Ref); ok {122+ return s.wb.resolve(s, r.Sheet), r.Addr, true123+ }115124 }116125 return nil, Addr{}, false117126 }@@ -0,0 +1,357 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/formula"11+)12+13+// Tables, as Sheets' Format > Convert to table and Excel's tables: a14+// named range whose first row names its columns, so formulas read it by15+// column name (Sales[Amount], see tablebind.go). A table grows as rows16+// are typed or pasted just below it, and its columns' names follow its17+// header row: renaming a header renames the column in every formula18+// that reads it (tablesync.go). Tables belong to the sheet's view state,19+// so each change to one is an undo step with the rest of the view.20+//21+// A region's table (a notebook cell's output sent to a sheet, or a22+// linked file) is a table too, by the region's name: app[status] reads23+// its status column. Its rows and columns are its source's, so the24+// commands that rename, resize, style or remove a table leave it alone.25+26+// Table is a table on a sheet.27+type Table struct {28+ Name string // as the user spelled it; formulas match it in any case29+ Range Rect // header row included30+ // Cols are the columns' names, left to right: the header row's text31+ // as last settled, never blank and never the same twice.32+ Cols []string33+ // Banded shades every other data row; Header styles the header row.34+ Banded, Header bool35+}36+37+// equal reports whether two tables are the same.38+func (t Table) equal(u Table) bool {39+ return t.Name == u.Name && t.Range == u.Range && slices.Equal(t.Cols, u.Cols) && t.Banded == u.Banded && t.Header == u.Header40+}41+42+// clone is a copy of t sharing nothing with it.43+func (t Table) clone() Table {44+ t.Cols = slices.Clone(t.Cols)45+ return t46+}47+48+// Data is the table's data rows, below the header row.49+func (t Table) Data() Rect {50+ d := t.Range51+ d.From.Row++52+ return d53+}54+55+// Col returns the index of the column named name, ignoring case, or -1.56+func (t Table) Col(name string) int {57+ return slices.IndexFunc(t.Cols, func(c string) bool { return strings.EqualFold(c, name) })58+}59+60+// Errors of tables.61+var (62+ ErrNoTable = errors.New("There's no table by that name")63+ ErrTableOverlap = errors.New("A table can't overlap another table, a region or a pivot table")64+)65+66+// tablePrefix is reserved for regions' names in formulas (nu.sales).67+const tablePrefix = regionPrefix68+69+// ValidTableName checks that name can name a table: a named range's70+// rules, except that only a name Excel reads as a cell is refused, so71+// Table1 is one; and not starting with nu., which regions' names use.72+func ValidTableName(name string) error {73+ if err := checkName(name, formula.LooksLikeCell); err != nil {74+ return err75+ }76+ if strings.HasPrefix(nameKey(name), nameKey(tablePrefix)) {77+ return errors.New("A table's name can't start with " + tablePrefix)78+ }79+ return nil80+}81+82+// checkTableName reports why name can't name a table, besides the one83+// with key self (when renaming one).84+func (w *Workbook) checkTableName(name, self string) error {85+ if err := ValidTableName(name); err != nil {86+ return err87+ }88+ k := nameKey(name)89+ if n, ok := w.LookupName(name); ok {90+ return fmt.Errorf("%s already names %s", n.Name, n.Ref())91+ }92+ if _, t, ok := w.Table(name); ok && k != self {93+ return fmt.Errorf("There's already a table named %s", t.Name)94+ }95+ if _, r, ok := w.Region(name); ok {96+ return fmt.Errorf("%s names a notebook output or a linked file", r.Name)97+ }98+ return nil99+}100+101+// Tables returns the sheet's tables, in the order they were made.102+func (s *Sheet) Tables() []Table {103+ out := make([]Table, len(s.view.tables))104+ for i, t := range s.view.tables {105+ out[i] = t.clone()106+ }107+ return out108+}109+110+// HasTables reports whether the sheet has tables, so what draws it can111+// skip looking for them.112+func (s *Sheet) HasTables() bool { return len(s.view.tables) > 0 }113+114+// Table finds a table by name, ignoring case, on any sheet. Regions'115+// tables aren't among them: see TableInfo.116+func (w *Workbook) Table(name string) (*Sheet, Table, bool) {117+ k := nameKey(name)118+ for _, s := range w.sheets {119+ if i := s.tableIndex(k); i >= 0 {120+ return s, s.view.tables[i].clone(), true121+ }122+ }123+ return nil, Table{}, false124+}125+126+func (s *Sheet) tableIndex(k string) int {127+ return slices.IndexFunc(s.view.tables, func(t Table) bool { return nameKey(t.Name) == k })128+}129+130+// TableAt returns the table holding the cell at a.131+func (s *Sheet) TableAt(a Addr) (Table, bool) {132+ for _, t := range s.view.tables {133+ if t.Range.Contains(a) {134+ return t.clone(), true135+ }136+ }137+ return Table{}, false138+}139+140+// NextTableName is a name no table has yet: Table1, Table2 ...141+func (w *Workbook) NextTableName() string {142+ for i := 1; ; i++ {143+ name := "Table" + strconv.Itoa(i)144+ if w.checkTableName(name, "") == nil {145+ return name146+ }147+ }148+}149+150+// copiedTables are ts as a copied sheet holds them, each renamed to a151+// name no table has: Sales_2, Sales_3 ...152+func (w *Workbook) copiedTables(ts []Table) []Table {153+ out := make([]Table, len(ts))154+ taken := map[string]bool{}155+ for i, t := range ts {156+ t = t.clone()157+ for n := 2; ; n++ {158+ name := t.Name + "_" + strconv.Itoa(n)159+ if !taken[nameKey(name)] && w.checkTableName(name, "") == nil {160+ t.Name, taken[nameKey(name)] = name, true161+ break162+ }163+ }164+ out[i] = t165+ }166+ return out167+}168+169+// checkTableRange reports why r can't hold the table with key self:170+// it overlaps another table, a region or a pivot table.171+func (s *Sheet) checkTableRange(r Rect, self string) error {172+ for _, t := range s.view.tables {173+ if nameKey(t.Name) != self && overlaps(t.Range, r) {174+ return ErrTableOverlap175+ }176+ }177+ if _, _, ok := s.InRegion(r); ok || s.InPivot(r) {178+ return ErrTableOverlap179+ }180+ return nil181+}182+183+// tableRange is r as a table's range: a header row and at least one184+// row of data.185+func tableRange(r Rect) Rect {186+ if r.To.Row == r.From.Row && r.To.Row < MaxRows-1 {187+ r.To.Row++188+ }189+ return r190+}191+192+// CreateTable makes a table named name of r, whose first row names its193+// columns, with its header styled and no bands, as one undo step. Blank or repeated names in the header row194+// are replaced with ones of their own (Column2, Amount2), in the cells195+// too; a range of one row gains an empty row of data.196+func (s *Sheet) CreateTable(name string, r Rect) error {197+ if err := s.wb.checkTableName(name, ""); err != nil {198+ return err199+ }200+ r = tableRange(r)201+ if r.To.Row == r.From.Row {202+ return errors.New("A table needs a row below its header row")203+ }204+ if err := s.checkTableRange(r, ""); err != nil {205+ return err206+ }207+ s.change("make table "+name, r, func() {208+ t := Table{Name: name, Range: r, Header: true}209+ t.Cols = s.headerNames(t)210+ s.putTables(append(s.Tables(), t))211+ })212+ return nil213+}214+215+// RenameTable renames a table, as one undo step, and every formula216+// that reads it with it.217+func (w *Workbook) RenameTable(old, name string) error {218+ s, t, ok := w.Table(old)219+ if !ok {220+ return ErrNoTable221+ }222+ if err := w.checkTableName(name, nameKey(old)); err != nil {223+ return err224+ }225+ if t.Name == name {226+ return nil227+ }228+ s.change("rename table "+t.Name, t.Range, func() {229+ w.renameTableInFormulas(nameKey(old), name)230+ s.editTable(nameKey(old), func(t *Table) { t.Name = name })231+ })232+ return nil233+}234+235+// ResizeTable points a table at r, as one undo step. Its columns take236+// their names from r's first row.237+func (w *Workbook) ResizeTable(name string, r Rect) error {238+ s, t, ok := w.Table(name)239+ if !ok {240+ return ErrNoTable241+ }242+ r = tableRange(r)243+ if err := s.checkTableRange(r, nameKey(name)); err != nil {244+ return err245+ }246+ s.change("resize table "+t.Name, r, func() {247+ s.editTable(nameKey(name), func(t *Table) {248+ t.Range = r249+ t.Cols = s.headerNames(*t)250+ })251+ })252+ return nil253+}254+255+// SetTableStyle sets whether a table's data rows are banded and its256+// header row styled, as one undo step.257+func (w *Workbook) SetTableStyle(name string, banded, header bool) error {258+ s, t, ok := w.Table(name)259+ if !ok {260+ return ErrNoTable261+ }262+ s.change("style table "+t.Name, t.Range, func() {263+ s.editTable(nameKey(name), func(t *Table) { t.Banded, t.Header = banded, header })264+ })265+ return nil266+}267+268+// RemoveTable removes a table, keeping its cells, as one undo step.269+// Formulas that read it read the same cells by address instead, as270+// Excel's Convert to range leaves them.271+func (w *Workbook) RemoveTable(name string) error {272+ s, t, ok := w.Table(name)273+ if !ok {274+ return ErrNoTable275+ }276+ s.change("remove table "+t.Name, t.Range, func() {277+ w.unbindTable(nameKey(name))278+ st := s.Tables()279+ st = slices.DeleteFunc(st, func(u Table) bool { return nameKey(u.Name) == nameKey(name) })280+ s.putTables(st)281+ })282+ return nil283+}284+285+// editTable changes the table with key k with fn, recording the change.286+func (s *Sheet) editTable(k string, fn func(*Table)) {287+ st := s.Tables()288+ if i := slices.IndexFunc(st, func(t Table) bool { return nameKey(t.Name) == k }); i >= 0 {289+ fn(&st[i])290+ s.putTables(st)291+ }292+}293+294+// putTables replaces the sheet's tables, recording the view for undo,295+// and has the formulas that read the tables changed recalculated. A296+// filter on a table's range follows the table as it grows or shrinks.297+// Every change to tables goes through here.298+func (s *Sheet) putTables(st []Table) {299+ if slices.EqualFunc(st, s.view.tables, Table.equal) {300+ return301+ }302+ s.recordView()303+ s.followFilter(s.view.tables, st)304+ w := s.wb305+ if w.hist.open != nil {306+ w.hist.dirty = append(w.hist.dirty, w.tableUsers(s.view.tables, st)...)307+ }308+ s.view.tables = st309+}310+311+// followFilter moves the filter along with the table whose range it312+// has, when the table's range changes.313+func (s *Sheet) followFilter(before, after []Table) {314+ f := s.view.filter315+ if f == nil {316+ return317+ }318+ for _, t := range before {319+ i := slices.IndexFunc(after, func(u Table) bool { return nameKey(u.Name) == nameKey(t.Name) })320+ if t.Range != f.Range || i < 0 || after[i].Range == t.Range {321+ continue322+ }323+ nf := f.clone()324+ nf.Range = after[i].Range325+ for c := range nf.Cols {326+ if c > nf.Range.To.Col {327+ delete(nf.Cols, c)328+ }329+ }330+ s.view.filter = nf331+ s.hidden.valid = false332+ return333+ }334+}335+336+// tableUsers are the formulas reading any table in either list whose337+// range, columns or name differ between them.338+func (w *Workbook) tableUsers(before, after []Table) []loc {339+ var out []loc340+ add := func(k string) {341+ for u := range w.nameUsers[k] {342+ out = append(out, u)343+ }344+ }345+ for _, t := range before {346+ i := slices.IndexFunc(after, func(u Table) bool { return nameKey(u.Name) == nameKey(t.Name) })347+ if i < 0 || !after[i].equal(t) {348+ add(nameKey(t.Name))349+ }350+ }351+ for _, t := range after {352+ if !slices.ContainsFunc(before, func(u Table) bool { return nameKey(u.Name) == nameKey(t.Name) }) {353+ add(nameKey(t.Name))354+ }355+ }356+ return out357+}@@ -0,0 +1,293 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+// salesSheet is a sheet with a table Sales over A1:C4: Region, Units and10+// Amount, three rows of data.11+func salesSheet(t *testing.T) *Sheet {12+ t.Helper()13+ s := sheetOf(t, map[string]string{14+ "A1": "Region", "B1": "Units", "C1": "Amount",15+ "A2": "North", "B2": "2", "C2": "10",16+ "A3": "South", "B3": "3", "C3": "20",17+ "A4": "East", "B4": "5", "C4": "30",18+ })19+ if err := s.CreateTable("Sales", rect("A1:C4")); err != nil {20+ t.Fatal(err)21+ }22+ return s23+}24+25+// entry is the formula or entry at a.26+func entry(s *Sheet, a string) string {27+ if c := s.Cell(at(a)); c != nil {28+ return c.Input29+ }30+ return ""31+}32+33+func TestTableStructuredReferences(t *testing.T) {34+ s := salesSheet(t)35+ for a, f := range map[string]string{36+ "E1": "=SUM(Sales[Amount])",37+ "E2": "=ROWS(Sales[#All])",38+ "E3": "=Sales[[#Headers],[Units]]",39+ "E4": "=COLUMNS(Sales)*10+ROWS(Sales)",40+ "E5": "=SUM(Sales[[Units]:[Amount]])",41+ "D2": "=Sales[@Units]*Sales[@Amount]",42+ "E6": "=Sales[@Amount]",43+ "E7": "=SUM(Sales[Nope])",44+ "E8": "=SUM(Nope[Amount])",45+ "E9": "=ROWS(Sales[#Data])+ROWS(sales[#headers])",46+ } {47+ if err := s.Set(at(a), f); err != nil {48+ t.Fatalf("%s: %v", f, err)49+ }50+ }51+ wantShown(t, s, map[string]string{52+ "E1": "60", "E2": "4", "E3": "Units", "E4": "33", "E5": "70", "D2": "20",53+ "E6": "#REF!", "E7": "#REF!", "E8": "#REF!", "E9": "4",54+ })55+ // Changing a cell of the table recalculates what reads it.56+ s.Set(at("C2"), "100")57+ wantShown(t, s, map[string]string{"E1": "150", "D2": "200"})58+ if deps := s.Dependents(at("C3")); len(deps) == 0 {59+ t.Error("no dependents traced through the table")60+ }61+}62+63+// Rows typed or pasted just below a table join it, in the same undo64+// step as the entry.65+func TestTableGrows(t *testing.T) {66+ s := salesSheet(t)67+ s.Set(at("E1"), "=SUM(Sales[Amount])")68+ s.Set(at("C5"), "40")69+ if tb, _ := s.TableAt(at("C5")); tb.Range != rect("A1:C5") {70+ t.Fatalf("table %v", tb.Range)71+ }72+ wantShown(t, s, map[string]string{"E1": "100"})73+ s.Set(at("A7"), "not next to it")74+ if _, ok := s.TableAt(at("A7")); ok {75+ t.Error("a row with a gap joined the table")76+ }77+ s.Undo()78+ s.Undo()79+ if tb, _ := s.TableAt(at("A1")); tb.Range != rect("A1:C4") {80+ t.Errorf("undo left %v", tb.Range)81+ }82+ wantShown(t, s, map[string]string{"E1": "60"})83+ clip := s.Copy(rect("A2:C3"))84+ if _, err := s.Paste(clip, rect("A5"), false); err != nil {85+ t.Fatal(err)86+ }87+ if tb, _ := s.TableAt(at("A1")); tb.Range != rect("A1:C6") {88+ t.Errorf("paste below grew it to %v", tb.Range)89+ }90+ wantShown(t, s, map[string]string{"E1": "90"})91+}92+93+// A header changed in place renames its column in the formulas that94+// read it; blank and repeated headers get names of their own.95+func TestTableHeaderRenames(t *testing.T) {96+ s := salesSheet(t)97+ s.Set(at("E1"), "=SUM(Sales[Amount])+Sales[[#Headers],[Units]:[Amount]]")98+ s.Set(at("C1"), "Revenue")99+ if got := entry(s, "E1"); got != "=SUM(Sales[Revenue])+Sales[[#Headers],[Units]:[Revenue]]" {100+ t.Errorf("formula after renaming: %s", got)101+ }102+ s.Undo()103+ if got := entry(s, "E1"); got != "=SUM(Sales[Amount])+Sales[[#Headers],[Units]:[Amount]]" {104+ t.Errorf("formula after undo: %s", got)105+ }106+ s.Set(at("B1"), "")107+ s.Set(at("A1"), "Amount")108+ tb, _ := s.TableAt(at("A1"))109+ if strings.Join(tb.Cols, ",") != "Amount2,Column2,Amount" || entry(s, "A1") != "Amount2" || entry(s, "B1") != "Column2" {110+ t.Errorf("columns %v, A1 %q, B1 %q", tb.Cols, entry(s, "A1"), entry(s, "B1"))111+ }112+}113+114+// Columns inserted, deleted and moved keep formulas reading columns by115+// name; a deleted column's references show #REF!.116+func TestTableColumnsShift(t *testing.T) {117+ s := salesSheet(t)118+ s.Set(at("F1"), "=SUM(Sales[Amount])")119+ s.Set(at("F2"), "=SUM(Sales[Units])")120+ s.InsertCols(1, 1)121+ tb, _ := s.TableAt(at("A1"))122+ if tb.Range != rect("A1:D4") || strings.Join(tb.Cols, ",") != "Region,Column2,Units,Amount" {123+ t.Fatalf("after insert: %v %v", tb.Range, tb.Cols)124+ }125+ wantShown(t, s, map[string]string{"G1": "60", "G2": "10", "B1": "Column2"})126+ s.DeleteCols(2, 1)127+ wantShown(t, s, map[string]string{"F1": "60", "F2": "#REF!"})128+ if got := entry(s, "F2"); got != "=SUM(Sales[Units])" {129+ t.Errorf("formula after delete: %s", got)130+ }131+ // The whole table moved: formulas follow it.132+ if _, err := s.Move(rect("A1:C4"), at("A10")); err != nil {133+ t.Fatal(err)134+ }135+ if tb, _ := s.TableAt(at("A10")); tb.Name != "Sales" || tb.Range != rect("A10:C13") {136+ t.Errorf("after move: %v", tb)137+ }138+ wantShown(t, s, map[string]string{"F1": "60"})139+ s.DeleteRows(9, 1)140+ if s.HasTables() {141+ t.Error("a table kept without its header row")142+ }143+ wantShown(t, s, map[string]string{"F1": "#REF!"})144+}145+146+// Filling a formula down a table reads each row's own cells; copying147+// keeps structured references as they are.148+func TestTableFillAndCopy(t *testing.T) {149+ s := salesSheet(t)150+ if err := s.Book().ResizeTable("Sales", rect("A1:D4")); err != nil {151+ t.Fatal(err)152+ }153+ s.Set(at("D1"), "Total")154+ s.Set(at("D2"), "=Sales[@Units]*Sales[@Amount]")155+ if _, err := s.FillDown(rect("D2:D4")); err != nil {156+ t.Fatal(err)157+ }158+ wantShown(t, s, map[string]string{"D2": "20", "D3": "60", "D4": "150"})159+ if got := entry(s, "D4"); got != "=Sales[@Units]*Sales[@Amount]" {160+ t.Errorf("filled %s", got)161+ }162+ clip := s.Copy(rect("D2"))163+ s.Paste(clip, rect("H9"), false)164+ if got := entry(s, "H9"); got != "=Sales[@Units]*Sales[@Amount]" {165+ t.Errorf("pasted %s", got)166+ }167+ wantShown(t, s, map[string]string{"H9": "#REF!"})168+}169+170+func TestTableRenameAndRemove(t *testing.T) {171+ s := salesSheet(t)172+ w := s.Book()173+ s.Set(at("E1"), "=SUM(Sales[Amount])+ROWS(Sales)")174+ s.Set(at("D3"), "=Sales[@Amount]")175+ if err := w.RenameTable("sales", "Revenue"); err != nil {176+ t.Fatal(err)177+ }178+ if got := entry(s, "E1"); got != "=SUM(Revenue[Amount])+ROWS(Revenue)" {179+ t.Errorf("after rename: %s", got)180+ }181+ if err := w.RemoveTable("Revenue"); err != nil {182+ t.Fatal(err)183+ }184+ if got := entry(s, "E1"); got != "=SUM($C$2:$C$4)+ROWS($A$2:$C$4)" {185+ t.Errorf("after remove: %s", got)186+ }187+ if got := entry(s, "D3"); got != "=$C3" {188+ t.Errorf("this row after remove: %s", got)189+ }190+ wantShown(t, s, map[string]string{"E1": "63", "D3": "20"})191+ s.Undo()192+ if got := entry(s, "E1"); got != "=SUM(Revenue[Amount])+ROWS(Revenue)" || !s.HasTables() {193+ t.Errorf("undo remove: %s", got)194+ }195+}196+197+func TestTableNames(t *testing.T) {198+ s := salesSheet(t)199+ w := s.Book()200+ if err := s.CreateTable("Other", rect("B2:D6")); err != ErrTableOverlap {201+ t.Errorf("overlap: %v", err)202+ }203+ for _, name := range []string{"Sales", "A1", "nu.x", "", "two words"} {204+ if err := s.CreateTable(name, rect("H1:I3")); err == nil {205+ t.Errorf("%q named a table", name)206+ }207+ }208+ if err := w.DefineName("sales", s, rect("A1")); err == nil {209+ t.Error("a named range took a table's name")210+ }211+ if w.NextTableName() != "Table1" {212+ t.Errorf("next name %s", w.NextTableName())213+ }214+ if err := s.CreateTable("One", rect("H1:I1")); err != nil {215+ t.Fatal(err)216+ }217+ if tb, _ := s.TableAt(at("H1")); tb.Range != rect("H1:I2") || strings.Join(tb.Cols, ",") != "Column1,Column2" {218+ t.Errorf("a one-row table: %v %v", tb.Range, tb.Cols)219+ }220+}221+222+// A filter on a table's range grows with the table.223+func TestTableFilterFollows(t *testing.T) {224+ s := salesSheet(t)225+ s.CreateFilter(rect("A1:C4"))226+ s.Set(at("A5"), "West")227+ if r, _ := s.FilterRange(); r != rect("A1:C5") {228+ t.Errorf("filter %v", r)229+ }230+}231+232+func TestTableFileRoundTrip(t *testing.T) {233+ s := salesSheet(t)234+ w := s.Book()235+ w.SetTableStyle("Sales", false, true)236+ s.Set(at("E1"), "=SUM(Sales[Amount])")237+ var b bytes.Buffer238+ if err := s.Write(&b); err != nil {239+ t.Fatal(err)240+ }241+ if !strings.Contains(b.String(), `"version": 6`) || !strings.Contains(b.String(), `"tables": [{"name":"Sales","range":"A1:C4","columns":["Region","Units","Amount"],"header":true}]`) {242+ t.Fatalf("file:\n%s", b.String())243+ }244+ got, err := Read(&b)245+ if err != nil {246+ t.Fatal(err)247+ }248+ tb, ok := got.TableAt(at("B2"))249+ if !ok || tb.Name != "Sales" || tb.Banded || !tb.Header {250+ t.Errorf("read back %+v", tb)251+ }252+ wantShown(t, got, map[string]string{"E1": "60"})253+}254+255+// A notebook cell's output sent to a sheet is a table by the region's256+// name; nu.name stays the whole table.257+func TestRegionIsTable(t *testing.T) {258+ s := New()259+ sent(t, s, "app", "B2")260+ apply(t, s, LiveOp{Region: "app", Reset: true, Header: liveRow("status", "ms"), Rows: []LiveRow{liveRow("ok", "12"), liveRow("fail", "30")}})261+ s.Set(at("A1"), "=SUM(app[ms])")262+ s.Set(at("A2"), "=ROWS(app)")263+ s.Set(at("A3"), "=ROWS(nu.app)")264+ s.Set(at("A4"), "=COUNTIF(app[status],\"ok\")")265+ wantShown(t, s, map[string]string{"A1": "42", "A2": "2", "A3": "3", "A4": "1"})266+ apply(t, s, LiveOp{Region: "app", Reset: true, Header: liveRow("status", "ms"), Rows: []LiveRow{liveRow("ok", "1")}})267+ wantShown(t, s, map[string]string{"A1": "1", "A2": "1"})268+ if err := s.CreateTable("app", rect("H1:I3")); err == nil {269+ t.Error("a table took a region's name")270+ }271+}272+273+// Tracing follows structured references: a formula's precedents name274+// the table's cells as written, and a table's cells list the formulas275+// reading them.276+func TestTableTracing(t *testing.T) {277+ s := salesSheet(t)278+ s.Set(at("E1"), "=SUM(Sales[Amount])+ROWS(Sales)")279+ s.Set(at("D3"), "=Sales[@Units]*2")280+ links := s.PrecedentLinks(at("E1"))281+ if len(links) != 2 || links[0].Kind != LinkTable || links[0].Name != "Sales[Amount]" || links[0].Range != rect("C2:C4") ||282+ links[1].Name != "Sales" || links[1].Range != rect("A2:C4") {283+ t.Errorf("precedents %+v", links)284+ }285+ deps, _ := s.DependentLinks(at("B3"), 0)286+ if len(deps) != 2 {287+ t.Errorf("dependents of B3 %+v", deps)288+ }289+ st := s.EvaluateSteps(at("D3"))290+ if expr, value, ok := st.Next(); !ok || expr != "Sales[@Units]" || value != "3" || st.Result() != "6" {291+ t.Errorf("first step %q = %q, result %q", expr, value, st.Result())292+ }293+}@@ -0,0 +1,377 @@
1+package sheet2+3+import (4+ "fmt"5+ "slices"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// How formulas read tables (table.go): a structured reference,12+// Sales[Amount], is kept as written and turned into the range it names13+// each time the formula is evaluated, as named ranges are (names.go), so14+// a table that grows, or whose columns move, is followed without15+// rewriting a formula. A formula reading a table is one of the table's16+// users, by its name's key, so a change to the table's cells or to the17+// table itself recalculates it.18+//19+// A name alone that names no range but a table stands for the table's20+// data rows, Sales as Sales[]. A region's table is found by the region's21+// name the same way: app[status], app alone for its rows under the22+// header, and nu.app, the whole table with its header row, as app[#All].23+24+// tableView is a table as formulas read it: a table's, or a region's.25+type tableView struct {26+ s *Sheet27+ r Rect // header row included28+ cols []string // the columns' names29+ ok bool // false for a region with no table shown: #REF!30+}31+32+// findTable finds the table or region formulas name with the key k.33+func (w *Workbook) findTable(k string) (tableView, bool) {34+ for _, s := range w.sheets {35+ if i := s.tableIndex(k); i >= 0 {36+ t := s.view.tables[i]37+ return tableView{s: s, r: t.Range, cols: t.Cols, ok: true}, true38+ }39+ }40+ for _, s := range w.sheets {41+ if i := s.regionIndex(k); i >= 0 {42+ r, ok := s.RegionTable(s.regions.list[i].Name)43+ if !ok {44+ return tableView{s: s}, true45+ }46+ return tableView{s: s, r: r, cols: s.rowTexts(r), ok: true}, true47+ }48+ }49+ return tableView{}, false50+}51+52+// rowTexts are the texts of r's first row, as shown.53+func (s *Sheet) rowTexts(r Rect) []string {54+ out := make([]string, 0, r.To.Col-r.From.Col+1)55+ for c := r.From.Col; c <= r.To.Col; c++ {56+ out = append(out, strings.TrimSpace(s.ShownText(Addr{Col: c, Row: r.From.Row})))57+ }58+ return out59+}60+61+// col is the index of the column named name, ignoring case, or -1.62+func (v tableView) col(name string) int {63+ for i, c := range v.cols {64+ if strings.EqualFold(c, name) {65+ return i66+ }67+ }68+ return -169+}70+71+// rows are the first and last row it names on the table's sheet, for a72+// formula at a on the sheet same says whether it's the table's.73+func (v tableView) rows(it formula.TableItems, a Addr, same bool) (int, int, bool) {74+ h, last := v.r.From.Row, v.r.To.Row75+ if it == formula.ItemThisRow {76+ if !same || a.Row <= h || a.Row > last {77+ return 0, 0, false78+ }79+ return a.Row, a.Row, true80+ }81+ lo, hi := -1, -182+ if it&formula.ItemHeaders != 0 {83+ lo, hi = h, h84+ }85+ if it&formula.ItemData != 0 && last > h {86+ if lo < 0 {87+ lo = h + 188+ }89+ hi = last90+ }91+ return lo, hi, lo >= 092+}93+94+// colSpan is the first and last column t names on the sheet.95+func (v tableView) colSpan(t formula.TableRef) (int, int, bool) {96+ from, to := t.Cols()97+ if from == "" {98+ return v.r.From.Col, v.r.To.Col, true99+ }100+ i, j := v.col(from), v.col(to)101+ if i < 0 || j < 0 {102+ return 0, 0, false103+ }104+ return v.r.From.Col + min(i, j), v.r.From.Col + max(i, j), true105+}106+107+// bindTable is what the structured reference t in a formula at a on s108+// stands for: a cell or a range, or #REF! when the table, a column or109+// the row it names isn't there. thisRow is the absolute markers a110+// reference to the formula's own row takes; every other is absolute.111+func (s *Sheet) bindTable(a Addr, t formula.TableRef, thisRow formula.Abs) Node {112+ v, found := s.wb.findTable(nameKey(t.Table))113+ if !found || !v.ok {114+ return formula.RefErr{}115+ }116+ lo, hi, okRows := v.rows(t.Rows(), a, v.s == s)117+ c0, c1, okCols := v.colSpan(t)118+ if !okRows || !okCols {119+ return formula.RefErr{}120+ }121+ sheet := ""122+ if v.s != s {123+ sheet = v.s.name124+ }125+ abs := formula.AbsCol | formula.AbsRow126+ if t.Items == formula.ItemThisRow {127+ abs = thisRow128+ }129+ r := Rect{From: Addr{Col: c0, Row: lo}, To: Addr{Col: c1, Row: hi}}130+ if r.From == r.To {131+ return formula.Ref{Addr: r.From, Abs: abs, Sheet: sheet}132+ }133+ return formula.Range{Rect: r, Abs: [2]formula.Abs{abs, abs}, Sheet: sheet}134+}135+136+// bound returns the formula at a in c with its names and structured137+// references replaced by the ranges they stand for: what is evaluated138+// and what dependencies are traced through. Undefined names stay, and139+// evaluate to #NAME?.140+func (s *Sheet) bound(a Addr, c *Cell) Node {141+ if len(c.names) == 0 {142+ return c.expr143+ }144+ const fixed = formula.AbsCol | formula.AbsRow145+ n, _ := formula.Rewrite(c.expr, formula.Rewriter{146+ Name: func(nn formula.Name) Node {147+ nm, ok := s.wb.names[nameKey(nn.Name)]148+ if !ok {149+ return s.boundTableName(a, nn)150+ }151+ if nm.Gone() {152+ return formula.RefErr{}153+ }154+ sheet := ""155+ if nm.Sheet != s {156+ sheet = nm.Sheet.name157+ }158+ if nm.Range.From == nm.Range.To {159+ return formula.Ref{Addr: nm.Range.From, Abs: fixed, Sheet: sheet}160+ }161+ return formula.Range{Rect: nm.Range, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}162+ },163+ Table: func(t formula.TableRef) Node { return s.bindTable(a, t, fixed) },164+ })165+ return n166+}167+168+// boundTableName is a name in a formula at a that isn't a named range:169+// a table's data rows (Sales), a region's whole table (nu.app), or the170+// name as written, which shows #NAME?.171+func (s *Sheet) boundTableName(a Addr, nn formula.Name) Node {172+ if _, found := s.wb.findTable(nameKey(nn.Name)); found {173+ return s.bindTable(a, formula.TableRef{Table: nn.Name}, formula.AbsCol|formula.AbsRow)174+ }175+ return s.boundRegion(nn)176+}177+178+// tablesInUse are the tables and regions formulas name, by structured179+// references or their names alone, with their cells, for finding what180+// reads a changed cell. A region named as nu.app is among regionsInUse.181+func (w *Workbook) tablesInUse() []namedUsers {182+ var out []namedUsers183+ for k, users := range w.nameUsers {184+ if _, isName := w.names[k]; isName {185+ continue186+ }187+ if v, ok := w.findTable(k); ok && v.ok {188+ out = append(out, namedUsers{v.s, v.r, users})189+ }190+ }191+ return out192+}193+194+// renameTableInFormulas rewrites the formulas reading the table with195+// key from to name it to instead.196+func (w *Workbook) renameTableInFormulas(from, to string) {197+ w.rewriteUsers(from, formula.Rewriter{198+ Name: func(n formula.Name) Node {199+ if nameKey(n.Name) == from {200+ return formula.Name{Name: to}201+ }202+ return n203+ },204+ Table: func(t formula.TableRef) Node {205+ if nameKey(t.Table) == from {206+ t.Table = to207+ }208+ return t209+ },210+ })211+}212+213+// renameColumnInFormulas rewrites the structured references to the214+// column old of the table with key k to name the column name instead.215+func (w *Workbook) renameColumnInFormulas(k, old, name string) {216+ w.rewriteUsers(k, formula.Rewriter{Table: func(t formula.TableRef) Node {217+ if nameKey(t.Table) != k {218+ return t219+ }220+ if strings.EqualFold(t.From, old) {221+ t.From = name222+ }223+ if strings.EqualFold(t.To, old) {224+ t.To = name225+ }226+ return t227+ }})228+}229+230+// unbindTable rewrites the formulas reading the table with key k to231+// read the cells it stands for by address: its column in a row of its232+// own relative to the formula (C5), the rest absolute ($C$2:$C$9).233+func (w *Workbook) unbindTable(k string) {234+ var users []loc235+ for u := range w.nameUsers[k] {236+ users = append(users, u)237+ }238+ for _, u := range users {239+ c := u.s.cells.get(u.a)240+ out := c.rewritten(formula.Rewriter{241+ Name: func(n formula.Name) Node {242+ if nameKey(n.Name) != k {243+ return n244+ }245+ if _, isName := w.names[k]; isName {246+ return n247+ }248+ return u.s.bindTable(u.a, formula.TableRef{Table: n.Name}, 0)249+ },250+ Table: func(t formula.TableRef) Node {251+ if nameKey(t.Table) != k {252+ return t253+ }254+ return u.s.bindTable(u.a, t, formula.AbsCol)255+ },256+ })257+ if out != c {258+ u.s.place(u.a, out)259+ }260+ }261+}262+263+// rewriteUsers rewrites the formulas using the name or table with key264+// k with rw.265+func (w *Workbook) rewriteUsers(k string, rw formula.Rewriter) {266+ var users []loc267+ for u := range w.nameUsers[k] {268+ users = append(users, u)269+ }270+ for _, u := range users {271+ c := u.s.cells.get(u.a)272+ if out := c.rewritten(rw); out != c {273+ u.s.place(u.a, out)274+ }275+ }276+}277+278+// TableInfo is a table as formulas find it: a table's, or a region's.279+type TableInfo struct {280+ Name string281+ Sheet *Sheet282+ // Range is its cells, header row included, when Shown.283+ Range Rect284+ shown bool285+ Cols []string286+ // Region is set on a region's table, which its source names and287+ // shapes: the commands that change tables leave it alone.288+ Region bool289+}290+291+// Shown reports whether the table has cells: a region's may have none292+// yet.293+func (t TableInfo) Shown() bool { return t.shown }294+295+// TableInfos returns every table formulas can read, sheet by sheet:296+// each sheet's tables in the order they were made, then its regions'.297+func (w *Workbook) TableInfos() []TableInfo {298+ var out []TableInfo299+ for _, s := range w.sheets {300+ for _, t := range s.view.tables {301+ out = append(out, TableInfo{Name: t.Name, Sheet: s, Range: t.Range, shown: true, Cols: slices.Clone(t.Cols)})302+ }303+ for _, r := range s.regions.list {304+ v, _ := w.findTable(nameKey(r.Name))305+ out = append(out, TableInfo{Name: r.Name, Sheet: s, Range: v.r, shown: v.ok, Cols: v.cols, Region: true})306+ }307+ }308+ return out309+}310+311+// LookupTable finds a table formulas can read by name, ignoring case.312+func (w *Workbook) LookupTable(name string) (TableInfo, bool) {313+ k := nameKey(name)314+ for _, t := range w.TableInfos() {315+ if nameKey(t.Name) == k {316+ return t, true317+ }318+ }319+ return TableInfo{}, false320+}321+322+// TableLook says how the cell at a draws as part of a table: in the323+// header row's style, or on a band (every other data row), when its324+// table has them.325+func (s *Sheet) TableLook(a Addr) (header, band bool) {326+ for i := range s.view.tables {327+ t := &s.view.tables[i]328+ if !t.Range.Contains(a) {329+ continue330+ }331+ if a.Row == t.Range.From.Row {332+ return t.Header, false333+ }334+ return false, t.Banded && (a.Row-t.Range.From.Row)%2 == 0335+ }336+ return false, false337+}338+339+// TableRange finds the cells a structured reference written alone340+// stands for (Sales[Amount], Sales[#All]), as a formula outside the341+// table reads them, or with a table's name alone its whole table,342+// header row included. ok is false when ref is neither; err says why343+// one names no cells.344+func (w *Workbook) TableRange(ref string) (s *Sheet, r Rect, ok bool, err error) {345+ n, perr := formula.Parse(ref, parserFuncs)346+ if perr != nil {347+ return nil, Rect{}, false, nil348+ }349+ var t formula.TableRef350+ switch n := n.(type) {351+ case formula.TableRef:352+ t = n353+ case formula.Name:354+ t = formula.TableRef{Table: n.Name, Items: formula.ItemAll}355+ default:356+ return nil, Rect{}, false, nil357+ }358+ v, found := w.findTable(nameKey(t.Table))359+ switch {360+ case !found:361+ if _, isName := n.(formula.Name); isName {362+ return nil, Rect{}, false, nil363+ }364+ return nil, Rect{}, true, fmt.Errorf("there's no table named %s", t.Table)365+ case !v.ok:366+ return nil, Rect{}, true, fmt.Errorf("%s has no rows yet", t.Table)367+ }368+ lo, hi, okRows := v.rows(t.Rows(), Addr{}, false)369+ c0, c1, okCols := v.colSpan(t)370+ switch {371+ case !okCols:372+ return nil, Rect{}, true, fmt.Errorf("%s has no such column (columns: %s)", t.Table, strings.Join(v.cols, ", "))373+ case !okRows:374+ return nil, Rect{}, true, fmt.Errorf("%s names no rows of %s here: a formula's own row, or rows it hasn't", ref, t.Table)375+ }376+ return v.s, Rect{From: Addr{Col: c0, Row: lo}, To: Addr{Col: c1, Row: hi}}, true, nil377+}@@ -0,0 +1,77 @@
1+package sheet2+3+import (4+ "fmt"5+)6+7+// Tables in the file (version 6): a sheet's tables are a list in its8+// view, each with its name, its range, header row included, its9+// columns' names and its style:10+//11+// "tables": [{"name":"Sales","range":"A1:C20","columns":["Region","Month","Amount"],"banded":true,"header":true}],12+//13+// They need a version: a build without tables can't read the formulas14+// that read them.15+type fileTable struct {16+ Name string `json:"name"`17+ Range string `json:"range"`18+ Columns []string `json:"columns,omitempty"`19+ Banded bool `json:"banded,omitempty"`20+ Header bool `json:"header,omitempty"`21+}22+23+// hasTables reports whether any sheet has a table.24+func (w *Workbook) hasTables() bool {25+ for _, s := range w.sheets {26+ if s.HasTables() {27+ return true28+ }29+ }30+ return false31+}32+33+// encodeTables is the sheet's tables as the file holds them.34+func encodeTables(ts []Table) []fileTable {35+ out := make([]fileTable, len(ts))36+ for i, t := range ts {37+ out[i] = fileTable{Name: t.Name, Range: t.Range.String(), Columns: t.Cols, Banded: t.Banded, Header: t.Header}38+ }39+ return out40+}41+42+// readTables restores a sheet's tables from a file, its cells already43+// read.44+func (s *Sheet) readTables(fts []fileTable) error {45+ for _, ft := range fts {46+ r, ok := ParseRange(ft.Range)47+ if !ok {48+ return fmt.Errorf("table %q: invalid range %q", ft.Name, ft.Range)49+ }50+ if err := s.LoadTable(Table{Name: ft.Name, Range: r, Cols: ft.Columns, Banded: ft.Banded, Header: ft.Header}); err != nil {51+ return fmt.Errorf("table %q: %w", ft.Name, err)52+ }53+ }54+ return nil55+}56+57+// LoadTable adds t to the sheet as a loader does, without recording58+// undo, its cells loaded. Columns t doesn't name, or names in a number59+// other than its range's width, are named from its header row, and the60+// names are made unique. Its range isn't checked against what else the61+// sheet holds: edits since it was made (a region sent over it, a table62+// moved onto another) may overlap them, and the file opens as it was63+// saved.64+func (s *Sheet) LoadTable(t Table) error {65+ if err := s.wb.checkTableName(t.Name, ""); err != nil {66+ return err67+ }68+ if t.Range.To.Row == t.Range.From.Row || !t.Range.To.Valid() {69+ return fmt.Errorf("the range %s has no row below its header", t.Range)70+ }71+ t.Cols = columnNames(t.Cols, nil)72+ if len(t.Cols) != t.Range.To.Col-t.Range.From.Col+1 {73+ t.Cols = columnNames(s.rowTexts(Rect{From: t.Range.From, To: Addr{Col: t.Range.To.Col, Row: t.Range.From.Row}}), nil)74+ }75+ s.view.tables = append(s.view.tables, t)76+ return nil77+}@@ -0,0 +1,252 @@
1+package sheet2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// Keeping tables (table.go) in step with their cells. At the end of12+// every change, before recalculating, a table grows over the rows just13+// below it that the change filled, and its columns' names are read14+// again from its header row when the change touched it: a name changed15+// in place renames the column in the formulas that read it, and a16+// blank or repeated header is given a name of its own. Undo puts back17+// the tables with the view, and the formulas and cells with theirs, so18+// it runs none of this.19+20+// settleTables brings the tables on the sheets the open step changed21+// in step with their cells, within the step.22+func (w *Workbook) settleTables() {23+ var touched map[*Sheet][]Addr24+ for _, l := range w.hist.dirty {25+ if len(l.s.view.tables) == 0 || !l.s.live {26+ continue27+ }28+ if touched == nil {29+ touched = map[*Sheet][]Addr{}30+ }31+ touched[l.s] = append(touched[l.s], l.a)32+ }33+ for s, at := range touched {34+ for _, t := range s.Tables() {35+ s.growTable(t, at)36+ }37+ for _, t := range s.Tables() {38+ if headerTouched(t, at) {39+ s.syncHeader(t)40+ }41+ }42+ }43+}44+45+// headerTouched reports whether a cell of t's header row is among at,46+// or t's columns don't match its width.47+func headerTouched(t Table, at []Addr) bool {48+ if len(t.Cols) != t.Range.To.Col-t.Range.From.Col+1 {49+ return true50+ }51+ return slices.ContainsFunc(at, func(a Addr) bool {52+ return a.Row == t.Range.From.Row && a.Col >= t.Range.From.Col && a.Col <= t.Range.To.Col53+ })54+}55+56+// growTable extends t over the rows just below it that cells among at,57+// filled in t's columns, make a run from its last row.58+func (s *Sheet) growTable(t Table, at []Addr) {59+ filled := map[int]bool{}60+ for _, a := range at {61+ if a.Row > t.Range.To.Row && a.Col >= t.Range.From.Col && a.Col <= t.Range.To.Col && s.cells.filledAt(a) {62+ filled[a.Row] = true63+ }64+ }65+ end := t.Range.To.Row66+ for filled[end+1] {67+ end++68+ }69+ if end == t.Range.To.Row {70+ return71+ }72+ r := t.Range73+ r.To.Row = end74+ if s.checkTableRange(r, nameKey(t.Name)) != nil {75+ return76+ }77+ s.editTable(nameKey(t.Name), func(t *Table) { t.Range = r })78+}79+80+// syncHeader reads t's columns' names again from its header row,81+// renaming in formulas each column whose name changed in place.82+func (s *Sheet) syncHeader(t Table) {83+ names := s.headerNames(t)84+ if slices.Equal(names, t.Cols) {85+ return86+ }87+ k := nameKey(t.Name)88+ for old, name := range renamedColumns(t.Cols, names) {89+ s.wb.renameColumnInFormulas(k, old, name)90+ }91+ s.editTable(k, func(t *Table) { t.Cols = names })92+}93+94+// renamedColumns pairs the names in before that are gone from after95+// with the names in their place that are new, by position: a column96+// renamed in its header. Names that moved, as when columns are inserted,97+// deleted or moved, rename nothing.98+func renamedColumns(before, after []string) map[string]string {99+ out := map[string]string{}100+ has := func(list []string, name string) bool {101+ return slices.ContainsFunc(list, func(n string) bool { return strings.EqualFold(n, name) })102+ }103+ for i := range min(len(before), len(after)) {104+ old, name := before[i], after[i]105+ if old == "" || old == name || has(after, old) || has(before, name) && !strings.EqualFold(old, name) {106+ continue107+ }108+ out[old] = name109+ }110+ return out111+}112+113+// headerNames reads the names of t's columns from its header row (see114+// columnNames), writing the names it gives blank or repeated headers115+// into their cells, so the header shows what formulas use.116+func (s *Sheet) headerNames(t Table) []string {117+ texts := s.rowTexts(Rect{From: t.Range.From, To: Addr{Col: t.Range.To.Col, Row: t.Range.From.Row}})118+ names := columnNames(texts, t.Cols)119+ for i, name := range names {120+ a := Addr{Col: t.Range.From.Col + i, Row: t.Range.From.Row}121+ if name == texts[i] {122+ continue123+ }124+ if _, spilled := s.SpillAnchor(a); !spilled {125+ s.put(a, name)126+ }127+ }128+ return names129+}130+131+// columnNames are the names of a table's columns whose header cells132+// show texts: each text, with a blank one named after its place133+// (Column3) and a repeat numbered (Amount2). Of two headers the same, the134+// one already a column's name in prev, the names before, keeps it.135+func columnNames(texts, prev []string) []string {136+ names := make([]string, len(texts))137+ seen := map[string]bool{}138+ for i, name := range texts {139+ if k := nameKey(name); name != "" && i < len(prev) && nameKey(prev[i]) == k && !seen[k] {140+ seen[k], names[i] = true, name141+ }142+ }143+ for i, name := range texts {144+ if k := nameKey(name); name != "" && names[i] == "" && !seen[k] {145+ seen[k], names[i] = true, name146+ }147+ }148+ for i, name := range names {149+ if name != "" {150+ continue151+ }152+ base := texts[i]153+ if base == "" {154+ base = "Column"155+ }156+ name = uniqueColumn(base, i+1, seen)157+ seen[nameKey(name)], names[i] = true, name158+ }159+ return names160+}161+162+// uniqueColumn is base numbered so no name in seen has it: base with163+// the column's place for a blank header (Column3), else from 2 up.164+func uniqueColumn(base string, place int, seen map[string]bool) string {165+ from := 2166+ if base == "Column" {167+ from = place168+ }169+ for i := from; ; i++ {170+ if name := base + strconv.Itoa(i); !seen[nameKey(name)] {171+ return name172+ }173+ }174+}175+176+// shiftTables moves tables with inserted or deleted rows or columns, as177+// a range reference moves. A table whose header row or every column is178+// deleted goes; one left without data rows keeps an empty one. Columns179+// inserted into a table come in unnamed, to be named from their header180+// cells when the change ends.181+func shiftTables(ts []Table, rows bool, sp formula.Span) []Table {182+ if len(ts) == 0 {183+ return ts184+ }185+ _, rng := formula.AxisMaps(rows, sp)186+ var out []Table187+ for _, t := range ts {188+ if rows && sp.N < 0 && t.Range.From.Row >= sp.At && t.Range.From.Row < sp.At-sp.N {189+ continue // the header row went190+ }191+ r, ok := rng(t.Range)192+ if !ok {193+ continue194+ }195+ t = t.clone()196+ if !rows {197+ t.Cols = shiftColumns(t, r, sp)198+ }199+ t.Range = tableRange(r)200+ out = append(out, t)201+ }202+ return out203+}204+205+// shiftColumns are t's columns' names once its range is r, after206+// columns were inserted or deleted: each kept column's name where the207+// column went, and "" for columns inserted.208+func shiftColumns(t Table, r Rect, sp formula.Span) []string {209+ out := make([]string, r.To.Col-r.From.Col+1)210+ for i, name := range t.Cols {211+ if c, ok := sp.Point(t.Range.From.Col + i); ok && c >= r.From.Col && c <= r.To.Col {212+ out[c-r.From.Col] = name213+ }214+ }215+ return out216+}217+218+// remapTables moves the tables wholly inside the cells moved with them,219+// as cut and paste moves them in Sheets; rng maps a range that moved.220+func (s *Sheet) remapTables(src Rect, rng func(Rect) (Rect, bool)) {221+ if len(s.view.tables) == 0 {222+ return223+ }224+ st := s.Tables()225+ for i, t := range st {226+ if src.Contains(t.Range.From) && src.Contains(t.Range.To) {227+ if r, ok := rng(t.Range); ok {228+ st[i].Range = r229+ }230+ }231+ }232+ s.putTables(st)233+}234+235+// moveTablesTo moves the tables wholly inside src on s to dst, shifted236+// by shift, as cutting them from one sheet and pasting on another does.237+func (s *Sheet) moveTablesTo(dst *Sheet, src Rect, shift func(Addr) Addr) {238+ var moved, kept []Table239+ for _, t := range s.Tables() {240+ if src.Contains(t.Range.From) && src.Contains(t.Range.To) {241+ t.Range = Rect{From: shift(t.Range.From), To: shift(t.Range.To)}242+ moved = append(moved, t)243+ } else {244+ kept = append(kept, t)245+ }246+ }247+ if len(moved) == 0 {248+ return249+ }250+ s.putTables(kept)251+ dst.putTables(append(dst.Tables(), moved...))252+}@@ -1,5 +1,5 @@
11 {2- "version": 5,2+ "version": 6,33 "names": {"Units": "Sales!C2:C9", "Lost": "#REF!", "Rate": "Lists!B1"},44 "macroOrigin": "fixture-machine",55 "macros": [@@ -117,12 +117,22 @@
117117 "name": "Lists",118118 "hidden": true,119119 "protected": [{"sheet": true}],120+ "tables": [{"name":"Targets","range":"D1:E3","columns":["Region","Target"],"banded":true,"header":true}],120121 "cells": {121122 "A1": "North",122123 "A2": "South",123124 "A3": "East",124125 "A4": "West",125- "B1": {"input": "1.5", "note": "Rate used by Sales!I2"}126+ "B1": {"input": "1.5", "note": "Rate used by Sales!I2"},127+ "D1": "Region",128+ "E1": "Target",129+ "D2": "North",130+ "E2": "20",131+ "D3": "South",132+ "E3": "=E2/2",133+ "F1": "=SUM(Targets[Target])+ROWS(Targets[#All])",134+ "F2": "=Targets[@Target]*2",135+ "F3": "=COUNTA(Targets[[#Headers],[Region]:[Target]])"126136 }127137 },128138 {@@ -1,5 +1,5 @@
11 {2- "version": 5,2+ "version": 6,33 "names": {"Lost": "#REF!", "Rate": "Lists!B1", "Units": "Sales!C2:C9"},44 "macroOrigin": "fixture-machine",55 "macros": [@@ -115,11 +115,21 @@
115115 "name": "Lists",116116 "hidden": true,117117 "protected": [{"sheet":true}],118+ "tables": [{"name":"Targets","range":"D1:E3","columns":["Region","Target"],"banded":true,"header":true}],118119 "cells": {119120 "A1": "North",120121 "B1": {"input":"1.5","note":"Rate used by Sales!I2"},122+ "D1": "Region",123+ "E1": "Target",124+ "F1": "=SUM(Targets[Target])+ROWS(Targets[#All])",121125 "A2": "South",126+ "D2": "North",127+ "E2": "20",128+ "F2": "=Targets[@Target]*2",122129 "A3": "East",130+ "D3": "South",131+ "E3": "=E2/2",132+ "F3": "=COUNTA(Targets[[#Headers],[Region]:[Target]])",123133 "A4": "West"124134 }125135 },@@ -39,6 +39,10 @@ const (
3939 // LinkFile is the linked file a region's cells come from: Link.Name4040 // is its path, and Target the region's cells.4141 LinkFile42+ // LinkTable is a table's cells a formula reads by a structured43+ // reference or the table's name alone: Link.Name is the reference as44+ // written, Sales[Amount].45+ LinkTable4246 // LinkOutput is the notebook cell whose output a region shows:4347 // Link.Name is the cell's name, Target.Sheet the notebook tab and4448 // Target.Range.From.Row the cell's index there.@@ -91,7 +95,7 @@ func (s *Sheet) PrecedentLinks(a Addr) []Link {
9195 }9296 }9397 if c, _, _ := s.cells.peek(a); c != nil && c.IsFormula() {94- s.walkPrecedents(c.expr, add)98+ s.walkPrecedents(c.expr, a, add)9599 }96100 if anchor, ok := s.SpillAnchor(a); ok {97101 add(Link{Target: Target{s, Rect{From: anchor, To: anchor}}, Kind: LinkSpill})@@ -102,9 +106,9 @@ func (s *Sheet) PrecedentLinks(a Addr) []Link {
102106 return out103107 }104108 105-// walkPrecedents calls add with each reference, range and name in n, in106-// the order they're written.107-func (s *Sheet) walkPrecedents(n Node, add func(Link)) {109+// walkPrecedents calls add with each reference, range, name and table110+// in n, the formula at a, in the order they're written.111+func (s *Sheet) walkPrecedents(n Node, a Addr, add func(Link)) {108112 w := s.wb109113 switch n := n.(type) {110114 case formula.Ref:@@ -118,12 +122,33 @@ func (s *Sheet) walkPrecedents(n Node, add func(Link)) {
118122 }119123 return120124 }125+ if _, found := w.findTable(nameKey(n.Name)); found {126+ s.tableLink(a, formula.TableRef{Table: n.Name}, n.Name, add)127+ return128+ }121129 if t, r, ok := w.regionName(nameKey(n.Name)); ok && r != (Rect{}) {122130 add(Link{Target: Target{t, r}, Kind: LinkRegion, Name: s.regionSpelling(n.Name)})123131 }132+ case formula.TableRef:133+ s.tableLink(a, n, n.String(), add)134+ default:135+ formula.EachChild(n, func(k Node) { s.walkPrecedents(k, a, add) })136+ }137+}138+139+// tableLink adds the cells the structured reference t, written as name140+// in the formula at a, reads, when it reads any.141+func (s *Sheet) tableLink(a Addr, t formula.TableRef, name string, add func(Link)) {142+ var r Target143+ switch n := s.bindTable(a, t, 0).(type) {144+ case formula.Ref:145+ r = Target{s.wb.resolve(s, n.Sheet), Rect{From: n.Addr, To: n.Addr}}146+ case formula.Range:147+ r = Target{s.wb.resolve(s, n.Sheet), n.Rect}124148 default:125- formula.EachChild(n, func(k Node) { s.walkPrecedents(k, add) })149+ return126150 }151+ add(Link{Target: r, Kind: LinkTable, Name: name})127152 }128153 129154 // regionSpelling is how formulas name the region a name like NU.SALES@@ -114,13 +114,18 @@ func (s *Sheet) refReaders(r Rect, f *depFinder) {
114114 }115115 }116116 117-// namesOverlap reports whether the name with key k, a named range or a118-// region's (nu.sales), stands for cells of r on s.117+// namesOverlap reports whether the name with key k, a named range, a118+// table (a region's too) or a region's (nu.sales), stands for cells of119+// r on s.119120 func (w *Workbook) namesOverlap(k string, s *Sheet, r Rect) bool {120121 if nm, ok := w.names[k]; ok {121122 _, over := intersectRect(nm.Range, r)122123 return !nm.Gone() && nm.Sheet == s && over123124 }125+ if v, ok := w.findTable(k); ok {126+ _, over := intersectRect(v.r, r)127+ return v.ok && v.s == s && over128+ }124129 t, table, ok := w.regionName(k)125130 if !ok || t != s || table == (Rect{}) {126131 return false@@ -11,14 +11,15 @@ import (
1111 )1212 1313 // View state that belongs to the worksheet rather than to a cell: frozen14-// rows and columns, the filter, the protected ranges and the merged15-// cells. It is saved in the file and every change to it is an undo step,14+// rows and columns, the filter, the protected ranges, the merged cells15+// and the tables. It is saved in the file and every change to it is an undo step,1616 // as in Sheets.1717 type viewState struct {1818 frozenRows, frozenCols int1919 filter *Filter // never modified in place; replaced whole2020 protected []Protection // see protect.go; replaced whole too2121 merges []Rect // see merge.go; replaced whole too22+ tables []Table // see table.go; replaced whole too2223 }2324 2425 // Frozen returns how many rows and columns are frozen at the top and left.@@ -80,7 +81,8 @@ func (s *Sheet) recordView() {
8081 func (v viewState) equal(w viewState) bool {8182 return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&8283 (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter)) &&83- slices.Equal(v.protected, w.protected) && slices.Equal(v.merges, w.merges)84+ slices.Equal(v.protected, w.protected) && slices.Equal(v.merges, w.merges) &&85+ slices.EqualFunc(v.tables, w.tables, Table.equal)8486 }8587 8688 // shiftView keeps frozen lines, the filter and protected ranges in step@@ -92,6 +94,7 @@ func (s *Sheet) shiftView(rows bool, sp formula.Span) {
9294 v := s.view9395 v.protected = shiftProtected(v.protected, rows, sp)9496 v.merges = shiftMerges(v.merges, rows, sp)97+ v.tables = shiftTables(v.tables, rows, sp)9598 frozen := &v.frozenCols9699 if rows {97100 frozen = &v.frozenRows@@ -110,6 +113,9 @@ func (s *Sheet) shiftView(rows bool, sp formula.Span) {
110113 if !v.equal(s.view) {111114 s.recordView()112115 merged := !slices.Equal(v.merges, s.view.merges)116+ if w := s.wb; w.hist.open != nil {117+ w.hist.dirty = append(w.hist.dirty, w.tableUsers(s.view.tables, v.tables)...)118+ }113119 s.view = v114120 if merged { // a merge gone may free an array to spill115121 s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})@@ -148,7 +154,8 @@ type fileView struct {
148154 Protected []fileProtection `json:"protected,omitempty"`149155 // Merges need no version: earlier builds ignore them and show the150156 // cells unmerged.151- Merges []string `json:"merges,omitempty"`157+ Merges []string `json:"merges,omitempty"`158+ Tables []fileTable `json:"tables,omitempty"` // version 6, see tablefile.go152159 }153160 154161 type fileFreeze struct {@@ -192,6 +199,9 @@ func (s *Sheet) writeView(b *bufio.Writer, indent string) error {
192199 }193200 keys, parts = append(keys, "merges"), append(parts, names)194201 }202+ if ts := s.view.tables; len(ts) > 0 {203+ keys, parts = append(keys, "tables"), append(parts, encodeTables(ts))204+ }195205 for i, p := range parts {196206 raw, err := json.Marshal(p)197207 if err != nil {@@ -217,6 +227,9 @@ func (s *Sheet) readView(fv fileView) error {
217227 }218228 s.LoadMerge(r)219229 }230+ if err := s.readTables(fv.Tables); err != nil {231+ return err232+ }220233 if fz := fv.Freeze; fz != nil {221234 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)222235 }@@ -215,8 +215,8 @@ func (w *Workbook) AddSheet(name string, at int) (*Sheet, error) {
215215 }216216 217217 // DuplicateSheet copies s, cells, widths, heights, frozen panes, filter,218-// merges and charts, to a new sheet right after it named "Copy of ...", as Sheets'219-// Duplicate. Formulas are copied as written, so references without a218+// merges, tables and charts, to a new sheet right after it named "Copy of ...", as Sheets'219+// Duplicate; each table copied takes a name of its own (Sales_2). Formulas are copied as written, so references without a220220 // sheet name read the copy's own cells.221221 func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {222222 if !s.live {@@ -238,6 +238,7 @@ func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {
238238 cp.lines = lineFormats{cols: maps.Clone(s.lines.cols), rows: maps.Clone(s.lines.rows), sheet: s.lines.sheet}239239 cp.view = s.view240240 cp.view.filter = s.view.filter.clone()241+ cp.view.tables = w.copiedTables(s.view.tables)241242 cp.charts = slices.Clone(s.charts)242243 cp.rules = s.rules // never changed in place243244 cp.pivot = pivotState{def: s.pivot.def.clone(), stale: s.pivot.def != nil, out: s.pivot.out, fit: s.pivot.fit}@@ -26,6 +26,13 @@ func (m *Model) inFunction() bool {
2626 return ok2727 }2828 29+// inTable reports whether the caret is inside a structured reference's30+// brackets, Sales[Am.31+func (m *Model) inTable() bool {32+ _, ok := suggest.InTable(m.storedFormula(m.line.Buf), m.line.Pos)33+ return ok34+}35+2936 // The suggestions' host.3037 3138 // Typing reports whether an entry is being typed in the cell (ENTER or@@ -70,10 +70,13 @@ func (g *grid) dataRange() sheet.Rect {
7070 return r7171 }7272 73-// tableAround is the table around a: in a region, the region's table74-// from its header row down, whatever is next to it; elsewhere the block75-// of data around a (Sheet.Region).73+// tableAround is the table around a: a table's range, or in a region,74+// the region's table from its header row down, whatever is next to75+// them; elsewhere the block of data around a (Sheet.Region).7676 func (g *grid) tableAround(a sheet.Addr) sheet.Rect {77+ if t, ok := g.sheet.TableAt(a); ok {78+ return t.Range79+ }7780 if reg, ok := g.sheet.RegionAt(a); ok {7881 if t, ok := g.sheet.RegionTable(reg.Name); ok {7982 return t@@ -117,12 +117,15 @@ func (s refScan) corners(pos int) ([][2]int, bool) {
117117 // Caret describes the formula text before the caret: the word being118118 // typed, if it may be a function, name or sheet, and the innermost119119 // function call the caret is in. A sheet name being typed in quotes is a120-// word starting with the quote, e.g. "'Q3 p".120+// word starting with the quote, e.g. "'Q3 p". In a structured121+// reference's brackets, Sales[Am, the word is the column or item being122+// typed and Table the table's name.121123 type Caret struct {122124 Word string123125 WordStart int124126 Fn string // e.g. "SUM", or "" outside any function's parentheses125127 Arg int // index of the argument the caret is in128+ Table string // the table whose brackets the caret is in, as written, or ""126129 }127130 128131 func isWordRune(r rune) bool {@@ -141,6 +144,11 @@ func ScanCaret(buf []rune, pos int) Caret {
141144 return Caret{}142145 }143146 var c Caret147+ if s.depth > 0 {148+ c.Table, c.WordStart = s.table, s.item149+ c.Word = string(buf[s.item:pos])150+ s.quote = -1151+ }144152 if s.quote >= 0 {145153 c.Word, c.WordStart = string(buf[s.quote:pos]), s.quote146154 }@@ -150,7 +158,7 @@ func ScanCaret(buf []rune, pos int) Caret {
150158 break151159 }152160 }153- if s.quote < 0 && s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {161+ if s.depth == 0 && s.quote < 0 && s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {154162 w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))155163 if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {156164 c.Word, c.WordStart = string(w), pos-len(w)@@ -166,6 +174,13 @@ type caretScan struct {
166174 quote int // start of a quoted sheet name being read, or -1167175 start int // start of the word being read, or -1168176 prev string // the word just before a space or "(" (SUM ( is allowed)177+ // In a structured reference's brackets: how deep, the table's name,178+ // where the item being typed starts, and whether the next character179+ // is escaped by '.180+ depth int181+ table string182+ item int183+ esc bool169184 }170185 171186 // callFrame is a function call the caret may be in.@@ -177,6 +192,10 @@ type callFrame struct {
177192 // step reads buf[i].178193 func (s *caretScan) step(buf []rune, i int) {179194 r := buf[i]195+ if s.depth > 0 {196+ s.bracket(r, i)197+ return198+ }180199 if s.inStr {181200 s.inStr = r != '"'182201 return@@ -203,6 +222,8 @@ func (s *caretScan) step(buf []rune, i int) {
203222 s.inStr = true204223 case '\'':205224 s.quote = i225+ case '[':226+ s.table, s.depth, s.item = s.prev, 1, i+1206227 case '(':207228 s.stack = append(s.stack, callFrame{fn: strings.ToUpper(strings.TrimPrefix(s.prev, "@"))})208229 case ')':@@ -217,6 +238,28 @@ func (s *caretScan) step(buf []rune, i int) {
217238 s.prev = ""218239 }219240 241+// bracket reads r, at i, inside a structured reference's brackets:242+// Sales[[#Headers],[Amount]]. Each [ or item separator starts an item;243+// ' escapes the character after it.244+func (s *caretScan) bracket(r rune, i int) {245+ switch {246+ case s.esc:247+ s.esc = false248+ case r == '\'':249+ s.esc = true250+ case r == '[':251+ s.depth++252+ s.item = i + 1253+ case r == ']':254+ if s.depth--; s.depth == 0 {255+ s.table = ""256+ }257+ s.item = i + 1258+ case (r == ',' || r == ';') && s.depth == 1:259+ s.item = i + 1260+ }261+}262+220263 // SplitArgs splits a signature's arguments at the top-level commas, so221264 // "[value2, ...]" stays one part.222265 func SplitArgs(args string) []string {@@ -42,6 +42,31 @@ func TestScanCaret(t *testing.T) {
4242 }4343 }4444 45+// In a structured reference's brackets the word is the item being46+// typed, spaces, commas and escapes included, and calls outside it still47+// count.48+func TestScanCaretInTable(t *testing.T) {49+ for _, tc := range []struct {50+ text, table, word, fn string51+ }{52+ {"=SUM(Sales[|", "Sales", "", "SUM"},53+ {"=SUM(Sales[Unit P|", "Sales", "Unit P", "SUM"},54+ {"=Sales[@Am|", "Sales", "@Am", ""},55+ {"=Sales[[#Headers],[To|", "Sales", "To", ""},56+ {"=Sales[[#Headers], |", "Sales", " ", ""},57+ {"=Sales['#it|", "Sales", "'#it", ""},58+ {"=Sales[x]+Co|", "", "Co", ""},59+ {"=SUM(Sales[[a]:[b]], |", "", "", "SUM"},60+ } {61+ i := strings.Index(tc.text, "|")62+ buf := []rune(strings.Replace(tc.text, "|", "", 1))63+ c := ScanCaret(buf, len([]rune(tc.text[:i])))64+ if c.Table != tc.table || c.Word != tc.word || c.Fn != tc.fn {65+ t.Errorf("%s: table %q word %q fn %q", tc.text, c.Table, c.Word, c.Fn)66+ }67+ }68+}69+4570 func TestArgPart(t *testing.T) {4671 for _, tc := range []struct {4772 args string@@ -102,6 +102,7 @@ var menuBar = []menuDef{
102102 {cmd: "format.unmerge"},103103 }}, sep,104104 {cmd: "column.width"}, {cmd: "column.reset"}, {cmd: "row.height"}, {cmd: "row.fit"}, sep,105+ {cmd: "table.create"}, sep,105106 {cmd: "format.conditional"}, {cmd: "format.conditional_clear"}, sep,106107 {cmd: "format.clear", title: "Clear formatting"},107108 }},@@ -115,7 +116,7 @@ var menuBar = []menuDef{
115116 }}, sep,116117 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,117118 {cmd: "data.pivot"}, {cmd: "data.pivot_edit"}, {cmd: "data.frequency"}, sep,118- {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, {cmd: "data.protect"},119+ {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, {title: "Table", items: tableItems}, {cmd: "data.protect"},119120 {cmd: "data.validation"}, {cmd: "data.validation_clear"}, sep,120121 {title: "Formula tracing", items: []menuItem{121122 {cmd: "data.precedents"}, {cmd: "data.dependents"}, {cmd: "data.trace_list"}, sep,@@ -208,7 +208,7 @@ func (m *Model) editName(n sheet.Name, sel sheet.Rect) {
208208 }209209 210210 // namedSelection is the name of the selected range, if it has one, for211-// the name box, as in Sheets.211+// the name box, as in Sheets: a named range's, or a table's.212212 func (g *grid) namedSelection() (string, bool) {213213 r := g.selection()214214 for _, n := range g.sheet.Names() {@@ -216,5 +216,13 @@ func (g *grid) namedSelection() (string, bool) {
216216 return n.Name, true217217 }218218 }219+ if !g.sheet.HasTables() {220+ return "", false221+ }222+ for _, t := range g.sheet.Tables() {223+ if t.Range == r {224+ return t.Name, true225+ }226+ }219227 return "", false220228 }@@ -126,7 +126,7 @@ func TestGotoAcceptsNamesAndRanges(t *testing.T) {
126126 t.Errorf("range: %v", m.selection())127127 }128128 press(t, m, "<f5>", "nope", "<enter>")129- if m.mode != modeError || m.errMsg != "Not a cell, range, named range or region: nope" {129+ if m.mode != modeError || m.errMsg != "Not a cell, range, named range, table or region: nope" {130130 t.Errorf("%v %q", m.mode, m.errMsg)131131 }132132 }@@ -197,5 +197,5 @@ func (m *Model) regionLine() string {
197197 from = s.Name()198198 }199199 }200- return m.th.Muted.Render("Output of " + r.Name + " from " + from + ", nu." + r.Name + " in formulas")200+ return m.th.Muted.Render("Output of " + r.Name + " from " + from + ", " + r.Name + "[column] in formulas")201201 }@@ -257,6 +257,8 @@ func (m *Model) contextLineText() string {
257257 if left, right, ok = suggest.SignatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {258258 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")259259 }260+ case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inTable():261+ left, right, _ = suggest.TableLine(&m.th, m.width, m.entrySheet(), m.storedFormula(m.line.Buf), m.line.Pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))260262 case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inFunction():261263 left, right, _ = suggest.SignatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))262264 case m.mode == modeEnter && m.line.IsFormula():@@ -285,7 +287,7 @@ func (m *Model) readyContext() (left, right string) {
285287 return m.traceLine()286288 }287289 for _, f := range []func() string{func() string { return m.jev.line(&m.th, m.sheet.RemoteCalls(m.cur)) },288- m.errorLine, m.validationLine, m.noteLine, m.spillLine, m.regionLine, m.recordingLine} {290+ m.errorLine, m.validationLine, m.noteLine, m.spillLine, m.regionLine, m.tableLine, m.recordingLine} {289291 if left = f(); left != "" {290292 return left, ""291293 }@@ -258,15 +258,15 @@ func isMoveKey(key string) bool {
258258 func (m *Model) openGoto() {259259 m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {260260 if !m.gotoText(strings.TrimSpace(text)) {261- m.fail("Not a cell, range, named range or region: " + text)261+ m.fail("Not a cell, range, named range, table or region: " + text)262262 }263263 return nil264264 })265265 m.prompt.indicator = "POINT"266266 }267267 268-// gotoText goes to a cell, range, named range or region's table (nu.r1,269-// as formulas name it), on any sheet, and reports whether text named268+// gotoText goes to a cell, range, named range, table (a region's too,269+// by its name or as formulas name it, nu.r1), on any sheet, and reports whether text named270270 // one. A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.271271 func (m *Model) gotoText(text string) bool {272272 target := m.sheet@@ -286,6 +286,9 @@ func (m *Model) gotoText(text string) bool {
286286 if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {287287 r, ok, target = n.Range, true, n.Sheet288288 }289+ if t, found := m.book().LookupTable(text); !ok && found && t.Shown() && target == m.sheet {290+ r, ok, target = t.Range, true, t.Sheet291+ }289292 if !ok && target == m.sheet {290293 r, target, ok = m.regionTable(text)291294 }@@ -93,7 +93,7 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
9393 }9494 9595 // headerRows is how many rows at the top of r are headers that sorting96-// leaves in place: frozen rows, the filter's header row, and, for a range96+// leaves in place: frozen rows, the filter's or a table's header row, and, for a range9797 // found around the active cell, a first row of text over numbers.9898 func (g *grid) headerRows(r sheet.Rect, selected bool) int {9999 n := 0@@ -103,6 +103,9 @@ func (g *grid) headerRows(r sheet.Rect, selected bool) int {
103103 if f, ok := g.sheet.FilterRange(); ok && f.From.Row == r.From.Row {104104 n = max(n, 1)105105 }106+ if t, ok := g.sheet.TableAt(r.From); ok && t.Range.From.Row == r.From.Row {107+ n = max(n, 1)108+ }106109 if n == 0 && !selected && g.looksLikeHeader(r) {107110 n = 1108111 }@@ -66,11 +66,12 @@ type Suggestion struct {
6666 Detail string // the signature, the range a name stands for, or a sheet's cells6767 Desc string6868 Fn bool // a function: accepting it adds "("69+ Column bool // a table's column or item: accepting it adds "]"6970 }7071 71-// For lists the functions, named ranges and sheets for word, typed on72-// sh: those starting with it first (names, then sheets, then73-// functions), then, from two letters on, those containing it, such as72+// For lists the functions, named ranges, tables and sheets for word,73+// typed on sh: those starting with it first (names, then tables, then74+// sheets, then functions), then, from two letters on, those containing it, such as7475 // COUNTIFS for "ifs". A word that is already a cell reference only gets7576 // those that start with it, and a word in quotes ('Q3) only sheets.7677 func For(sh *sheet.Sheet, word string) []Suggestion {@@ -90,6 +91,9 @@ func For(sh *sheet.Sheet, word string) []Suggestion {
9091 for _, n := range sh.Names() {9192 add(Suggestion{Name: n.Name, Detail: n.Ref(), Desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))9293 }94+ for _, t := range sh.Book().TableInfos() {95+ add(tableSuggestion(sh, t), strings.ToUpper(t.Name))96+ }9397 for _, s := range otherSheets(sh) {9498 add(sheetSuggestion(s), strings.ToUpper(s.Name()))9599 }@@ -112,10 +116,18 @@ func (l *List) Shown(h Host) ([]Suggestion, int) {
112116 return nil, 0113117 }114118 c := formula.ScanCaret(h.Stored(line.Buf), line.Pos)115- if c.Word == "" {119+ var list []Suggestion120+ switch {121+ case c.Table != "":122+ if word, at := strings.CutPrefix(c.Word, "@"); at {123+ c.Word, c.WordStart = word, c.WordStart+1124+ }125+ list = columnSuggestions(h.EntrySheet(), c.Table, c.Word)126+ case c.Word == "":116127 return nil, 0128+ default:129+ list = For(h.EntrySheet(), c.Word)117130 }118- list := For(h.EntrySheet(), c.Word)119131 if len(list) == 0 || len(list) == 1 && !list[0].Fn && strings.EqualFold(list[0].Name, c.Word) {120132 return nil, 0121133 }@@ -154,6 +166,9 @@ func (l *List) accept(h Host, s Suggestion, start int) {
154166 if s.Fn && (len(rest) == 0 || rest[0] != '(') {155167 text = append(text, '(')156168 }169+ if s.Column && (len(rest) == 0 || rest[0] != ']') {170+ text = append(text, ']')171+ }157172 if strings.HasSuffix(s.Name, "!") {158173 rest = trimSheetEnd(rest) // the rest of a sheet name typed before159174 }@@ -64,6 +64,48 @@ func TestOffersNamesSheetsThenFunctions(t *testing.T) {
6464 }6565 }6666 67+// Tables are offered with named ranges; in a table's brackets its68+// columns and items are, and the context line lists its columns.69+func TestOffersTablesAndColumns(t *testing.T) {70+ h := newHost(t)71+ s := h.s72+ for a, v := range map[string]string{"C1": "Region", "D1": "Unit Price", "E1": "Q[1]"} {73+ s.Set(sheet.Addr{Col: int(a[0] - 'A')}, v)74+ }75+ if err := s.CreateTable("Orders", sheet.Rect{From: sheet.Addr{Col: 2}, To: sheet.Addr{Col: 4, Row: 3}}); err != nil {76+ t.Fatal(err)77+ }78+ if got := names(For(s, "ord")); len(got) == 0 || got[0] != "Orders" {79+ t.Fatalf("for ord: %v", got)80+ }81+ var l List82+ h.line.Set("=Orders[u")83+ l.Active = true84+ list, start := l.Shown(h)85+ if strings.Join(names(list), " ") != "Unit Price" || start != len("=Orders[") {86+ t.Fatalf("columns %v from %d", names(list), start)87+ }88+ l.Key(h, "tab")89+ if h.line.Text() != "=Orders[Unit Price]" {90+ t.Fatalf("accepted %q", h.line.Text())91+ }92+ h.line.Set("=Orders[@q")93+ l.Active = true94+ l.Key(h, "tab")95+ if h.line.Text() != "=Orders[@Q'[1']]" {96+ t.Fatalf("an escaped column after @: %q", h.line.Text())97+ }98+ h.line.Set("=Orders[#")99+ l.Active = true100+ if list, _ := l.Shown(h); strings.Join(names(list), " ") != "#All #Data #Headers #This Row" {101+ t.Fatalf("items %v", names(list))102+ }103+ left, _, ok := TableLine(&h.th, 80, s, []rune("=Orders[Uni"), len("=Orders[Uni"), "")104+ if !ok || !strings.Contains(ansi.Strip(left), "Orders[Region, Unit Price, Q[1]]") || !strings.Contains(left, h.th.Argument.Render("Unit Price")) {105+ t.Errorf("context %q", left)106+ }107+}108+67109 func TestKeysMoveAndAccept(t *testing.T) {68110 h := newHost(t)69111 var l List@@ -0,0 +1,113 @@
1+package suggest2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+ "github.com/FelineStateMachine/012/internal/ui/formula"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// Tables in formula suggestions: typing =Sa offers the table Sales with14+// the named ranges, and once its "[" is typed, Sales[A offers its15+// columns (Amount) and, after a #, the items (#All, #Headers ...). The16+// context line shows the table's columns with the one being typed17+// marked, as it shows a function's arguments.18+19+// items are the items a structured reference names rows by.20+var items = []struct{ name, desc string }{21+ {"#All", "The whole table, header row included"},22+ {"#Data", "The data rows, as the table's name alone"},23+ {"#Headers", "The header row"},24+ {"#This Row", "The formula's own row, as @ before a column"},25+}26+27+// tableSuggestion is the suggestion for a table formulas can read.28+func tableSuggestion(sh *sheet.Sheet, t sheet.TableInfo) Suggestion {29+ where := "table, no rows yet"30+ if t.Shown() {31+ where = "table, " + t.Range.String()32+ if t.Sheet != sh {33+ where = "table, " + sheet.Qualified(t.Sheet.Name(), t.Range)34+ }35+ }36+ return Suggestion{Name: t.Name, Detail: where, Desc: "Table " + t.Name + ": " + strings.Join(t.Cols, ", ") + "; type [ for its columns"}37+}38+39+// columnSuggestions lists what may go in the brackets of the table40+// named table, for word typed there: its columns starting with it,41+// then from two letters on those containing it, and the items after a42+// #. A column is offered as a structured reference writes it, its [ ] #43+// and ' escaped.44+func columnSuggestions(sh *sheet.Sheet, table, word string) []Suggestion {45+ t, ok := sh.Book().LookupTable(table)46+ if !ok {47+ return nil48+ }49+ w := strings.ToUpper(word)50+ var prefix, inner []Suggestion51+ if strings.HasPrefix(w, "#") {52+ for _, it := range items {53+ if strings.HasPrefix(strings.ToUpper(it.name), w) {54+ prefix = append(prefix, Suggestion{Name: it.name, Detail: "item", Desc: it.desc, Column: true})55+ }56+ }57+ return prefix58+ }59+ for i, col := range t.Cols {60+ s := Suggestion{Name: sheet.EscapeColumn(col), Detail: "column " + sheet.ColName(t.Range.From.Col+i),61+ Desc: "The " + col + " column of " + t.Name, Column: true}62+ switch name := strings.ToUpper(col); {63+ case strings.HasPrefix(name, w):64+ prefix = append(prefix, s)65+ case len(w) >= 2 && strings.Contains(name, w):66+ inner = append(inner, s)67+ }68+ }69+ return append(prefix, inner...)70+}71+72+// InTable reports the table whose brackets the caret is in, if any: buf73+// is the formula as it's parsed (see Host.Stored).74+func InTable(buf []rune, pos int) (string, bool) {75+ c := formula.ScanCaret(buf, pos)76+ return c.Table, c.Table != ""77+}78+79+// TableLine puts the columns of the table whose brackets the caret is80+// in on the context line, Sales[Region, Units, Amount], with the column81+// being typed marked and the items after, and the keys that apply82+// (hints) as room allows. It reports false outside a table's brackets83+// or for a table formulas can't find.84+func TableLine(th *theme.Theme, width int, sh *sheet.Sheet, buf []rune, pos int, hints string) (left, right string, ok bool) {85+ c := formula.ScanCaret(buf, pos)86+ t, found := sh.Book().LookupTable(c.Table)87+ if c.Table == "" || !found {88+ return "", "", false89+ }90+ typed := strings.TrimPrefix(c.Word, "@")91+ var b strings.Builder92+ b.WriteString(th.Key.Render(t.Name) + "[")93+ marked := false94+ for i, col := range t.Cols {95+ if i > 0 {96+ b.WriteString(", ")97+ }98+ if !marked && typed != "" && strings.HasPrefix(strings.ToUpper(col), strings.ToUpper(typed)) {99+ b.WriteString(th.Argument.Render(col))100+ marked = true101+ continue102+ }103+ b.WriteString(col)104+ }105+ b.WriteString("]")106+ full := b.String() + " " + th.Muted.Render("#All #Data #Headers, @ for this row")107+ for _, try := range [][2]string{{full, hints}, {b.String(), hints}, {full, ""}} {108+ if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {109+ return try[0], try[1], true110+ }111+ }112+ return ansi.Truncate(b.String(), width, "…"), "", true113+}@@ -0,0 +1,283 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// Tables, as Sheets' Format > Convert to table: a range whose first row15+// names its columns, read in formulas by those names (Sales[Amount]).16+// Converting asks for the table's name on the context line; Data > Table17+// renames, resizes (pointed at like any range), styles and removes the18+// table the active cell is in, and lists every table, regions' too, to19+// go to one. Filters and sorts started in a table take its range.20+21+// tableItems is Data > Table.22+var tableItems = []menuItem{23+ {cmd: "data.tables"}, sep,24+ {cmd: "table.rename"}, {cmd: "table.resize"}, sep,25+ {cmd: "table.banded"}, {cmd: "table.header"}, sep,26+ {cmd: "table.remove"},27+}28+29+func init() {30+ inTable := func(m *Model) bool { _, ok := m.sheet.TableAt(m.cur); return ok }31+ register(32+ &command{id: "table.create", title: "Convert to table", desc: "Make the selection, or the data around the active cell, a table whose first row names its columns",33+ run: (*Model).createTable, edits: func(m *Model) sheet.Rect { return m.dataRange() },34+ enabled: func(m *Model) bool { return !inTable(m) }},35+ &command{id: "data.tables", macro: macroView, title: "Tables", desc: "List the tables and notebook outputs formulas read by column name: go to, rename or remove them",36+ run: func(m *Model) tea.Cmd {37+ m.openTables()38+ return nil39+ }},40+ &command{id: "table.rename", title: "Rename table", desc: "Rename the active cell's table, and every formula that reads it",41+ run: func(m *Model) tea.Cmd {42+ t, _ := m.sheet.TableAt(m.cur)43+ m.renameTable(t, false)44+ return nil45+ }, enabled: inTable},46+ &command{id: "table.resize", title: "Resize table", desc: "Point at the range the active cell's table covers, header row first",47+ run: func(m *Model) tea.Cmd {48+ t, _ := m.sheet.TableAt(m.cur)49+ m.resizeTable(t)50+ return nil51+ }, enabled: inTable},52+ &command{id: "table.banded", title: "Banded rows", desc: "Shade every other row of the active cell's table",53+ run: func(m *Model) tea.Cmd { return m.styleTable(func(t *sheet.Table) { t.Banded = !t.Banded }) },54+ enabled: inTable, checked: func(m *Model) bool { t, ok := m.sheet.TableAt(m.cur); return ok && t.Banded }},55+ &command{id: "table.header", title: "Header style", desc: "Draw the header row of the active cell's table bold, underlined and in color",56+ run: func(m *Model) tea.Cmd { return m.styleTable(func(t *sheet.Table) { t.Header = !t.Header }) },57+ enabled: inTable, checked: func(m *Model) bool { t, ok := m.sheet.TableAt(m.cur); return ok && t.Header }},58+ &command{id: "table.remove", title: "Remove table", desc: "Make the active cell's table plain cells again; formulas that read it read the cells by address",59+ run: func(m *Model) tea.Cmd {60+ t, _ := m.sheet.TableAt(m.cur)61+ m.removeTable(t)62+ return nil63+ }, enabled: inTable},64+ )65+ keymap["ctrl+alt+t"] = "table.create"66+}67+68+// createTable asks for a name for a table of the selection, or of the69+// data around the active cell.70+func (m *Model) createTable() tea.Cmd {71+ r := m.dataRange()72+ m.openText("Name for the table of "+r.String()+":", m.book().NextTableName(), func(m *Model, name string) tea.Cmd {73+ if err := m.sheet.CreateTable(name, r); err != nil {74+ m.fail(err.Error())75+ return nil76+ }77+ m.changed = true78+ t, _ := m.sheet.TableAt(r.From)79+ m.selectRect(t.Range)80+ m.note = name + " is a table: read it by column in formulas, e.g. =SUM(" + name + "[" + t.Cols[len(t.Cols)-1] + "])"81+ return nil82+ })83+ m.prompt.indicator = "NAME"84+ return nil85+}86+87+// renameTable asks for a new name for t. From the picker (back), the88+// picker opens again afterwards.89+func (m *Model) renameTable(t sheet.Table, back bool) {90+ m.openText("Rename table "+t.Name+":", t.Name, func(m *Model, name string) tea.Cmd {91+ if err := m.book().RenameTable(t.Name, name); err != nil {92+ m.fail(err.Error())93+ return nil94+ }95+ m.changed = true96+ if back {97+ m.noteUnrecorded()98+ m.openTables()99+ return nil100+ }101+ m.note = "Renamed " + t.Name + " to " + name + " in every formula that reads it"102+ return nil103+ })104+ m.prompt.indicator = "NAME"105+}106+107+// resizeTable asks for t's new range, pointed at from its own.108+func (m *Model) resizeTable(t sheet.Table) {109+ m.openRange("Range for "+t.Name+", header row first:", func(m *Model, r sheet.Rect) tea.Cmd {110+ if err := m.book().ResizeTable(t.Name, r); err != nil {111+ m.fail(err.Error())112+ return nil113+ }114+ m.changed = true115+ nt, _ := m.sheet.TableAt(r.From)116+ m.selectRect(nt.Range)117+ m.note = t.Name + " covers " + nt.Range.String() + ": " + strings.Join(nt.Cols, ", ")118+ return nil119+ })120+ m.point = pointer{anchor: t.Range.From, at: t.Range.To, anchored: true}121+}122+123+// styleTable changes the style of the active cell's table with fn.124+func (m *Model) styleTable(fn func(*sheet.Table)) tea.Cmd {125+ t, ok := m.sheet.TableAt(m.cur)126+ if !ok {127+ return nil128+ }129+ fn(&t)130+ if err := m.book().SetTableStyle(t.Name, t.Banded, t.Header); err != nil {131+ m.fail(err.Error())132+ return nil133+ }134+ m.changed = true135+ return nil136+}137+138+// removeTable makes t plain cells again.139+func (m *Model) removeTable(t sheet.Table) {140+ if err := m.book().RemoveTable(t.Name); err != nil {141+ m.fail(err.Error())142+ return143+ }144+ m.changed = true145+ m.note = "Removed the table " + t.Name + "; its cells stay, and formulas read them by address " +146+ m.th.KeyHints(m.shortcut("edit.undo"), "undo")147+}148+149+// tableLine is the context line in a table: its name and the column's,150+// as formulas read it.151+func (m *Model) tableLine() string {152+ t, ok := m.sheet.TableAt(m.cur)153+ i := m.cur.Col - t.Range.From.Col154+ if !ok || i >= len(t.Cols) {155+ return ""156+ }157+ col := t.Cols[i]158+ ref := t.Name + "[" + col + "]"159+ if strings.ContainsAny(col, " []#',") {160+ ref = t.Name + "[[" + col + "]]"161+ }162+ return m.th.Muted.Render("Table "+t.Name+", column ") + m.th.Key.Render(col) + m.th.Muted.Render(": "+ref+" in formulas")163+}164+165+// tablesHost is what the tables picker acts on. The model implements166+// it. The picker stays in package ui, as the named ranges picker does:167+// its keys open the model's prompts and move its selection.168+type tablesHost interface {169+ styles() *theme.Theme170+ book() *sheet.Workbook171+ closeOverlay()172+ tablesItems() []picker.Item173+ // renameTable asks for a table's new name, then opens the picker174+ // again.175+ renameTable(t sheet.Table, back bool)176+ // tableChanged follows a table changed from the picker.177+ tableChanged()178+}179+180+// tablesPicker is the picker of tables with its extra keys.181+type tablesPicker struct {182+ m tablesHost183+ *picker.Picker184+ msg string // feedback on the last action, e.g. a removal185+}186+187+// openTables opens the picker of tables.188+func (m *Model) openTables() {189+ p := m.newPicker("Tables", "Type a name", 60, m.tablesItems())190+ p.Action = "go to"191+ m.clearSelection()192+ m.openOverlay(&tablesPicker{m: m, Picker: p})193+}194+195+func (m *Model) tableChanged() {196+ m.syncChanged()197+ m.noteUnrecorded()198+}199+200+// tablesItems lists every table formulas can read.201+func (m *Model) tablesItems() []picker.Item {202+ var items []picker.Item203+ for _, t := range m.book().TableInfos() {204+ where := t.Range.String()205+ if m.book().Len() > 1 {206+ where = sheet.Qualified(t.Sheet.Name(), t.Range)207+ }208+ desc := "Go to it; columns " + strings.Join(t.Cols, ", ")209+ switch {210+ case t.Region && !t.Shown():211+ where, desc = t.Sheet.Name(), "A region with no rows yet"212+ case t.Region:213+ desc = "A region: its source names and shapes it; " + strconv.Itoa(len(t.Cols)) + " columns"214+ }215+ items = append(items, picker.Item{216+ Title: t.Name, Name: len(t.Name), Detail: where, Desc: desc, Off: !t.Shown(),217+ Pick: func() tea.Cmd {218+ m.closeOverlay()219+ if !t.Shown() || m.refuseHidden(t.Sheet) {220+ return nil221+ }222+ m.showSheet(t.Sheet)223+ m.selectRect(t.Range)224+ return nil225+ },226+ })227+ }228+ return items229+}230+231+// current is the table highlighted in the picker, when it's one the232+// table commands change.233+func (p *tablesPicker) current() (sheet.Table, bool) {234+ if p.Picker.Sel >= len(p.Shown()) {235+ return sheet.Table{}, false236+ }237+ _, t, ok := p.m.book().Table(p.Shown()[p.Picker.Sel].Item.Title)238+ return t, ok239+}240+241+func (p *tablesPicker) Key(k tea.KeyPressMsg) tea.Cmd {242+ m := p.m243+ p.msg = ""244+ switch k.String() {245+ case "f2":246+ if t, ok := p.current(); ok {247+ m.closeOverlay()248+ m.renameTable(t, true)249+ }250+ return nil251+ case "ctrl+d":252+ if t, ok := p.current(); ok {253+ m.book().RemoveTable(t.Name)254+ m.tableChanged()255+ p.msg = "Removed the table " + t.Name + "; Ctrl+Z brings it back"256+ p.Items = m.tablesItems()257+ sel := p.Picker.Sel258+ p.Changed()259+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)260+ }261+ return nil262+ }263+ return p.Picker.Key(k)264+}265+266+func (p *tablesPicker) Status() (string, string) {267+ th := p.m.styles()268+ keys := th.KeyHints("Enter", "go to", "F2", "rename", "Ctrl+D", "remove", "Esc", "close")269+ if _, ok := p.current(); !ok {270+ keys = th.KeyHints("Enter", "go to", "Esc", "close")271+ }272+ if len(p.Shown()) == 0 {273+ keys = th.KeyHints("Esc", "close")274+ }275+ if p.msg != "" {276+ return p.msg, keys277+ }278+ if len(p.Items) == 0 {279+ return "No tables yet: Format > Convert to table makes one", keys280+ }281+ desc, _ := p.Picker.Status()282+ return desc, keys283+}@@ -0,0 +1,230 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+)7+8+// unitsModel is a model whose sheet holds Region, Units and Amount in9+// A1:C4, the cursor on B2.10+func unitsModel(t *testing.T) *Model {11+ t.Helper()12+ m := tallModel()13+ m.Update(teaSize(100, 30))14+ for a, v := range map[string]string{15+ "A1": "Region", "B1": "Units", "C1": "Amount",16+ "A2": "North", "B2": "2", "C2": "10",17+ "A3": "South", "B3": "3", "C3": "20",18+ "A4": "East", "B4": "5", "C4": "30",19+ } {20+ m.sheet.Set(addr(a), v)21+ }22+ m.selectRect(rectOf("B2"))23+ return m24+}25+26+func TestConvertToTable(t *testing.T) {27+ m := unitsModel(t)28+ press(t, m, "<ctrl+alt+t>")29+ if l := line(m, contextLine); !strings.HasPrefix(l, "Name for the table of A1:C4: Table1") {30+ t.Fatalf("prompt %q", l)31+ }32+ press(t, m, "Sales", "<enter>")33+ tb, ok := m.sheet.TableAt(addr("B2"))34+ if !ok || tb.Name != "Sales" || tb.Range != rectOf("A1:C4") {35+ t.Fatalf("table %+v", tb)36+ }37+ if l := line(m, contextLine); !strings.Contains(l, "=SUM(Sales[Amount])") {38+ t.Errorf("note %q", l)39+ }40+ if l := line(m, formulaLine); !strings.HasPrefix(l, " Sales") {41+ t.Errorf("name box %q", l)42+ }43+ if m.available("table.create") {44+ t.Error("Convert to table offered inside a table")45+ }46+ // On a table's cell the context line names the table and column.47+ press(t, m, "<esc>", "<right>")48+ if l := line(m, contextLine); !strings.Contains(l, "Table Sales, column Units: Sales[Units] in formulas") {49+ t.Errorf("context %q", l)50+ }51+ // Typing below it grows it; undo takes the row back out.52+ press(t, m, "<ctrl+down>", "<down>", "40", "<enter>")53+ if tb, _ := m.sheet.TableAt(addr("C5")); tb.Range != rectOf("A1:C5") {54+ t.Errorf("grew to %v", tb.Range)55+ }56+ press(t, m, "<ctrl+z>")57+ if _, ok := m.sheet.TableAt(addr("C5")); ok {58+ t.Error("undo kept the row")59+ }60+}61+62+func TestTableCommands(t *testing.T) {63+ m := unitsModel(t)64+ m.sheet.CreateTable("Sales", rectOf("A1:C4"))65+ m.sheet.Set(addr("E1"), "=SUM(Sales[Amount])")66+ m.runCommand("table.rename")67+ press(t, m, "Revenue", "<enter>")68+ if input(m, "E1") != "=SUM(Revenue[Amount])" {69+ t.Errorf("renamed formula %q", input(m, "E1"))70+ }71+ m.runCommand("table.resize")72+ if l := line(m, contextLine); !strings.HasPrefix(l, "Range for Revenue, header row first: A1:C4") {73+ t.Fatalf("resize prompt %q", l)74+ }75+ press(t, m, "<shift+left>", "<enter>")76+ if tb, _ := m.sheet.TableAt(addr("A1")); tb.Range != rectOf("A1:B4") {77+ t.Errorf("resized to %v", tb.Range)78+ }79+ wantShown(t, m, "E1", "#REF!")80+ press(t, m, "<ctrl+z>")81+ for id, want := range map[string]bool{"table.banded": false, "table.header": true} {82+ if c := commands[id]; !c.checked(m) != !want {83+ t.Errorf("%s checked %v", id, c.checked(m))84+ }85+ m.runCommand(id)86+ if commands[id].checked(m) == want {87+ t.Errorf("%s didn't toggle", id)88+ }89+ }90+ m.runCommand("table.remove")91+ if m.sheet.HasTables() || input(m, "E1") != "=SUM($C$2:$C$4)" {92+ t.Errorf("removed: %v %q", m.sheet.HasTables(), input(m, "E1"))93+ }94+ if m.available("table.rename") {95+ t.Error("table commands offered outside a table")96+ }97+}98+99+// Filters and sorts started in a table take its range, header kept.100+func TestTableScopesFilterAndSort(t *testing.T) {101+ m := unitsModel(t)102+ m.sheet.Set(addr("A6"), "apart")103+ m.sheet.CreateTable("Sales", rectOf("A1:C5"))104+ m.selectRect(rectOf("C3"))105+ m.runCommand("data.sort_range_za")106+ if input(m, "C2") != "30" || input(m, "C1") != "Amount" {107+ t.Errorf("sorted: C1 %q C2 %q", input(m, "C1"), input(m, "C2"))108+ }109+ m.runCommand("data.filter")110+ if r, _ := m.sheet.FilterRange(); r != rectOf("A1:C5") {111+ t.Errorf("filter on %v", r)112+ }113+}114+115+func TestTablesPicker(t *testing.T) {116+ m := unitsModel(t)117+ m.sheet.CreateTable("Sales", rectOf("A1:C4"))118+ m.sheet.Set(addr("F1"), "x")119+ m.sheet.CreateTable("Other", rectOf("F1:G3"))120+ m.runCommand("data.tables")121+ scr := screen(m)122+ for _, want := range []string{"Tables", "Sales", "A1:C4", "Other", "F1:G3"} {123+ if !strings.Contains(scr, want) {124+ t.Errorf("missing %q in\n%s", want, scr)125+ }126+ }127+ press(t, m, "oth", "<enter>")128+ if m.selection() != rectOf("F1:G3") {129+ t.Errorf("went to %v", m.selection())130+ }131+ press(t, m, "<esc>", "<f5>", "sales", "<enter>")132+ if m.selection() != rectOf("A1:C4") {133+ t.Errorf("Go to a table: %v", m.selection())134+ }135+ m.runCommand("data.tables")136+ press(t, m, "sales", "<ctrl+d>")137+ if _, _, ok := m.book().Table("Sales"); ok {138+ t.Error("not removed")139+ }140+ if st := line(m, m.height-1); !strings.Contains(st, "Removed the table Sales") {141+ t.Errorf("status %q", st)142+ }143+ press(t, m, "<esc>")144+}145+146+func TestTableSuggestions(t *testing.T) {147+ m := unitsModel(t)148+ m.sheet.CreateTable("Sales", rectOf("A1:C4"))149+ m.selectRect(rectOf("E1"))150+ press(t, m, "=SUM(sal")151+ list, _ := m.entry.assist.Shown(m.host())152+ if len(list) == 0 || list[0].Name != "Sales" || !strings.Contains(list[0].Detail, "A1:C4") {153+ t.Fatalf("suggestions %v", list)154+ }155+ press(t, m, "<tab>", "[am")156+ if list, _ = m.entry.assist.Shown(m.host()); len(list) != 1 || list[0].Name != "Amount" {157+ t.Fatalf("columns %v", list)158+ }159+ if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "Sales[Region, Units, Amount]") {160+ t.Errorf("context %q", ctx)161+ }162+ press(t, m, "<tab>")163+ if m.line.Text() != "=SUM(Sales[Amount]" {164+ t.Fatalf("accepted %q", m.line.Text())165+ }166+ press(t, m, ")", "<enter>")167+ wantShown(t, m, "E1", "60")168+ // Items after #, and a column after @.169+ m.selectRect(rectOf("D2"))170+ press(t, m, "=Sales[#h")171+ if list, _ = m.entry.assist.Shown(m.host()); len(list) != 1 || list[0].Name != "#Headers" {172+ t.Fatalf("items %v", list)173+ }174+ press(t, m, "<esc>", "<esc>", "=Sales[@u", "<tab>", "<enter>")175+ if input(m, "D2") != "=Sales[@Units]" {176+ t.Errorf("this row %q", input(m, "D2"))177+ }178+ wantShown(t, m, "D2", "2")179+}180+181+// Scripts answer the table commands' questions, as a recording writes182+// them.183+func TestTableCommandsInScripts(t *testing.T) {184+ m := unitsModel(t)185+ script(t, m, `186+select("A1:C4")187+run("table.create", answer="Sales")188+select("B2")189+run("table.rename", answer="Revenue")190+run("table.resize", answer="A1:B4")191+run("table.banded")192+`)193+ if m.warn != "" {194+ t.Fatal(m.warn)195+ }196+ tb, ok := m.sheet.TableAt(addr("A2"))197+ if !ok || tb.Name != "Revenue" || tb.Range != rectOf("A1:B4") || !tb.Banded {198+ t.Errorf("table %+v", tb)199+ }200+}201+202+// A table's header row reads without color: bold and underlined, and203+// the context line names the table on its cells. Its bands are204+// decoration, with no state to read.205+func TestMonochromeTable(t *testing.T) {206+ m := newModel()207+ m.sheet.Set(addr("B1"), "Item")208+ m.sheet.Set(addr("B2"), "Rent")209+ if err := m.sheet.CreateTable("Costs", rectOf("B1:B3")); err != nil {210+ t.Fatal(err)211+ }212+ if c := cellAt(m, addr("B1")); !every(c, true, func(c monoCell) bool { return c.bold && c.underline == "1" }) {213+ t.Errorf("a table's header: %+v", c)214+ }215+ if c := cellAt(m, addr("B2")); !every(c, true, plain) {216+ t.Errorf("a table's first row: %+v", c)217+ }218+ m.cur = addr("B2")219+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Table Costs, column Item") {220+ t.Errorf("the table isn't named: %q", l)221+ }222+}223+224+// wantShown checks the value the cell at a shows.225+func wantShown(t *testing.T, m *Model, a, want string) {226+ t.Helper()227+ if got := m.sheet.Value(addr(a)).String(); got != want {228+ t.Errorf("%s = %q, want %q", a, got, want)229+ }230+}@@ -13,6 +13,11 @@ import (
1313 // enough that text on them keeps its contrast.1414 const barBlend = 0.121515 16+// bandBlend is how far a table's banded rows move from the background17+// toward the text color: a shade lighter than the bars, so a band reads18+// as part of the grid rather than a bar.19+const bandBlend = 0.0720+1621 // FromPalette draws the roles in a color scheme's colors. The roles are1722 // New's, on the ANSI slots; each slot becomes the scheme's color. Then,1823 // so every scheme reads well:@@ -45,6 +50,7 @@ func FromPalette(p Palette) Theme {
4550 }4651 bar := lipgloss.NewStyle().Background(blend(p.Background, text, barBlend)).Foreground(text)4752 t.MenuBarRow, t.StatusBarRow = bar, bar53+ t.TableBand = lipgloss.NewStyle().Background(blend(p.Background, text, bandBlend)).Foreground(text)4854 eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p, t.levels) })4955 t.ColumnHeaderRow = lipgloss.NewStyle().Background(t.Header.GetBackground()).Foreground(t.Header.GetForeground())5056 t.standouts()@@ -75,6 +75,12 @@ type Theme struct {
7575 // FilterOn is the filter mark in the header of a column whose filter7676 // hides something (the mark itself also changes, from ▾ to ▼).7777 FilterOn lipgloss.Style78+ // TableHeader is a table's header row when the table styles it,79+ // bold and underlined so it reads without color. TableBand is every80+ // other data row of a banded table: a band of the background moved81+ // toward the text, with the text on it readable.82+ TableHeader lipgloss.Style83+ TableBand lipgloss.Style7884 // NoteMark is the mark in the top-right corner of a cell with a note,7985 // like Sheets' small triangle.8086 NoteMark lipgloss.Style@@ -243,7 +249,10 @@ func roles(dark bool) Theme {
243249 filterFg := lipgloss.Yellow244250 noteFg := lipgloss.Yellow245251 borderFg := lipgloss.White252+ // Bands are the header's gray, lightest on a light terminal.253+ bandBg, bandFg := lipgloss.BrightBlack, lipgloss.BrightWhite246254 if !dark {255+ bandBg, bandFg = lipgloss.White, lipgloss.Black247256 borderFg = lipgloss.Black248257 noteFg = lipgloss.Magenta249258 headerBg, headerFg = lipgloss.White, lipgloss.Black@@ -292,13 +301,15 @@ func roles(dark bool) Theme {
292301 ProgressTodo: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),293302 Copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).294303 UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),295- FrozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),296- Tab: lipgloss.NewStyle(),297- TabActive: accent.Bold(true),298- TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),299- FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),300- NoteMark: lipgloss.NewStyle().Foreground(noteFg),301- CellBorder: lipgloss.NewStyle().Foreground(borderFg),304+ FrozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),305+ Tab: lipgloss.NewStyle(),306+ TabActive: accent.Bold(true),307+ TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),308+ FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),309+ NoteMark: lipgloss.NewStyle().Foreground(noteFg),310+ TableHeader: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),311+ TableBand: lipgloss.NewStyle().Background(bandBg).Foreground(bandFg),312+ CellBorder: lipgloss.NewStyle().Foreground(borderFg),302313 303314 MenuBar: lipgloss.NewStyle(),304315 MenuAccel: lipgloss.NewStyle().Underline(true),@@ -209,7 +209,7 @@ func listFit(th *theme.Theme, labels []string, total, room int, more bool) strin
209209 // another, or by what a region's cells come from.210210 func linkLabel(shown *sheet.Sheet, l sheet.Link, from *sheet.Sheet) string {211211 switch l.Kind {212- case sheet.LinkName, sheet.LinkRegion:212+ case sheet.LinkName, sheet.LinkRegion, sheet.LinkTable:213213 return l.Name214214 case sheet.LinkFile:215215 return filepath.Base(l.Name)@@ -224,7 +224,7 @@ func (m *Model) traceItem(l sheet.Link, dependent bool) picker.Item {
224224 detail = "dependent, the cells it spills into"225225 case !dependent:226226 detail = "precedent" + map[sheet.LinkKind]string{sheet.LinkName: ", named range " + l.Range.String(),227- sheet.LinkRegion: ", a region's table", sheet.LinkSpill: ", the formula that spilled it",227+ sheet.LinkRegion: ", a region's table", sheet.LinkTable: ", a table's " + l.Range.String(), sheet.LinkSpill: ", the formula that spilled it",228228 sheet.LinkFile: ", the linked file its rows come from", sheet.LinkOutput: ", the notebook cell its rows come from"}[l.Kind]229229 }230230 if l.Sheet != m.sheet && l.OnGrid() {@@ -228,8 +228,8 @@ func (m *Model) cellText(lc *lineCtx, a sheet.Addr, sp rowtext.Span, look *sheet
228228 }229229 230230 // cellRole is the role a cell is drawn in: the pointer, the selection,231-// a search match, a trace, an error or a spill, or the plain cell role232-// (colored false). Roles are large, so it points at the theme's.231+// a search match, a trace, an error or a spill, a table's header or232+// band, or the plain cell role (colored false). Roles are large, so it points at the theme's.233233 func (m *Model) cellRole(lc *lineCtx, a sheet.Addr, sp *rowtext.Span, spills bool) (*lipgloss.Style, bool) {234234 switch {235235 case a == lc.focus || lc.merges != nil && lc.merged(a, lc.focus):@@ -250,6 +250,14 @@ func (m *Model) cellRole(lc *lineCtx, a sheet.Addr, sp *rowtext.Span, spills boo
250250 case spills && sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: lc.row}).Spilled():251251 return &m.th.Spilled, true252252 }253+ if m.sheet.HasTables() {254+ switch header, band := m.sheet.TableLook(a); {255+ case header:256+ return &m.th.TableHeader, true257+ case band:258+ return &m.th.TableBand, true259+ }260+ }253261 return &m.th.Cell, false254262 }255263 @@ -8,15 +8,33 @@ require (
88 )99 1010 require (11+ github.com/andybalholm/brotli v1.1.1 // indirect1112 github.com/cockroachdb/apd/v3 v3.2.3 // indirect13+ github.com/dustin/go-humanize v1.0.1 // indirect14+ github.com/google/uuid v1.6.0 // indirect15+ github.com/klauspost/compress v1.17.9 // indirect16+ github.com/mattn/go-isatty v0.0.24 // indirect17+ github.com/ncruces/go-strftime v1.0.0 // indirect18+ github.com/parquet-go/bitpack v1.0.0 // indirect19+ github.com/parquet-go/jsonlite v1.0.0 // indirect20+ github.com/parquet-go/parquet-go v0.32.0 // indirect21+ github.com/pierrec/lz4/v4 v4.1.21 // indirect22+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect1223 github.com/richardlehane/mscfb v1.0.7 // indirect1324 github.com/richardlehane/msoleps v1.0.6 // indirect1425 github.com/tiendc/go-deepcopy v1.7.2 // indirect26+ github.com/twpayne/go-geom v1.6.1 // indirect1527 github.com/xuri/efp v0.0.1 // indirect1628 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect1729 golang.org/x/crypto v0.57.0 // indirect1830 golang.org/x/net v0.59.0 // indirect31+ golang.org/x/sys v0.48.0 // indirect1932 golang.org/x/text v0.42.0 // indirect33+ google.golang.org/protobuf v1.36.11 // indirect34+ modernc.org/libc v1.75.7 // indirect35+ modernc.org/mathutil v1.7.1 // indirect36+ modernc.org/memory v1.12.1 // indirect37+ modernc.org/sqlite v1.59.0 // indirect2038 )2139 2240 replace github.com/FelineStateMachine/012 => ../@@ -1,11 +1,47 @@
1+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=2+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=3+github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=4+github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=5+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=6+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=7+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=8+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=19 github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=210 github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=311 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=412 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=5-github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=6-github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=13+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=14+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=15+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=16+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=17+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=18+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=19+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=20+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=21+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=22+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=23+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=24+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=25+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=26+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=27+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=28+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=29+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=30+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=31+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=32+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=33+github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=34+github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=35+github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=36+github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=37+github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM=38+github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=39+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=40+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=741 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=842 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=43+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=44+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=945 github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=1046 github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=1147 github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=@@ -14,19 +50,61 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
1450 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=1551 github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=1652 github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=53+github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=54+github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=1755 github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=1856 github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=1957 github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=2058 github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=2159 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=2260 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=61+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=62+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=2363 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=2464 golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=2565 golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=2666 golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=67+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=68+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=2769 golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=2870 golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=71+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=72+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=73+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=74+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=2975 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=3076 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=77+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=78+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=79+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=80+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=3181 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=3282 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=83+modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=84+modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=85+modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=86+modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=87+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=88+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=89+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=90+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=91+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=92+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=93+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=94+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=95+modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=96+modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=97+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=98+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=99+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=100+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=101+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=102+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=103+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=104+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=105+modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=106+modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=107+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=108+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=109+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=110+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=@@ -0,0 +1,76 @@
1+package oracle2+3+import (4+ "context"5+ "path/filepath"6+ "testing"7+8+ "github.com/xuri/excelize/v2"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+)12+13+// tableFormulas read the table Sales (A1:C5: Region, Units, Amount) by14+// structured references, each in the cell it is written in. excel is15+// what Excel gives where excelize can't compute a structured reference16+// (it reads tables but its calculation engine doesn't resolve them); 01217+// is checked against it there, and against excelize elsewhere.18+var tableFormulas = []struct{ cell, formula, excel string }{19+ {"E1", "SUM(Sales[Amount])", "100"},20+ {"E2", "SUM(Sales[[Units]:[Amount]])", "113"},21+ {"E3", "ROWS(Sales[#All])", "5"},22+ {"E4", "ROWS(Sales)", "4"},23+ {"E5", "Sales[[#Headers],[Units]]", "Units"},24+ {"E6", `COUNTIF(Sales[Region],"North")`, "2"},25+ {"D3", "Sales[[#This Row],[Amount]]*2", "40"},26+ {"E7", "SUM(C2:C5)", ""},27+}28+29+// TestTablesFromExcel builds a workbook with a table in excelize, as30+// Excel would save it, and reads it with 012's XLSX reader: the table31+// comes in, and formulas reading it compute what Excel computes.32+func TestTablesFromExcel(t *testing.T) {33+ x := excelize.NewFile()34+ rows := [][]any{{"Region", "Units", "Amount"}, {"North", 2, 10}, {"South", 3, 20}, {"North", 1, 30}, {"East", 7, 40}}35+ for i, row := range rows {36+ cell, _ := excelize.CoordinatesToCellName(1, i+1)37+ if err := x.SetSheetRow("Sheet1", cell, &row); err != nil {38+ t.Fatal(err)39+ }40+ }41+ if err := x.AddTable("Sheet1", &excelize.Table{Range: "A1:C5", Name: "Sales"}); err != nil {42+ t.Fatal(err)43+ }44+ for _, tc := range tableFormulas {45+ if err := x.SetCellFormula("Sheet1", tc.cell, tc.formula); err != nil {46+ t.Fatal(err)47+ }48+ }49+ name := filepath.Join(t.TempDir(), "tables.xlsx")50+ if err := x.SaveAs(name); err != nil {51+ t.Fatal(err)52+ }53+ got, err := fileio.Import(context.Background(), name, fileio.Options{})54+ if err != nil {55+ t.Fatal(err)56+ }57+ s := got.Sheet58+ if tb, ok := s.TableAt(addr("B2")); !ok || tb.Name != "Sales" || tb.Range.String() != "A1:C5" {59+ t.Fatalf("table %+v", tb)60+ }61+ for _, tc := range tableFormulas {62+ ours := s.Value(addr(tc.cell))63+ if tc.excel != "" {64+ t.Logf("skip %s: excelize doesn't compute structured references", tc.formula)65+ if !agree(ours, tc.excel) {66+ t.Errorf("%s: 012 %s, Excel %s", tc.formula, show(ours), tc.excel)67+ }68+ continue69+ }70+ theirs, _ := x.CalcCellValue("Sheet1", tc.cell, excelize.Options{RawCellValue: true})71+ if !agree(ours, theirs) {72+ t.Errorf("%s: 012 %s, excelize %q", tc.formula, show(ours), theirs)73+ }74+ }75+ x.Close()76+}