Rules see spilled values: conditional formats color spilled cells, and validation marks spilled values it doesn't accept but never stops an array from spilling, since rules judge what's typed. A spilled cell's dropdown and checkbox are refused like any edit of it. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- 55d028d78e5cf48833f9dc118c3a76512d88ad88
- parent
- 5b9b9b39c98bd7a73e41aca3bae9127f71b9aef8
- parent
- 9993cf1509b795c95b5a4dd46eee9d911af490c6
- author
- Dami <dami.etoile@gmail.com>
@@ -120,8 +120,9 @@ work left open. Sizes: S (a day or two), M (about a week), L (weeks).
120120 |---|---|---|121121 | Dynamic arrays: FILTER, SORT, UNIQUE, SEQUENCE, spill ranges, `#SPILL!`; then LET and LAMBDA. Done: arrays in array contexts and ARRAYFORMULA, spills owned by their formula (`#REF!` with Sheets' explanation when blocked), FILTER, SORT, SORTN, UNIQUE, SEQUENCE, TRANSPOSE, FLATTEN, CHOOSECOLS and CHOOSEROWS, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, and XLSX's dynamic array formulas; see [formulas.md](docs/formulas.md#arrays-and-spills) | The biggest gap in the function library; spills need engine support for ranges a formula owns | L |122122 | Text and regex: TEXTJOIN, SPLIT, REGEXMATCH, REGEXEXTRACT, REGEXREPLACE. Done, on RE2 as Sheets ([formulas.md](docs/formulas.md#regular-expressions)) | Sheets staples | S |123-| Conditional formatting (color scales, rules on values and formulas), drawn in theme roles | Visual; must read under all 349 schemes | M |124-| Data validation: dropdown lists (a picker in the cell), number and date rules, checkboxes | Pairs with the filter picker | M |123+| Conditional formatting (color scales, rules on values and formulas), drawn in theme roles. Done: single-color rules on Sheets' conditions and custom formulas, 2 and 3 point color scales, in named colors on the ANSI slots with readable ink checked under every scheme, a rules panel, and XLSX both ways; see [data.md](docs/data.md#conditional-formatting) | Visual; must read under all 349 schemes | M |124+| Data validation: dropdown lists (a picker in the cell), number and date rules, checkboxes. Done: dropdowns from items or a range (Alt+Down or a click), checkboxes (Space or a click), number, date and text length bounds, custom formulas, reject or warn with Sheets' help text, and XLSX both ways; see [data.md](docs/data.md#data-validation) | Pairs with the filter picker | M |125+| Conditional formatting and validation, what's left: data bars and icon sets, "top 10", "duplicate values", date periods (this week, last month), validation on pastes and fills, rules moving with cut and paste, a dropdown's chip look, and custom checkbox values | Excel's other rule types come in as notes today | M |125126 | Wrap text, row heights, borders, merged cells | Layout changes in the grid renderer | M to L |126127 | Notes on cells (shown on hover and in the context line). Done: Insert > Note (Shift+F2), a corner mark, the context line and a hover box, saved and in XLSX as Excel's notes; see [data.md](docs/data.md#notes) | | S |127128 | Locale: decimal comma, date order, list separator in formulas | Sheets' File > Settings > Locale | M |@@ -30,6 +30,7 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
3030 cmdline the : command line and its completions3131 findbar find and replace, a bar on the context line3232 themepicker File > Settings > Theme, previewing as it moves33+ rules the conditional formatting and data validation panel3334 tabstrip the sheet tabs' layout and where each sheet was left3435 transfer imports running in the background and their progress3536 e2e/ end-to-end tests through libghostty (separate module, cgo)@@ -156,6 +157,18 @@ style.
156157 format) keeps only its formatting and note. A blocked anchor shows157158 `#REF!` with the reason. `Set` refuses spilled cells; files keep only158159 the anchor.160+- **Rules.** A sheet's conditional formats and data validation161+ (`rules.go`, `condfmt.go`, `validation.go`) are lists of rules on162+ ranges, replaced whole on every change so undo steps keep them as they163+ were; they follow inserted and deleted lines, and their formulas follow164+ renamed sheets, as formulas do. How they draw a cell (`Look`, in165+ `looks.go`) is worked out when the screen asks for it and kept until166+ the next recalculation, which counts itself in `Workbook.gen`: a color167+ scale reads its range's numbers once per recalculation, a custom168+ formula is evaluated for the cells drawn, with its relative references169+ moved as a copy's would be. `CheckEntry` tells the UI whether a cell170+ takes an entry, evaluating a formula in place without storing it. The171+ file keeps each rule as one line of JSON (`rulefile.go`).159172 - **JEV.** The engine never touches the network. JEV functions describe a160173 question and ask the `Book` for the answer, which the engine looks up161174 in the workbook's `RemoteSource`, set with `SetRemote`; `internal/jev`@@ -302,7 +315,7 @@ draw. The components:
302315 | edit line | `lineedit.Line` | the one-line editor shared by cell entries, prompts and search fields (package `lineedit`) |303316 | cell entry | `entry`, `assist` | typing into a cell, pointing at references, other sheets while pointing, formula suggestions and signatures (`entry.go`, `assist.go`) |304317 | prompt | `prompt` | a question on the context line, typed or pointed at (`prompt.go`) |305-| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker, the sort and choice bars, the chart editor and selection, the pivot editor (`pivoteditor.go`, `pivotactions.go`), the shortcuts |318+| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker, the sort and choice bars, the chart editor and selection, the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts |306319 | sheet tabs | `tabstrip.Strip` | where each sheet was left, the tab strip's scroll and layout (package `tabstrip`); what clicks on it do (`tabstrip.go`) |307320 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |308321 | import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |@@ -330,6 +343,7 @@ methods off `ui.Model`'s exported API:
330343 | `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail (7) |331344 | `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep (9) |332345 | `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the sheet and cell shown, show a cell, note, mark modified, leave keeping the search, pass a click to the grid (10) |346+| `rules` | `rules.Host` | theme, size, the edit line, close, the sheet and selection, save a conditional format or a validation rule (as the commands macros record), note a rule removed or moved, the terminal's palette colors (10) |333347 | `tabstrip`, `transfer` | none | they're handed a view or messages and draw what they're given |334348 335349 Components that stay in package `ui` declare an unexported host the model@@ -356,8 +370,8 @@ hints) are what every overlay is drawn with.
356370 357371 **Packages.** `theme`, `rowtext`, `formula`, `overlay` and `lineedit`358372 depend on nothing in `ui`, so they can be tested and measured alone. The359-components in `picker`, `cmdline`, `themepicker`, `findbar`, `tabstrip`360-and `transfer` build on them and reach the model only through their373+components in `picker`, `cmdline`, `themepicker`, `findbar`, `rules`,374+`tabstrip` and `transfer` build on them and reach the model only through their361375 hosts, with unit tests of their own. A component moves out of package362376 `ui` when its host stays small (about ten methods or fewer); one that363377 needs more keeps a narrow interface inside `ui` instead, since moving it@@ -78,6 +78,95 @@ as "greater than" or "text contains". Filtered-out rows are hidden, not
7878 deleted: row numbers show the gap, navigation skips them, formulas still7979 count them, and the status line says how many rows are hidden.8080 81+## Conditional formatting82+83+Format > Conditional formatting opens the sheet's rules in a panel at the84+right of the grid, as Sheets' sidebar: `+ Add rule` adds one over the85+selection, Enter edits the highlighted rule, Del removes it and86+Shift+Up/Down moves it. In a rule's form, Up/Down pick a line,87+Left/Right change a choice, Space flips a check, text lines are typed88+into, Enter saves and Esc goes back without saving. Every change is an89+undo step.90+91+A rule applies to ranges (`A2:A100`, or several: `A2:A9,C2:C9`) and is92+one of:93+94+- **Single color**: cells that meet a condition get a text color, a fill,95+ and bold, italic, underline or strikethrough. The conditions are96+ Sheets': is empty, is not empty, text contains, does not contain,97+ starts with, ends with, is exactly (ignoring case), date is, is before,98+ is after (a date, or today, tomorrow, yesterday), greater than, greater99+ than or equal to, less than, less than or equal to, is equal to, is not100+ equal to, is between, is not between, and custom formula is. A value101+ may be a formula (`=B2`), and a custom formula (`=$C2>100`) is written102+ for the first cell of the first range: its relative references move103+ with each cell, as a copied formula's would.104+- **Color scale**: every number in the ranges is shaded along two or three105+ colors, from its minimum to its maximum; each point is the minimum or106+ maximum value, a number, a percent of the way between them, or a107+ percentile, and a midpoint is optional.108+109+Rules are tried in order, and the first that applies to a cell formats110+it, so a rule higher in the list wins. The pointer and the selection draw111+over a rule's colors; its text styles show through them.112+113+Colors are named (red, yellow, green, cyan, blue, magenta) rather than114+picked from a color wheel, because each is one of the terminal's 16 ANSI115+colors: the terminal's palette or the color scheme ([themes.md](themes.md))116+decides what they look like, and a fill's text is drawn in whichever of117+black or white reads on it. A text color that wouldn't read on a rule's118+fill takes the fill's ink instead. Color scales blend the scheme's colors119+(or the terminal's, as it reports them) and pick readable text for each120+shade.121+122+Rules only change how cells look, so they may go on a pivot table's123+results. They follow inserted and deleted rows and columns, and a rule124+whose cells are all deleted goes. Format > Clear conditional formats125+takes the selection out of every rule.126+127+## Data validation128+129+Data > Data validation opens the sheet's validation rules in the same130+panel; Insert > Dropdown opens it with a dropdown for the selection, and131+Insert > Checkbox makes the selection checkboxes at once. The criteria:132+133+| Criteria | Cells may hold | Shows |134+|---|---|---|135+| Dropdown | one of the items typed (`Yes, No, Maybe`), ignoring case | ▾ at the right of the cell |136+| Dropdown (from a range) | one of the values in a range, on any sheet (`Lists!A1:A20`) | ▾ |137+| Checkbox | TRUE or FALSE; a blank cell is unchecked | `[✓]` or `[ ]`, centered |138+| Number | a number between, not between, equal to, greater than ... values | |139+| Date | any date, or one on, after, before, between ... dates | |140+| Text length | text whose length is between, less than ... | |141+| Custom formula is | anything for which the formula (`=B2<=C2`) is TRUE | |142+143+Alt+Down, or a click on the ▾, opens a dropdown's list under the cell:144+type to search, Enter enters the item as if typed. Space, or a click on145+the box, checks or unchecks the selected checkboxes (all checked when146+the active one isn't, as in Sheets); Space anywhere else starts an entry147+as usual. When the active cell has no dropdown, Alt+Down opens the148+column's filter.149+150+Each rule either **rejects** an invalid entry or **shows a warning**.151+Rejected, the entry stays open with Sheets' message on the context line152+("Invalid entry in C2: Input must be a number between 0 and 80"), for153+fixing or Esc. Warned, it goes in, and the cell's text gets a dotted154+underline; on the active cell the context line says why ("Invalid:155+Input must be ..."). A rule's help text, when it has one, replaces its156+own words there. A formula is checked by the value it computes. Blanks157+are always valid.158+159+Each cell has one rule: a rule added over cells that had another takes160+them from it. Validation checks what's typed, picked from a dropdown and161+entered by macros; pastes, fills and imports aren't refused, and what162+they leave invalid is marked. Validation can't go on a pivot table's163+results, which take no entries. Data > Remove data validation takes the164+selection out of every rule.165+166+Adding a checkbox or a dropdown changes no cells, so on a protected range167+it doesn't ask first (on a protected sheet it does); checking a box or168+picking an item asks as typing does ([protection](#protected-sheets-and-ranges)).169+81170 ## Pivot tables82171 83172 Data > Pivot table summarizes the selection, or the table around the@@ -19,7 +19,7 @@ File > Import asks where the data goes, as Sheets' Import location does
1919 | Format | Import | Download |2020 |---|---|---|2121 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |22-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, frozen panes, filters (see [Filters in XLSX](#filters-in-xlsx)), notes (Excel's notes, its legacy comments; threaded comments aren't read), shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. 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 spilled into. A sheet Excel protects comes in unprotected, with a note saying so, since Excel's protection locks cells where 012's only warns. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, column and row formats as column and row styles, frozen rows and columns as frozen panes, a filter as Excel's with the rows it hides hidden, and notes as Excel's notes (which Sheets reads as notes). Protected ranges aren't written. A sheet whose name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) gets one it can (without the spaces, with a number when two would clash, e.g. `Plan (2)`), and the formulas and named ranges naming it name that. A formula that spills goes out as Excel 365's dynamic array formula, with the values it spilled in the cells, so Excel spills it the same and older Excels show the values; see [Arrays in XLSX](#arrays-in-xlsx). JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference), functions only Sheets has (`SORTN`, `FLATTEN`, `SPLIT`, `REGEXMATCH`, `REGEXEXTRACT`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |22+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, frozen panes, filters (see [Filters in XLSX](#filters-in-xlsx)), notes (Excel's notes, its legacy comments; threaded comments aren't read), shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed), conditional formatting and data validation (see [Rules in XLSX](#rules-in-xlsx)). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. 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 spilled into. A sheet Excel protects comes in unprotected, with a note saying so, since Excel's protection locks cells where 012's only warns. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, column and row formats as column and row styles, frozen rows and columns as frozen panes, a filter as Excel's with the rows it hides hidden, and notes as Excel's notes (which Sheets reads as notes), and conditional formats and data validation as Excel's. Protected ranges aren't written. A sheet whose name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) gets one it can (without the spaces, with a number when two would clash, e.g. `Plan (2)`), and the formulas and named ranges naming it name that. A formula that spills goes out as Excel 365's dynamic array formula, with the values it spilled in the cells, so Excel spills it the same and older Excels show the values; see [Arrays in XLSX](#arrays-in-xlsx). JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference), functions only Sheets has (`SORTN`, `FLATTEN`, `SPLIT`, `REGEXMATCH`, `REGEXEXTRACT`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |2323 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |2424 | Parquet | Every column, with dates and timestamps; lists joined with commas | |2525 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |@@ -69,6 +69,47 @@ grouped by year or month, and filtering by color or icon. The filter
6969 itself still comes in over its range. Filters of Excel tables (as7070 opposed to the sheet's AutoFilter) aren't read.7171 72+## Rules in XLSX73+74+Conditional formats ([data.md](data.md#conditional-formatting)) go out75+as Excel's conditional formatting and data validation as its data76+validation, and come back:77+78+| 012 | Excel |79+|---|---|80+| Is empty, is not empty | `containsBlanks`, `notContainsBlanks` |81+| Text contains, does not contain, starts with, ends with | `containsText`, `notContainsText`, `beginsWith`, `endsWith` |82+| Text is exactly | `cellIs` equal to the text |83+| Date is today, tomorrow, yesterday | `timePeriod` |84+| Date is, is before, is after a date | an expression, `INT(A1)<DATE(2026,9,30)` |85+| Greater than ... is not between | `cellIs` with the operator; dates as Excel's serial numbers |86+| Custom formula is | `expression`, in Excel's syntax |87+| Color scale | `colorScale` with its points (`min`, `max`, `num`, `percent`, `percentile`) |88+| A rule's style | a differential style (dxf): bold, italic, underline, strikethrough, the text color and a solid fill |89+| Dropdown, from a range | `list` of the items in quotes, or of the range (`Lists!$A$1:$A$20`) |90+| Checkbox | `list` of `TRUE,FALSE`, which comes back as a checkbox |91+| Number, date, text length | `decimal`, `date`, `textLength` with the operator; any date as a date greater than 0 |92+| Custom formula is | `custom` |93+| Reject the input, show a warning | error style `stop`, `warning`; help text as the prompt |94+95+Each single-color rule stops the ones after it (`stopIfTrue`) and its96+priority is its place, so Excel applies the first that matches, as 01297+does. Named colors go out in the colors of Sheets' palette, and Excel's98+colors (ARGB, theme and indexed) come in as the nearest named color by99+hue, grays as none; a color scale's gray or white point takes the nearest100+scale color. Validation in Excel 2010's extension (lists from other101+sheets) is read too; whole-number rules come in as number rules.102+103+Left out, with a note saying how many and which: conditional formats 012104+has no rule for (data bars, icon sets, top or bottom values, above or105+below average, duplicate or unique values, errors, date periods other106+than today, tomorrow and yesterday, and what Excel 2010's extension107+holds), rules whose style has only a number format or borders, time108+validation and lists from named ranges or formulas. Going out, a109+formula with no Excel equivalent (`#AND#`) leaves its rule out, and so110+do list items with commas or quotes, or longer than Excel's 255111+characters.112+72113 ## The native format73114 74115 `.012` files are JSON with one line per cell, keyed by address, so diffs@@ -220,3 +261,42 @@ empty sheet; a workbook without pivots is still written as version 4.
220261 - `filters` take a filter column's criteria: `hidden` values and a221262 `condition` with its `value`.222263 - `rowTotals` and `columnTotals` are the grand total row and column.264+265+## Conditional formats and data validation266+267+A sheet's rules are two optional fields after its cells and charts, one268+rule per line; they need no version, and older builds ignore them269+(losing the rules, as they lose charts):270+271+```json272+ "conditionalFormats": [273+ {"ranges":"A2:A6","condition":"formula","values":["=$D2"],"text":"green","strikethrough":true},274+ {"ranges":"C2:C6","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]}275+ ],276+ "validations": [277+ {"ranges":"B2:B6","criteria":"list","items":["Ann","Bo","Cy"],"help":"Pick who does it"},278+ {"ranges":"D2:D6","criteria":"checkbox"},279+ {"ranges":"C2:C6","criteria":"number","condition":"between","values":["0","80"],"reject":true}280+ ]281+```282+283+- `ranges` are the rule's ranges, `A2:A9,C2:C9`.284+- A single-color rule has a `condition` (`empty`, `not_empty`, `contains`,285+ `not_contains`, `starts_with`, `ends_with`, `exactly`, `date_is`,286+ `date_before`, `date_after`, `gt`, `ge`, `lt`, `le`, `eq`, `ne`,287+ `between`, `not_between`, `formula`), its `values` as typed, and its288+ style: `text` and `fill` colors (`red`, `yellow`, `green`, `cyan`,289+ `blue`, `magenta`) and `bold`, `italic`, `underline`, `strikethrough`.290+- A color scale has `scale`: two or three points, each a `type` (`min`,291+ `max`, `num`, `percent`, `percentile`), a `value` where it takes one,292+ and a `color`.293+- A validation rule has `criteria` (`list`, `range`, `checkbox`,294+ `number`, `date`, `length`, `formula`) with its `items`, `source`295+ (`Lists!A1:A20`), or `condition` (`between`, `not_between`, `eq`, `ne`,296+ `gt`, `ge`, `lt`, `le`; none for any date) and `values`; `reject` refuses297+ invalid entries, and `help` replaces the rule's own help text.298+299+A line of either list is also what Add conditional format rule and Add300+data validation rule take (in the palette, and as `run(...,301+answer=...)` in macros), which is how a macro records a rule added from302+the panel.@@ -137,7 +137,10 @@ can't be typed into, cleared, pasted over or filled: the context line
137137 names the formula to edit instead. Selecting the formula with its spill138138 and pressing Del clears it, formatting spilled cells keeps the139139 formatting, and inserting or deleting rows and columns through a spill140-spills it again. Copying spilled cells pastes their values.140+spills it again. Copying spilled cells pastes their values. Conditional141+formats color spilled cells by their values, and data validation marks142+spilled values it doesn't accept, but never stops an array from143+spilling: rules judge what's typed, and a spilled value isn't.141144 142145 When a cell in the way of an array isn't empty, the formula shows143146 `#REF!` and says why, as Sheets does: "Array result was not expanded@@ -26,6 +26,9 @@ the app uses, so it never drifts from what the keys do.
2626 | View > Freeze | Keep rows or columns on screen while the rest scrolls |2727 | Data > Sort sheet, Sort range | Sort by the active column A to Z or Z to A, or pick columns and order on a bar (Left/Right column, Space order, Alt+A add, Alt+H header row) |2828 | Alt+Down, click ▾ in a header | With a filter (Data > Create a filter): pick the column's values (Space checks, type to search) or a condition |29+| Alt+Down, click ▾ in a cell | Open the cell's dropdown (Data > Data validation, Insert > Dropdown): type to search, Enter picks; see [data.md](data.md#data-validation) |30+| Space, click a checkbox | Check or uncheck the selected checkboxes (Insert > Checkbox); elsewhere Space starts an entry |31+| Format > Conditional formatting, Data > Data validation | The sheet's rules in a panel: Up/Down pick, Enter adds or edits, Del removes, Shift+Up/Down reorders; in a rule, Left/Right change a choice, Space flips a check, Enter saves, Esc goes back; see [data.md](data.md#conditional-formatting) |2932 | Data > Pivot table | Summarize the table on a new sheet; in the pivot editor Up/Down pick a line, Space adds a field (or opens a filter, flips a total, changes the data range), Left/Right change a field's order or summary, S its "show as", R or F2 renames a value, Shift+Up/Down move it, Del removes it, Enter keeps, Esc undoes. Data > Edit pivot table reopens it |3033 | Alt+Shift+F | Frequency table of the active column on a new sheet (Data > Frequency table), as VisiData's Shift+F |3134 | Ctrl+Alt+= / Ctrl+Alt+- | Insert rows above / delete the selected rows (columns when whole columns are selected) |@@ -123,6 +126,8 @@ and click a column or row header (or the corner) to select whole columns,
123126 rows or everything. Double-click edits. Drag a column header's right edge124127 to resize it; double-click the edge to fit the contents. Dragging past the125128 edge of the grid scrolls. Right-click opens the cell, column or row menu.129+A click on a checkbox's box checks or unchecks it, and a click on a130+dropdown's ▾ opens its list.126131 The mouse pointer changes shape over cells, resize handles and the formula127132 bar in terminals that support it (OSC 22).128133 @@ -23,7 +23,7 @@ it lags, and past a second it stalls.
2323 | Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (two million by default): navigation, drawing and every command cost what the cells cost, not the grid | Loading and saving two million cells: 1 to 2 s; 600 MB of heap | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | Heap per cell (about 300 B), JSON file format |2424 | Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms); 1000 SUMs over a full column: 0.75 ms; 8192 running totals: 2.8 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 37 ms an edit | | About 20 ns per cell read: two map lookups |2525 | Full recalc | Any sheet: under 40 ms for 213 k numbers; 1000 full-column SUMs 1 ms; 8192 running totals 2.5 ms | 60 whole-column criteria functions over 8192 rows: 59 ms | | Same as above |26-| Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |26+| Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms; a color scale on every cell shown adds 0.4 ms at 200 x 60 | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |2727 | Selection statistics | Any selection: extending one over all 2 M cells costs 0.4 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |2828 | Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (two million cells in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |2929 | Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |@@ -172,6 +172,25 @@ terminal (`BenchmarkFrame`).
172172 About half of a frame at 400 x 120 is Bubble Tea's parse and diff of the173173 view string.174174 175+Conditional formats and validation cost what the screen shows. A cell's176+look is worked out when it's drawn and kept until the next177+recalculation (at most 65,536 cells, then the cache starts over), and a178+color scale reads its range's numbers once per recalculation: its179+lowest and highest, and a percentile by selection, not by sorting.180+With a 3-point color scale over all of 8192 x 26 numbers (213 k cells,181+every cell on screen a shade), at 200 x 60 (`BenchmarkFrame`,182+`BenchmarkKeystroke`, `scale-8192x26`):183+184+| | Without rules | With the scale |185+|---|---|---|186+| A frame, or an arrow key through to it | 0.89 ms | 1.28 ms |187+| Typing a number and Enter, through to the frame | 0.91 ms | 2.9 ms (the scale's percentile over 213 k numbers) |188+189+Shades and rule colors keep their escape codes, so a plain cell on one190+costs a string concatenation, not a style render. A custom formula is191+evaluated for each cell drawn, once per recalculation, with its192+references moved for the cell.193+175194 A chart drawn as text costs 4 to 50 us at 24 x 10 to 120 x 40 cells,176195 whatever its data: only the categories that fit are drawn (a pie of 8192177196 slices, 0.44 ms, is the worst). An image for kitty graphics is redrawn@@ -38,7 +38,12 @@ What gets recorded:
3838 (`run("column.width", answer="15")`, `run("sheet.delete", answer="enter")`);3939 - pasted text (`paste_text(...)`), the fill handle (`fill(...)`), column4040 borders dragged or fitted (`set_width(...)`) and tabs dragged41- (`move_sheet(...)`).41+ (`move_sheet(...)`);42+- rules added in the conditional formatting and data validation panels,43+ as the commands that add a rule from a line of the file44+ (`run("format.conditional_add", answer='{"ranges":"B2:B9",...}')`), and45+ items picked from a dropdown, as entries. Editing, removing or moving a46+ rule in the panel is noted as a comment, as the dialogs below are.4247 4348 What doesn't: dialogs such as sorting by several columns, the filter4449 picker, find and replace, the chart editor, dragging a chart, and undo.@@ -211,6 +216,7 @@ menus, help, undo or macros. The ids are those in the recorded scripts; a few:
211216 | `column.width` (answer: the width), `column.reset` | Column widths |212217 | `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |213218 | `data.define_name` (answer: the name) | Name the selection |219+| `format.conditional_add`, `data.validation_add` (answer: the rule as a line of the file, see [files.md](files.md#conditional-formats-and-data-validation)), `insert.checkbox`, `format.conditional_clear`, `data.validation_clear`, `data.checkbox_toggle` | Conditional formats, data validation and checkboxes |214220 | `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 |215221 | `view.freeze_rows1`, `view.freeze_cols1`, `view.freeze_rows0`, ... | Frozen panes |216222 @@ -90,6 +90,22 @@ for cell text, text on the bars, the selection, the headers and every
9090 other role (`TestEveryThemeReadable`). Charts, as text and as kitty9191 images, use the scheme's colors too.9292 93+Conditional formats ([data.md](data.md#conditional-formatting)) name94+their colors (red, yellow, green, cyan, blue, magenta) rather than95+giving RGB, and each is an ANSI slot, so they follow the theme too: a96+text color is the slot on the cell (`RuleText`), a fill the slot as a97+background with black or bright white text, whichever reads better98+(`RuleFill`), and a text color on a fill (`RuleOn`) takes the fill's99+ink where it wouldn't read on it. These roles are corrected and checked100+like the others, and the terminal theme's are checked against the two101+reference palettes. A color scale blends the slots of its points, the102+scheme's colors or the terminal's own (asked for with OSC 4 at startup,103+xterm's when it doesn't answer), in 32 shades between two points; text104+on a shade is the scheme's text or background color (the terminal's105+bright white or black), whichever reads better, or pure black or white106+where neither reaches 4.5:1. A test checks the shades of every pair of107+colors under every scheme.108+93109 The roles for the bars are `MenuBarRow`, `FormulaBarRow`, `ContextRow`,94110 `ColumnHeaderRow` and `StatusBarRow` (a background and a text color for95111 the whole line, out to the terminal's edge, under overlays too), with@@ -24,7 +24,7 @@ func TestNamedRanges(t *testing.T) {
2424 s := start(t, "")2525 s.keys("10", "<enter>", "20", "<enter>", "=SUM(Pair)", "<enter>")2626 s.waitFor("#NAME?")27- s.keys("<ctrl+home>", "<shift+down>", "<alt+d>", "d")27+ s.keys("<ctrl+home>", "<shift+down>", "<alt+d>", "d", "<enter>")2828 s.waitFor("Name for A1:A2:")2929 s.keys("Pair", "<enter>")3030 s.waitForName("Pair")@@ -0,0 +1,70 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ ghostty "go.mitchellh.com/libghostty"10+)11+12+// Rules as a user meets them: a dropdown picked from with Alt+Down and13+// the mouse, a checkbox toggled with Space and a click, an entry a rule14+// rejects, a conditional format added from the panel, and all of it15+// saved and opened again.16+func TestRules(t *testing.T) {17+ dir := t.TempDir()18+ tasksFile(t, dir)19+ s := start(t, dir, "tasks.012")20+ s.waitFor("Write spec")21+22+ // B3's dropdown, from the keyboard.23+ s.keys("<down>", "<down>", "<right>", "<alt+down>")24+ s.waitFor("Search the items")25+ s.keys("cy", "<enter>")26+ s.waitForBar("B3", "Cy")27+28+ // D3's checkbox, with Space, then B5's dropdown and D5's checkbox29+ // with the mouse (row 5 is screen line gridRow1+4).30+ s.keys("<right>", "<right>", "<space>")31+ s.waitForBar("D3", "TRUE")32+ s.waitForLine(gridRow1+2, " 3 Build UI Cy ▾ 40 [✓]")33+ s.click(ghostty.MouseButtonLeft, 6+14+10-1, gridRow1+4) // B's last column34+ s.waitFor("Search the items")35+ s.keys("ann", "<enter>")36+ s.waitForBar("B5", "Ann")37+ s.click(ghostty.MouseButtonLeft, 6+14+10+8+3, gridRow1+4) // D's glyph38+ s.waitForBar("D5", "TRUE")39+40+ // Hours reject anything over 80, with Sheets' words.41+ s.keys("<left>", "120", "<enter>")42+ s.waitFor("Invalid entry in C5: Input must be a number between 0 and 80")43+ s.keys("<esc>")44+ s.waitForBar("C5", "6")45+46+ // Format > Conditional formatting: a new rule over the selection.47+ s.keys("<ctrl+k>", "conditional formatting", "<enter>")48+ s.waitFor("3 rules")49+ s.keys("<enter>")50+ s.waitFor("Add conditional format")51+ s.keys("<enter>")52+ s.waitFor("4 rules")53+ s.keys("<esc>", "<ctrl+s>")54+ s.eventually("saved", func() bool { return !strings.Contains(s.line(29), "modified") })55+ body, err := os.ReadFile(filepath.Join(dir, "tasks.012"))56+ if err != nil {57+ t.Fatal(err)58+ }59+ for _, want := range []string{`{"ranges":"C5","condition":"not_empty","fill":"green"}`, `"B3": "Cy"`, `"D5": "TRUE"`, `"validations": [`} {60+ if !strings.Contains(string(body), want) {61+ t.Errorf("file lacks %s:\n%s", want, body)62+ }63+ }64+65+ // Opened again, the rules are there.66+ s2 := start(t, dir, "tasks.012")67+ s2.waitForLine(gridRow1+2, " 3 Build UI Cy ▾ 40 [✓]")68+ s2.keys("<ctrl+k>", "conditional formatting", "<enter>")69+ s2.waitFor("4 rules")70+}@@ -0,0 +1,120 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+// tasksBook is a task list with rules on every column: owners from a10+// dropdown (one of them not on its list), hours on a color scale, done11+// tasks struck through in green, and checkboxes.12+const tasksBook = `{13+ "version": 2,14+ "widths": {"A": 14, "B": 10, "C": 8, "D": 7},15+ "cells": {16+ "A1": {"input":"Task","bold":true},17+ "B1": {"input":"Owner","bold":true},18+ "C1": {"input":"Hours","bold":true,"align":"right"},19+ "D1": {"input":"Done","bold":true,"align":"center"},20+ "A2": "Write spec", "B2": "Ann", "C2": "12", "D2": "TRUE",21+ "A3": "Build UI", "B3": "Bo", "C3": "40", "D3": "FALSE",22+ "A4": "Tests", "B4": "Cy", "C4": "26", "D4": "TRUE",23+ "A5": "Docs", "B5": "Dee", "C5": "6",24+ "A6": "Release", "B6": "Ann", "C6": "3", "D6": "FALSE",25+ "A7": "Total", "C7": "=SUM(C2:C6)"26+ },27+ "conditionalFormats": [28+ {"ranges":"A2:A6","condition":"formula","values":["=$D2"],"text":"green","strikethrough":true},29+ {"ranges":"C2:C6","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]},30+ {"ranges":"C7","condition":"gt","values":["80"],"fill":"magenta","bold":true}31+ ],32+ "validations": [33+ {"ranges":"B2:B6","criteria":"list","items":["Ann","Bo","Cy"],"help":"Pick who does it"},34+ {"ranges":"D2:D6","criteria":"checkbox"},35+ {"ranges":"C2:C6","criteria":"number","condition":"between","values":["0","80"],"reject":true}36+ ]37+}38+`39+40+func tasksFile(t *testing.T, dir string) {41+ if err := os.WriteFile(filepath.Join(dir, "tasks.012"), []byte(tasksBook), 0o644); err != nil {42+ t.Fatal(err)43+ }44+}45+46+// openTasks opens the task list.47+func openTasks(s *session) {48+ s.keys("<ctrl+o>")49+ s.waitFor("Open file:")50+ s.keys("tasks", "<enter>")51+ s.waitFor("Write spec")52+}53+54+var ruleScreens = []screen{55+ {name: "rules-sheet", files: tasksFile, setup: func(s *session) {56+ openTasks(s)57+ s.keys("<down>", "<down>", "<down>", "<down>", "<right>")58+ s.waitFor("Invalid: Pick who does it")59+ }},60+ {name: "rules-sheet-dracula", opts: options{config: "theme = Dracula\n"}, files: tasksFile, setup: func(s *session) {61+ openTasks(s)62+ s.keys("<down>", "<down>", "<right>", "<right>", "<right>")63+ s.waitFor("check or uncheck")64+ }},65+ {name: "rules-sheet-latte", opts: options{light: true, config: "theme = Catppuccin Latte\n"}, files: tasksFile, setup: func(s *session) {66+ openTasks(s)67+ s.keys("<down>", "<down>", "<right>", "<right>", "<right>")68+ s.waitFor("check or uncheck")69+ }},70+ {name: "rules-panel", files: tasksFile, setup: func(s *session) {71+ openTasks(s)72+ s.keys("<ctrl+k>", "conditional formatting", "<enter>")73+ s.waitFor("Conditional format rules")74+ s.keys("<down>", "<down>")75+ s.waitFor("first rule that matches")76+ }},77+ {name: "rules-editor", files: tasksFile, setup: func(s *session) {78+ openTasks(s)79+ s.keys("<ctrl+k>", "conditional formatting", "<enter>")80+ s.waitFor("Conditional format rules")81+ s.keys("<down>", "<enter>")82+ s.waitFor("Edit conditional format")83+ }},84+ {name: "rules-editor-narrow", opts: options{cols: 60, rows: 16}, files: tasksFile, setup: func(s *session) {85+ openTasks(s)86+ s.keys("<ctrl+k>", "conditional formatting", "<enter>")87+ s.waitFor("Conditional format rules")88+ s.keys("<down>", "<down>", "<enter>")89+ s.waitFor("Minpoint")90+ }},91+ {name: "validation-editor", files: tasksFile, setup: func(s *session) {92+ openTasks(s)93+ s.keys("<ctrl+k>", "data validation", "<enter>")94+ s.waitFor("Data validation rules")95+ s.keys("<down>", "<enter>")96+ s.waitFor("Edit data validation")97+ }},98+ {name: "dropdown-open", files: tasksFile, setup: func(s *session) {99+ openTasks(s)100+ s.keys("<down>", "<down>", "<right>", "<alt+down>")101+ s.waitFor("Search the items")102+ }},103+ {name: "validation-reject", files: tasksFile, setup: func(s *session) {104+ openTasks(s)105+ s.keys("<down>", "<right>", "<right>", "120", "<enter>")106+ s.waitFor("Invalid entry in C2")107+ }},108+}109+110+func init() {111+ light := map[string]bool{"rules-sheet": true, "rules-panel": true, "rules-editor": true, "validation-editor": true, "dropdown-open": true}112+ for _, sc := range ruleScreens {113+ screens = append(screens, sc)114+ if light[sc.name] {115+ sc.name += "-light"116+ sc.opts.light = true117+ screens = append(screens, sc)118+ }119+ }120+}@@ -101,7 +101,7 @@ func pivotEditor(s *session) {
101101 // name, ending on the total.102102 func named(s *session) {103103 budget(s)104- s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<alt+d>", "d", "Expenses", "<enter>")104+ s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<alt+d>", "d", "<enter>", "Expenses", "<enter>")105105 s.waitForName("Expenses")106106 s.keys("<esc>", "<ctrl+home>", "<down>", "<right>", "<right>", "4", "<enter>")107107 s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<right>")@@ -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)">B3</span><span style="background:var(--vt-palette-7)"> </span> Bo3+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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">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>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="font-weight:bold">Task</span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#b58900"> </span><span style="color:#1d1f21;background:#b58900">12</span><span style="background:#b58900"> </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> Build UI <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Bo</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">▾</span><span style="background:#c82829"> </span><span style="color:#ffffff;background:#c82829">40</span><span style="background:#c82829"> </span> [ ]8+<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-2);text-decoration:line-through">Tests</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">B3</span> <span style="color:var(--vt-palette-8)">───────────────────────────────────┐</span>9+<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> Docs <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-0)">Search</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">items</span> <span style="color:var(--vt-palette-8)">│</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> Release <span style="color:var(--vt-palette-8)">├────────────────────────────────────────┤</span>11+<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> Total <span style="color:var(--vt-palette-8)">│</span> Ann <span style="color:var(--vt-palette-8)">│</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> <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)">Bo</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">✓</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Cy <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-8)">─┘</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+Enter Bo in B3 <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)">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@@ -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)">B3</span><span style="background:var(--vt-palette-8)"> </span> Bo3+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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">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> <span style="font-weight:bold">Task</span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </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> Build UI <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Bo</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">B3</span> <span style="color: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> Docs <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">Search</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">items</span> <span style="color: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> Release <span style="color: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> Total <span style="color:var(--vt-palette-8)">│</span> Ann <span style="color: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> <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)">Bo</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">✓</span><span style="background:var(--vt-palette-6)"> </span><span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Cy <span style="color: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> <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">3</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>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+Enter Bo in B3 <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)">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@@ -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)">89</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)">92</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)">89</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)">92</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@@ -40,6 +40,6 @@
4040 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</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)">Ctrl+Shift+R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4141 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Menus</span> <span style="font-weight:bold">and</span> <span style="font-weight:bold">search</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>4242 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open a menu by its underlined letter <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+letter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>43-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</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-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">45</span> <span style="color:var(--vt-palette-8)">─┘</span>43+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</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-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">─┘</span>4444 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">40</span><span style="background:var(--vt-palette-8)"> </span>4545 <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)">89</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)">92</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@@ -4,7 +4,7 @@
44 <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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Sort range › <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)">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> Household budget 2026 <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>66 <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> <span style="color:var(--vt-palette-8)">│</span> Create a filter <span style="color:var(--vt-palette-8)">│</span>7-<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> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</span>7+<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> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">│</span>88 <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> Groceries 612.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">filter</span> <span style="color:var(--vt-palette-8)">│</span>99 <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> Transit 96 <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>1010 <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> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span> <span style="color:var(--vt-palette-8)">│</span> Pivot table <span style="color:var(--vt-palette-8)">│</span>@@ -15,16 +15,16 @@
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>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> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>1717 <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>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> Trace precedents <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+,</span><span style="background:var(--vt-palette-7)"> </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> Trace dependents <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+.</span><span style="background:var(--vt-palette-7)"> </span> <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>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>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)">Macros</span><span style="background:var(--vt-palette-6)"> </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> 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="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)">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> <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)">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>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> Trace precedents <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> Trace dependents <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="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><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)">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>2828 <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@@ -4,7 +4,7 @@
44 <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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Sort range › <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)">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> Household budget 2026 <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>66 <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> <span style="color:var(--vt-palette-8)">│</span> Create a filter <span style="color:var(--vt-palette-8)">│</span>7-<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> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</span>7+<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> Rent 1450 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">│</span>88 <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> Groceries 612.4 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Remove</span> <span style="color:var(--vt-palette-8)">filter</span> <span style="color:var(--vt-palette-8)">│</span>99 <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> Transit 96 <span style="color:var(--vt-palette-8)">├────────────────────────────────────────────────┤</span>1010 <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> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span> <span style="color:var(--vt-palette-8)">│</span> Pivot table <span style="color:var(--vt-palette-8)">│</span>@@ -15,16 +15,16 @@
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>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> Protect sheets and ranges <span style="color:var(--vt-palette-8)">│</span>1717 <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>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> Trace precedents <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+,</span><span style="background:var(--vt-palette-8)"> </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> Trace dependents <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+.</span><span style="background:var(--vt-palette-8)"> </span> <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>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>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)">Macros</span><span style="background:var(--vt-palette-6)"> </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> 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="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)">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> <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)">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>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> Trace precedents <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> Trace dependents <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="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><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)">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>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> <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@@ -4,23 +4,23 @@
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-4)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">B</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-8)">│</span> Increase decimal places <span style="color:var(--vt-palette-8)">│</span> Plain text <span style="color:var(--vt-palette-8)">│</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="font-weight:bold">Bills</span> <span style="font-weight:bold">for</span> <span style="font-weight:bold">October</span> <span style="color:var(--vt-palette-8)">│</span> Decrease decimal places <span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</span>66 <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> <span style="font-weight:bold">Item</span> <span style="font-weight:bold">Due</span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────│</span> Number <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+1</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>7-<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> Rent 10/1/2026 <span style="color:var(--vt-palette-8)">│</span> Bold <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ct</span><span style="color:var(--vt-palette-8)">│</span> Percent <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+5</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>8-<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> Food 9/28/2026 <span style="color:var(--vt-palette-8)">│</span> Italic <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ct</span><span style="color:var(--vt-palette-8)">│</span> Scientific <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+6</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>9-<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> Transit 9/30/2026 <span style="color:var(--vt-palette-8)">│</span> Underline <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ct</span><span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</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> <span style="text-decoration:line-through">Gym</span> <span style="text-decoration:line-through">10/5/2026</span> <span style="color:var(--vt-palette-8)">│</span> Strikethrough <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Shi</span><span style="color:var(--vt-palette-8)">│</span> Accounting <span style="color:var(--vt-palette-8)">│</span>7+<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> Rent 10/1/2026 <span style="color:var(--vt-palette-8)">│</span> Bold <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Percent <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+5</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>8+<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> Food 9/28/2026 <span style="color:var(--vt-palette-8)">│</span> Italic <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Scientific <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+6</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>9+<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> Transit 9/30/2026 <span style="color:var(--vt-palette-8)">│</span> Underline <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</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> <span style="text-decoration:line-through">Gym</span> <span style="text-decoration:line-through">10/5/2026</span> <span style="color:var(--vt-palette-8)">│</span> Strikethrough <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+S</span><span style="color:var(--vt-palette-8)">│</span> Accounting <span style="color:var(--vt-palette-8)">│</span>1111 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">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">Total</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────│</span> Financial <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Align left <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shi</span><span style="color:var(--vt-palette-8)">│</span> Currency <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="font-style:italic">Gym</span> <span style="font-style:italic">cancelled</span> <span style="font-style:italic">from</span> <span style="color:var(--vt-palette-8)">│</span> Align center <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shi</span><span style="color:var(--vt-palette-8)">│</span> Currency rounded <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Align right <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shi</span><span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</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> <span style="color:var(--vt-palette-8)">│</span> Align left <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+S</span><span style="color:var(--vt-palette-8)">│</span> Currency <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</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> <span style="font-style:italic">Gym</span> <span style="font-style:italic">cancelled</span> <span style="font-style:italic">from</span> <span style="color:var(--vt-palette-8)">│</span> Align center <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+S</span><span style="color:var(--vt-palette-8)">│</span> Currency rounded <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Align right <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+S</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> Date <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+3</span><span style="background:var(--vt-palette-7)"> </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> Column width <span style="color:var(--vt-palette-8)">│</span> Time <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Shift+2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>1717 <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> Reset column width <span style="color:var(--vt-palette-8)">│</span> Date time <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> <span style="color:var(--vt-palette-8)">├────────────────────────────────────│</span> Duration <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> Clear formatting <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ct</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>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>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> Conditional formatting <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> <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>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>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> 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>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>2424 <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>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>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>@@ -4,23 +4,23 @@
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-4)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">B</span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-8)">│</span> Increase decimal places <span style="color:var(--vt-palette-8)">│</span> Plain text <span style="color: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="font-weight:bold">Bills</span> <span style="font-weight:bold">for</span> <span style="font-weight:bold">October</span> <span style="color:var(--vt-palette-8)">│</span> Decrease decimal places <span style="color:var(--vt-palette-8)">├───────────────────────────────────┤</span>66 <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> <span style="font-weight:bold">Item</span> <span style="font-weight:bold">Due</span> <span style="color:var(--vt-palette-8)">├────────────────────────────────────│</span> Number <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+1</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>7-<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> Rent 10/1/2026 <span style="color:var(--vt-palette-8)">│</span> Bold <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ct</span><span style="color:var(--vt-palette-8)">│</span> Percent <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Food 9/28/2026 <span style="color:var(--vt-palette-8)">│</span> Italic <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ct</span><span style="color:var(--vt-palette-8)">│</span> Scientific <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit 9/30/2026 <span style="color:var(--vt-palette-8)">│</span> Underline <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ct</span><span style="color: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> <span style="text-decoration:line-through">Gym</span> <span style="text-decoration:line-through">10/5/2026</span> <span style="color:var(--vt-palette-8)">│</span> Strikethrough <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shi</span><span style="color:var(--vt-palette-8)">│</span> Accounting <span style="color:var(--vt-palette-8)">│</span>7+<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> Rent 10/1/2026 <span style="color:var(--vt-palette-8)">│</span> Bold <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Percent <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Food 9/28/2026 <span style="color:var(--vt-palette-8)">│</span> Italic <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Scientific <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> Transit 9/30/2026 <span style="color:var(--vt-palette-8)">│</span> Underline <span style="background:var(--vt-palette-8)"> </span><span style="color: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> <span style="text-decoration:line-through">Gym</span> <span style="text-decoration:line-through">10/5/2026</span> <span style="color:var(--vt-palette-8)">│</span> Strikethrough <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+S</span><span style="color:var(--vt-palette-8)">│</span> Accounting <span style="color:var(--vt-palette-8)">│</span>1111 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">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">Total</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-4)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────│</span> Financial <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Align left <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shi</span><span style="color:var(--vt-palette-8)">│</span> Currency <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="font-style:italic">Gym</span> <span style="font-style:italic">cancelled</span> <span style="font-style:italic">from</span> <span style="color:var(--vt-palette-8)">│</span> Align center <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shi</span><span style="color:var(--vt-palette-8)">│</span> Currency rounded <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Align right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shi</span><span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Align left <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="color:var(--vt-palette-8)">│</span> Currency <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color: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> <span style="font-style:italic">Gym</span> <span style="font-style:italic">cancelled</span> <span style="font-style:italic">from</span> <span style="color:var(--vt-palette-8)">│</span> Align center <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="color:var(--vt-palette-8)">│</span> Currency rounded <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Align right <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="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> Date <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+3</span><span style="background:var(--vt-palette-8)"> </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> Column width <span style="color:var(--vt-palette-8)">│</span> Time <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1717 <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> Reset column width <span style="color:var(--vt-palette-8)">│</span> Date time <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> <span style="color:var(--vt-palette-8)">├────────────────────────────────────│</span> Duration <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> Clear formatting <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ct</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>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>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> Conditional formatting <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> <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>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>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> 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>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>2424 <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>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>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>@@ -12,5 +12,5 @@
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>1313 <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>1414 <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)">31</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">132</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)">32</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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@@ -9,12 +9,12 @@
99 <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> Tran<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>1010 <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> Savi<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>1111 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Tota<span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">View</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Freeze</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">│</span>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> 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>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> 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>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> 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>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> Insert rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <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)">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)">20</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">132</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)">23</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">142</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>@@ -9,12 +9,12 @@
99 <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> Tran<span style="color:var(--vt-palette-8)">│</span> Freeze 1 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>1010 <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> Savi<span style="color:var(--vt-palette-8)">│</span> Unfreeze <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color:var(--vt-palette-8)">│</span>1111 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Tota<span style="color:var(--vt-palette-8)">│</span> Freeze 2 <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">View</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Freeze</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">Alt+Down</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Filter</span> <span style="color:var(--vt-palette-8)">by</span> <span style="color:var(--vt-palette-8)">column</span> <span style="color:var(--vt-palette-8)">Data</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> 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>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> 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>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> 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>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> Insert rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <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)">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)">20</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">132</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)">23</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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>@@ -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> 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>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> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</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 co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <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)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">132</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)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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>@@ -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> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</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> Vim keys <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</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> Theme <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</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)">132</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">132</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)">142</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">142</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="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">RULES</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)">A1</span><span style="background:var(--vt-palette-7)"> </span> Task3+4+<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>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Edit</span> <span style="font-weight:bold">conditional</span> <span style="font-weight:bold">format</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#b58900"> </span><span style="color:#1d1f21;background:#b58900">12</span><span style="background:#b58900"> </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)">Apply</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">to</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A2:A6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<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> Build UI Bo <span style="color:var(--vt-palette-0)">▾</span><span style="background:#c82829"> </span><span style="color:#ffffff;background:#c82829">40</span><span style="background:#c82829"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Format <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Single</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">color</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</span>8+<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-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-0)">▾</span><span style="background:#bf5915"> </span><span style="color:#ffffff;background:#bf5915">26</span><span style="background:#bf5915"> </span> [✓] <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────────┤</span>9+<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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-0)">▾</span><span style="background:#888b00"> </span><span style="color:#1d1f21;background:#888b00">6</span><span style="background:#888b00"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Format if <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Custom</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">formula</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">is</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</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> Release Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#718c00"> </span><span style="color:var(--vt-palette-0);background:#718c00">3</span><span style="background:#718c00"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Value <span style="font-weight:bold">=$D2</span> <span style="color:var(--vt-palette-8)">│</span>11+<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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </span> <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────────┤</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> <span style="color:var(--vt-palette-8)">│</span> Text color <span style="color:var(--vt-palette-2)">Aa</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Green</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> Fill <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">None</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> [ ] Bold <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">│</span> [ ] Italic <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> [ ] Underline <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> [x] Strikethrough <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>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> Preview <span style="color:var(--vt-palette-2);text-decoration:line-through">123</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>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="color:var(--vt-palette-0)">The</span> <span style="color:var(--vt-palette-0)">ranges</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">rule</span> <span style="color:var(--vt-palette-0)">applies</span> <span style="color:var(--vt-palette-0)">to,</span> <span style="color:var(--vt-palette-0)">e.g.</span> <span style="color:var(--vt-palette-0)">A2:A100</span> <span style="color:var(--vt-palette-0)">or</span> <span style="color:var(--vt-palette-0)">A2:A9,C2:C9</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)">save</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)">back</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">RULES</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)">A1</span><span style="background:var(--vt-palette-8)"> </span> Task3+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>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Edit</span> <span style="font-weight:bold">conditional</span> <span style="font-weight:bold">format</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</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)">Apply</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">to</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<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> Build<span style="color:var(--vt-palette-8)">│</span> Format <span style="color:var(--vt-palette-15);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)">Color</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">scale</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span><span style="color: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> Docs <span style="color:var(--vt-palette-8)">│</span> Minpoint <span style="color:var(--vt-palette-15);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)">Min</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">value</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> Relea<span style="color:var(--vt-palette-8)">│</span> Color <span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-15);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)">Green</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> Total<span style="color:var(--vt-palette-8)">│</span> Midpoint <span style="color:var(--vt-palette-15);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)">Percentile</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Value <span style="font-weight:bold">50</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Color <span style="background:var(--vt-palette-3)"> </span> <span style="color:var(--vt-palette-15);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)">Yellow</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Maxpoint <span style="color:var(--vt-palette-15);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)">Max</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">value</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</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>16+ <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)">save</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)">back</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">RULES</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)">A1</span><span style="background:var(--vt-palette-8)"> </span> Task3+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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Edit</span> <span style="font-weight:bold">conditional</span> <span style="font-weight:bold">format</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </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)">Apply</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">to</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">A2:A6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<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> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Format <span style="color:var(--vt-palette-15);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)">Single</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">color</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color: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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-8)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Format if <span style="color:var(--vt-palette-15);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)">Custom</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">formula</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">is</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Value <span style="font-weight:bold">=$D2</span> <span style="color: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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Text color <span style="color:var(--vt-palette-2)">Aa</span> <span style="color:var(--vt-palette-15);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)">Green</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> Fill <span style="color:var(--vt-palette-15);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)">None</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-8)">│</span> [ ] Bold <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> [ ] Italic <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> [ ] Underline <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> [x] Strikethrough <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>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> Preview <span style="color:var(--vt-palette-2);text-decoration:line-through">123</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>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="color:var(--vt-palette-8)">The</span> <span style="color:var(--vt-palette-8)">ranges</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">rule</span> <span style="color:var(--vt-palette-8)">applies</span> <span style="color:var(--vt-palette-8)">to,</span> <span style="color:var(--vt-palette-8)">e.g.</span> <span style="color:var(--vt-palette-8)">A2:A100</span> <span style="color:var(--vt-palette-8)">or</span> <span style="color:var(--vt-palette-8)">A2:A9,C2:C9</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)">save</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)">back</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">RULES</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)">A1</span><span style="background:var(--vt-palette-7)"> </span> Task3+4+<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>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Conditional</span> <span style="font-weight:bold">format</span> <span style="font-weight:bold">rules</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#b58900"> </span><span style="color:#1d1f21;background:#b58900">12</span><span style="background:#b58900"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span> + Add rule <span style="color:var(--vt-palette-8)">│</span>7+<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> Build UI Bo <span style="color:var(--vt-palette-0)">▾</span><span style="background:#c82829"> </span><span style="color:#ffffff;background:#c82829">40</span><span style="background:#c82829"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-2);text-decoration:line-through">123</span> A2:A6 Custom formula is =$D2 <span style="color:var(--vt-palette-8)">│</span>8+<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-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-0)">▾</span><span style="background:#bf5915"> </span><span style="color:#ffffff;background:#bf5915">26</span><span style="background:#bf5915"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:#718c00"> </span><span style="background:#9e8a00"> </span><span style="background:#bc680e"> </span><span style="background:#c82829"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Color</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">scale</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>9+<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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-0)">▾</span><span style="background:#888b00"> </span><span style="color:#1d1f21;background:#888b00">6</span><span style="background:#888b00"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-5);font-weight:bold">123</span><span style="background:var(--vt-palette-5)"> </span> C7 Greater than 80 <span style="color:var(--vt-palette-8)">│</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> Release Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#718c00"> </span><span style="color:var(--vt-palette-0);background:#718c00">3</span><span style="background:#718c00"> </span> [ ] <span style="color:var(--vt-palette-8)">└────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-0)">rules</span> <span style="color:var(--vt-palette-8)">─┘</span>11+<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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </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="color:var(--vt-palette-0)">The</span> <span style="color:var(--vt-palette-0)">first</span> <span style="color:var(--vt-palette-0)">rule</span> <span style="color:var(--vt-palette-0)">that</span> <span style="color:var(--vt-palette-0)">matches</span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">cell</span> <span style="color:var(--vt-palette-0)">wins</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)">edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Del</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)">Shift+↑/↓</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)">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">RULES</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)">A1</span><span style="background:var(--vt-palette-8)"> </span> Task3+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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Conditional</span> <span style="font-weight:bold">format</span> <span style="font-weight:bold">rules</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span> + Add rule <span style="color:var(--vt-palette-8)">│</span>7+<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> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-2);text-decoration:line-through">123</span> A2:A6 Custom formula is =$D2 <span style="color: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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:#b5bd68"> </span><span style="background:#dcc370"> </span><span style="background:#e4a56f"> </span><span style="background:#cc6666"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">Color</span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">scale</span><span style="background:var(--vt-palette-6)"> </span><span style="color: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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-8)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">123</span><span style="background:var(--vt-palette-5)"> </span> C7 Greater than 80 <span style="color: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> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </span> [ ] <span style="color:var(--vt-palette-8)">└────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">rules</span> <span style="color: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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </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="color:var(--vt-palette-8)">The</span> <span style="color:var(--vt-palette-8)">first</span> <span style="color:var(--vt-palette-8)">rule</span> <span style="color:var(--vt-palette-8)">that</span> <span style="color:var(--vt-palette-8)">matches</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">cell</span> <span style="color:var(--vt-palette-8)">wins</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)">edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Del</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)">Shift+↑/↓</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)">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@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">F</span><span style="color:#f8f8f2;background:#383941">ile</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">E</span><span style="color:#f8f8f2;background:#383941">dit</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">V</span><span style="color:#f8f8f2;background:#383941">iew</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">I</span><span style="color:#f8f8f2;background:#383941">nsert</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941">F</span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">o</span><span style="color:#f8f8f2;background:#383941">rmat</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">D</span><span style="color:#f8f8f2;background:#383941">ata</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941;text-decoration:underline">H</span><span style="color:#f8f8f2;background:#383941">elp</span><span style="background:#383941"> </span><span style="background:#8be9fd"> </span><span style="color:#000000;background:#8be9fd;font-weight:bold">READY</span><span style="background:#8be9fd"> </span>2+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">D3</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">FALSE</span><span style="background:#1e1f29"> </span>3+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Space</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">check</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">or</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">uncheck</span><span style="background:#1e1f29"> </span>4+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">A</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">B</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">C</span><span style="background:#555555"> </span><span style="background:#8be9fd"> </span><span style="color:#000000;background:#8be9fd;font-weight:bold">D</span><span style="background:#8be9fd"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">E</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">F</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">G</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">H</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">I</span><span style="background:#555555"> </span>5+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">1</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29;font-weight:bold">Task</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29;font-weight:bold">Owner</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29;font-weight:bold">Hours</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29;font-weight:bold">Done</span><span style="background:#1e1f29"> </span>6+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">2</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#50fa7b;background:#1e1f29;text-decoration:line-through">Write</span><span style="background:#1e1f29;text-decoration:line-through"> </span><span style="color:#50fa7b;background:#1e1f29;text-decoration:line-through">spec</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Ann</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">▾</span><span style="background:#f1fa8c"> </span><span style="color:#1e1f29;background:#f1fa8c">12</span><span style="background:#f1fa8c"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">[✓]</span><span style="background:#1e1f29"> </span>7+<span style="background:#8be9fd"> </span><span style="color:#000000;background:#8be9fd;font-weight:bold">3</span><span style="background:#8be9fd"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Build</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">UI</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Bo</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">▾</span><span style="background:#ff5555"> </span><span style="color:#1e1f29;background:#ff5555">40</span><span style="background:#ff5555"> </span><span style="background:#8be9fd"> </span><span style="color:#000000;background:#8be9fd">[</span><span style="background:#8be9fd"> </span><span style="color:#000000;background:#8be9fd">]</span><span style="background:#8be9fd"> </span><span style="background:#1e1f29"> </span>8+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">4</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#50fa7b;background:#1e1f29;text-decoration:line-through">Tests</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Cy</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">▾</span><span style="background:#f8a871"> </span><span style="color:#1e1f29;background:#f8a871">26</span><span style="background:#f8a871"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">[✓]</span><span style="background:#1e1f29"> </span>9+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">5</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Docs</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29;text-decoration:underline dotted #f1fa8c">Dee</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">▾</span><span style="background:#87fa81"> </span><span style="color:#1e1f29;background:#87fa81">6</span><span style="background:#87fa81"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">[</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">]</span><span style="background:#1e1f29"> </span>10+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">6</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Release</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Ann</span><span style="background:#1e1f29"> </span><span style="color:#6a6a69;background:#1e1f29">▾</span><span style="background:#50fa7b"> </span><span style="color:#1e1f29;background:#50fa7b">3</span><span style="background:#50fa7b"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">[</span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">]</span><span style="background:#1e1f29"> </span>11+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">7</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span><span style="color:#f8f8f2;background:#1e1f29">Total</span><span style="background:#1e1f29"> </span><span style="background:#ff79c6"> </span><span style="color:#000000;background:#ff79c6;font-weight:bold">87</span><span style="background:#ff79c6"> </span><span style="background:#1e1f29"> </span>12+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">8</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>13+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">9</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>14+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">10</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>15+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">11</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>16+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">12</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>17+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">13</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>18+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">14</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>19+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">15</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>20+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">16</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>21+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">17</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>22+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">18</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>23+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">19</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>24+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">20</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>25+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">21</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>26+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">22</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>27+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">23</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>28+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">24</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>29+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">25</span><span style="background:#555555"> </span><span style="background:#1e1f29"> </span>30+<span style="background:#8be9fd"> </span><span style="color:#000000;background:#8be9fd;font-weight:bold">Sheet1</span><span style="background:#8be9fd"> </span><span style="background:#383941"> </span><span style="color:#6a6a69;background:#383941">+</span><span style="background:#383941"> </span><span style="color:#6a6a69;background:#383941">│</span><span style="background:#383941"> </span><span style="color:#f8f8f2;background:#383941">tasks.012</span><span style="background:#383941"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Ctrl+K</span><span style="background:#555555"> </span><span style="background:#383941"> </span><span style="color:#6a6a69;background:#383941">search</span><span style="background:#383941"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Ctrl+/</span><span style="background:#555555"> </span><span style="background:#383941"> </span><span style="color:#6a6a69;background:#383941">shortcuts</span><span style="background:#383941"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">F10</span><span style="background:#555555"> </span><span style="background:#383941"> </span><span style="color:#6a6a69;background:#383941">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">F</span><span style="color:#4c4f69;background:#dadde4">ile</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">E</span><span style="color:#4c4f69;background:#dadde4">dit</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">V</span><span style="color:#4c4f69;background:#dadde4">iew</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">I</span><span style="color:#4c4f69;background:#dadde4">nsert</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4">F</span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">o</span><span style="color:#4c4f69;background:#dadde4">rmat</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">D</span><span style="color:#4c4f69;background:#dadde4">ata</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">H</span><span style="color:#4c4f69;background:#dadde4">elp</span><span style="background:#dadde4"> </span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299;font-weight:bold">READY</span><span style="background:#179299"> </span>2+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">D3</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">FALSE</span><span style="background:#eff1f5"> </span>3+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Space</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">check</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">or</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">uncheck</span><span style="background:#eff1f5"> </span>4+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">A</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">B</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">C</span><span style="background:#acb0be"> </span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299;font-weight:bold">D</span><span style="background:#179299"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">E</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">F</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">G</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">H</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">I</span><span style="background:#acb0be"> </span>5+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">1</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5;font-weight:bold">Task</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5;font-weight:bold">Owner</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5;font-weight:bold">Hours</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5;font-weight:bold">Done</span><span style="background:#eff1f5"> </span>6+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">2</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#467948;background:#eff1f5;text-decoration:line-through">Write</span><span style="background:#eff1f5;text-decoration:line-through"> </span><span style="color:#467948;background:#eff1f5;text-decoration:line-through">spec</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Ann</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">▾</span><span style="background:#df8e1d"> </span><span style="color:#000000;background:#df8e1d">12</span><span style="background:#df8e1d"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">[✓]</span><span style="background:#eff1f5"> </span>7+<span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299;font-weight:bold">3</span><span style="background:#179299"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Build</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">UI</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Bo</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">▾</span><span style="background:#d20f39"> </span><span style="color:#eff1f5;background:#d20f39">40</span><span style="background:#d20f39"> </span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299">[</span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299">]</span><span style="background:#179299"> </span><span style="background:#eff1f5"> </span>8+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">4</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#467948;background:#eff1f5;text-decoration:line-through">Tests</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Cy</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">▾</span><span style="background:#d94f2b"> </span><span style="color:#000000;background:#d94f2b">26</span><span style="background:#d94f2b"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">[✓]</span><span style="background:#eff1f5"> </span>9+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">5</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Docs</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5;text-decoration:underline dotted #df8e1d">Dee</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">▾</span><span style="background:#779a26"> </span><span style="color:#000000;background:#779a26">6</span><span style="background:#779a26"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">[</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">]</span><span style="background:#eff1f5"> </span>10+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">6</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Release</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Ann</span><span style="background:#eff1f5"> </span><span style="color:#5c5f77;background:#eff1f5">▾</span><span style="background:#40a02b"> </span><span style="color:#000000;background:#40a02b">3</span><span style="background:#40a02b"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">[</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">]</span><span style="background:#eff1f5"> </span>11+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">7</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Total</span><span style="background:#eff1f5"> </span><span style="background:#ea76cb"> </span><span style="color:#36373b;background:#ea76cb;font-weight:bold">87</span><span style="background:#ea76cb"> </span><span style="background:#eff1f5"> </span>12+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">8</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>13+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">9</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>14+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">10</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>15+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">11</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>16+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">12</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>17+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">13</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>18+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">14</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>19+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">15</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>20+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">16</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>21+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">17</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>22+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">18</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>23+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">19</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>24+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">20</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>25+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">21</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>26+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">22</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>27+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">23</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>28+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">24</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>29+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">25</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>30+<span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299;font-weight:bold">Sheet1</span><span style="background:#179299"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">+</span><span style="background:#dadde4"> </span><span style="color:#6c6f85;background:#dadde4">│</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4">tasks.012</span><span style="background:#dadde4"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Ctrl+K</span><span style="background:#acb0be"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">search</span><span style="background:#dadde4"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Ctrl+/</span><span style="background:#acb0be"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">shortcuts</span><span style="background:#dadde4"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">F10</span><span style="background:#acb0be"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">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)">B5</span><span style="background:var(--vt-palette-7)"> </span> Dee3+<span style="color:var(--vt-palette-3)">Invalid:</span> <span style="color:var(--vt-palette-3)">Pick</span> <span style="color:var(--vt-palette-3)">who</span> <span style="color:var(--vt-palette-3)">does</span> <span style="color:var(--vt-palette-3)">it</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Alt+Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">choose</span> <span style="color:var(--vt-palette-0)">from</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">list</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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">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>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="font-weight:bold">Task</span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#b58900"> </span><span style="color:#1d1f21;background:#b58900">12</span><span style="background:#b58900"> </span> [✓]7+<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> Build UI Bo <span style="color:var(--vt-palette-0)">▾</span><span style="background:#c82829"> </span><span style="color:#ffffff;background:#c82829">40</span><span style="background:#c82829"> </span> [ ]8+<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-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-0)">▾</span><span style="background:#bf5915"> </span><span style="color:#ffffff;background:#bf5915">26</span><span style="background:#bf5915"> </span> [✓]9+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> Docs <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline dotted var(--vt-palette-3)">Dee</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">▾</span><span style="background:#888b00"> </span><span style="color:#1d1f21;background:#888b00">6</span><span style="background:#888b00"> </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> Release Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#718c00"> </span><span style="color:var(--vt-palette-0);background:#718c00">3</span><span style="background:#718c00"> </span> [ ]11+<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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </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> tasks.012 <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">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)">B5</span><span style="background:var(--vt-palette-8)"> </span> Dee3+<span style="color:var(--vt-palette-3)">Invalid:</span> <span style="color:var(--vt-palette-3)">Pick</span> <span style="color:var(--vt-palette-3)">who</span> <span style="color:var(--vt-palette-3)">does</span> <span style="color:var(--vt-palette-3)">it</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="color:var(--vt-palette-8)">from</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">list</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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</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)">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> <span style="font-weight:bold">Task</span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </span> [✓]7+<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> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓]9+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">5</span><span style="background:var(--vt-palette-6)"> </span> Docs <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline dotted var(--vt-palette-3)">Dee</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </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> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </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> tasks.012 <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">RULES</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)">A1</span><span style="background:var(--vt-palette-7)"> </span> Task3+4+<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>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Edit</span> <span style="font-weight:bold">data</span> <span style="font-weight:bold">validation</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#b58900"> </span><span style="color:#1d1f21;background:#b58900">12</span><span style="background:#b58900"> </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)">Apply</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">to</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">B2:B6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<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> Build UI Bo <span style="color:var(--vt-palette-0)">▾</span><span style="background:#c82829"> </span><span style="color:#ffffff;background:#c82829">40</span><span style="background:#c82829"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Criteria <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Dropdown</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</span>8+<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-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-0)">▾</span><span style="background:#bf5915"> </span><span style="color:#ffffff;background:#bf5915">26</span><span style="background:#bf5915"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span> Items <span style="font-weight:bold">Ann,</span> <span style="font-weight:bold">Bo,</span> <span style="font-weight:bold">Cy</span> <span style="color:var(--vt-palette-8)">│</span>9+<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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-0)">▾</span><span style="background:#888b00"> </span><span style="color:#1d1f21;background:#888b00">6</span><span style="background:#888b00"> </span> [ ] <span style="color:var(--vt-palette-8)">├──────────────────────────────────────────────┤</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> Release Ann <span style="color:var(--vt-palette-0)">▾</span><span style="background:#718c00"> </span><span style="color:var(--vt-palette-0);background:#718c00">3</span><span style="background:#718c00"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> If invalid <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">‹</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Show</span><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)">warning</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">›</span> <span style="color:var(--vt-palette-8)">│</span>11+<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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </span> <span style="color:var(--vt-palette-8)">│</span> Help text <span style="font-weight:bold">Pick</span> <span style="font-weight:bold">who</span> <span style="font-weight:bold">does</span> <span style="font-weight:bold">it</span> <span style="color:var(--vt-palette-8)">│</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> <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────┘</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="color:var(--vt-palette-0)">The</span> <span style="color:var(--vt-palette-0)">ranges</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">rule</span> <span style="color:var(--vt-palette-0)">applies</span> <span style="color:var(--vt-palette-0)">to,</span> <span style="color:var(--vt-palette-0)">e.g.</span> <span style="color:var(--vt-palette-0)">B2:B100</span> <span style="color:var(--vt-palette-0)">or</span> <span style="color:var(--vt-palette-0)">B2:B9,D2:D9</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)">save</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)">back</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">RULES</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)">A1</span><span style="background:var(--vt-palette-8)"> </span> Task3+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-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Edit</span> <span style="font-weight:bold">data</span> <span style="font-weight:bold">validation</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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </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)">Apply</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">to</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">B2:B6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<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> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> Criteria <span style="color:var(--vt-palette-15);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)">Dropdown</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span> Items <span style="font-weight:bold">Ann,</span> <span style="font-weight:bold">Bo,</span> <span style="font-weight:bold">Cy</span> <span style="color: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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-8)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </span> [ ] <span style="color: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> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> If invalid <span style="color:var(--vt-palette-15);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)">Show</span><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)">warning</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">›</span> <span style="color: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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </span> <span style="color:var(--vt-palette-8)">│</span> Help text <span style="font-weight:bold">Pick</span> <span style="font-weight:bold">who</span> <span style="font-weight:bold">does</span> <span style="font-weight:bold">it</span> <span style="color: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> <span style="color: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="color:var(--vt-palette-8)">The</span> <span style="color:var(--vt-palette-8)">ranges</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">rule</span> <span style="color:var(--vt-palette-8)">applies</span> <span style="color:var(--vt-palette-8)">to,</span> <span style="color:var(--vt-palette-8)">e.g.</span> <span style="color:var(--vt-palette-8)">B2:B100</span> <span style="color:var(--vt-palette-8)">or</span> <span style="color:var(--vt-palette-8)">B2:B9,D2:D9</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)">save</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)">back</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">EDIT</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)">C2</span><span style="background:var(--vt-palette-8)"> </span> 1203+<span style="color:var(--vt-palette-3)">Invalid</span> <span style="color:var(--vt-palette-3)">entry</span> <span style="color:var(--vt-palette-3)">in</span> <span style="color:var(--vt-palette-3)">C2:</span> <span style="color:var(--vt-palette-3)">Input</span> <span style="color:var(--vt-palette-3)">must</span> <span style="color:var(--vt-palette-3)">be</span> <span style="color:var(--vt-palette-3)">a</span> <span style="color:var(--vt-palette-3)">number</span> <span style="color:var(--vt-palette-3)">between</span> <span style="color:var(--vt-palette-3)">0</span> <span style="color:var(--vt-palette-3)">and</span> <span style="color:var(--vt-palette-3)">80</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="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">C</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)">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> <span style="font-weight:bold">Task</span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span>6+<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-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <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)">120</span><span style="background:var(--vt-palette-6)"> </span> [✓]7+<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> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </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> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </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> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-8)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </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> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </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> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </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> tasks.012 <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@@ -41,6 +41,10 @@ type Snapshot struct {
4141 // the formula's cell, for formats that keep formulas (XLSX).4242 Spills map[sheet.Addr]sheet.Rect4343 44+ // CondFormats and Validations are the sheet's rules, for formats45+ // that keep them (XLSX).46+ CondFormats []sheet.CondFormat47+ Validations []sheet.Validation4448 // Notes are the cells' notes in the range, for formats that keep4549 // them (XLSX, as comments). A note may be on a cell with no contents.4650 Notes map[sheet.Addr]string@@ -84,7 +88,8 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
8488 r.To = r.From8589 }8690 snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name,87- ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter()}91+ ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter(),92+ CondFormats: s.CondFormats(), Validations: s.Validations()}8893 snap.FrozenRows, snap.FrozenCols = s.Frozen()8994 for _, a := range s.NotesIn(notes) {9095 if snap.Notes == nil {@@ -94,6 +94,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
9494 res.Notes = append(res.Notes, fmt.Sprintf("%s naming a sheet that doesn't exist saved as values, e.g. %s (%s)",9595 count(w.missing.n, "formula", "formulas"), w.missing.example, sheet.QuoteSheet(w.missingSheet)))9696 }97+ res.Notes = append(res.Notes, w.rulesNotes()...)9798 return res, nil9899 }99100 @@ -22,6 +22,7 @@ type xlsxBook struct {
2222 expanded int64 // bytes of shared formulas expanded, see formula2323 // protected names the sheets read so far that Excel protects.2424 protected []string25+ skips ruleSkips // rules left out, see xlsxrulesimport.go2526 }2627 2728 // xlsxSheetInfo is a sheet as the workbook lists it.@@ -79,7 +79,8 @@ func (bk *xlsxBook) importBook(ctx context.Context, opt Options) (*Result, error
7979 b.s = active8080 prog.setRows(done)8181 s, notes := b.finish(notes)82- return &Result{Sheet: s, Rows: done, Notes: append(notes, applyFilters(book, filters)...)}, nil82+ notes = append(notes, applyFilters(book, filters)...)83+ return &Result{Sheet: s, Rows: done, Notes: append(notes, bk.skips.notes()...)}, nil8384 }8485 8586 // importSheet reads sheet i into b.s, with its frozen panes, reporting@@ -118,6 +119,7 @@ func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, af **xls
118119 if *af, err = r.readTail(); err != nil {119120 return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)120121 }122+ bk.loadRules(b.s, r)121123 if r.protected {122124 bk.protected = append(bk.protected, bk.sheets[i].name)123125 }@@ -0,0 +1,277 @@
1+package fileio2+3+import (4+ "bufio"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/numfmt"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// A sheet's rules go out as Excel's conditional formatting (single-color15+// rules with a differential style, dxf, and color scales) and data16+// validation, after the autoFilter as the schema orders them. The rules17+// are tried in order and the first that applies wins, so each single-18+// color rule stops the ones after it (stopIfTrue) and its priority is its19+// place. Named colors are written in the colors of Sheets' palette.20+//21+// 012 Excel22+// is empty, is not empty containsBlanks, notContainsBlanks23+// text contains, starts ... containsText, notContainsText, beginsWith, endsWith24+// text is exactly cellIs equal to the text25+// date is, before, after timePeriod (today, tomorrow, yesterday) or an expression on INT(cell)26+// comparisons, between cellIs27+// custom formula expression28+// color scale colorScale29+// checkbox list validation of TRUE,FALSE30+// dropdown, from a range list validation of the items, or of the range31+// number, date, text length decimal, date, textLength validation32+// custom formula custom validation33+34+// ruleRGB are the colors named rule colors are written in, for text, a35+// fill and a color scale's points.36+var ruleRGB = [sheet.NumColors][3]string{37+ {},38+ {"FFCC0000", "FFF4CCCC", "FFF8696B"}, // red39+ {"FFBF9000", "FFFFF2CC", "FFFFEB84"}, // yellow40+ {"FF38761D", "FFD9EAD3", "FF63BE7B"}, // green41+ {"FF134F5C", "FFD0E0E3", "FF67C9D3"}, // cyan42+ {"FF1155CC", "FFCFE2F3", "FF5A8AC6"}, // blue43+ {"FF741B47", "FFEAD1DC", "FFC27BC0"}, // magenta44+}45+46+// ruleNotes counts rules Excel has no equivalent for, keeping the first47+// one's range as an example.48+type ruleNotes struct {49+ formats, validations valueCount50+}51+52+// writeRules writes a sheet's conditional formats and data validation.53+func (w *xlsxWriter) writeRules(bw *bufio.Writer, ws string, snap *Snapshot) {54+ for i, f := range snap.CondFormats {55+ rule, ok := w.cfRule(f, i+1)56+ if !ok {57+ w.rules.formats.add(w.multi, ws, f.Ranges[0].From)58+ continue59+ }60+ fmt.Fprintf(bw, `<conditionalFormatting sqref="%s">%s</conditionalFormatting>`, sqref(f.Ranges), rule)61+ }62+ var dv strings.Builder63+ n := 064+ for _, v := range snap.Validations {65+ x, ok := w.validation(v)66+ if !ok {67+ w.rules.validations.add(w.multi, ws, v.Ranges[0].From)68+ continue69+ }70+ dv.WriteString(x)71+ n++72+ }73+ if n > 0 {74+ fmt.Fprintf(bw, `<dataValidations count="%d">%s</dataValidations>`, n, dv.String())75+ }76+}77+78+// sqref writes ranges as Excel's sqref: "A1:A9 C1:C9".79+func sqref(rs []sheet.Rect) string {80+ parts := make([]string, len(rs))81+ for i, r := range rs {82+ parts[i] = excelRect(r)83+ }84+ return strings.Join(parts, " ")85+}86+87+// excelText quotes text as a formula's string.88+func excelText(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }89+90+func excelNum(n float64) string { return strconv.FormatFloat(n, 'f', -1, 64) }91+92+// excelArg writes a rule's value as an Excel formula: a formula93+// translated, a number (a date as Excel's serial), or text.94+func (w *xlsxWriter) excelArg(arg string) (string, bool) {95+ arg = strings.TrimSpace(arg)96+ if sheet.IsFormulaEntry(arg) {97+ return toExcelFormula(arg, w.renamed)98+ }99+ if n, f, ok := sheet.ParseValue(arg); ok {100+ return excelNum(toExcelSerial(n, f)), true101+ }102+ return excelText(arg), true103+}104+105+// cellIsOps are Excel's names of the comparisons.106+var cellIsOps = map[sheet.RuleOp]string{sheet.RuleGreater: "greaterThan", sheet.RuleGreaterEq: "greaterThanOrEqual",107+ sheet.RuleLess: "lessThan", sheet.RuleLessEq: "lessThanOrEqual", sheet.RuleEqual: "equal",108+ sheet.RuleNotEqual: "notEqual", sheet.RuleBetween: "between", sheet.RuleNotBetween: "notBetween"}109+110+// textRules are the text tests Excel has a rule type for, with the111+// formula each is written with, on the cell and the text.112+var textRules = map[sheet.RuleOp]struct{ typ, op, formula string }{113+ sheet.RuleContains: {"containsText", "containsText", `NOT(ISERROR(SEARCH(%[2]s,%[1]s)))`},114+ sheet.RuleNotContains: {"notContainsText", "notContains", `ISERROR(SEARCH(%[2]s,%[1]s))`},115+ sheet.RuleStartsWith: {"beginsWith", "beginsWith", `LEFT(%[1]s,LEN(%[2]s))=%[2]s`},116+ sheet.RuleEndsWith: {"endsWith", "endsWith", `RIGHT(%[1]s,LEN(%[2]s))=%[2]s`},117+}118+119+// cfRule writes rule f as a <cfRule> of priority p, or reports false120+// when Excel can't take it.121+func (w *xlsxWriter) cfRule(f sheet.CondFormat, p int) (string, bool) {122+ if f.IsScale() {123+ return colorScaleXML(f.Scale, p), true124+ }125+ cell := excelRect(sheet.Rect{From: f.Ranges[0].From, To: f.Ranges[0].From})126+ head := fmt.Sprintf(`<cfRule type="%%s" dxfId="%d" priority="%d" stopIfTrue="1"`, w.styles.dxf(f.Style), p)127+ formulas := func(fs ...string) string {128+ var b strings.Builder129+ for _, fx := range fs {130+ b.WriteString("<formula>" + escapeXML(fx, false) + "</formula>")131+ }132+ return b.String()133+ }134+ arg := strings.TrimSpace(f.Args[0])135+ switch op := f.Op; {136+ case op == sheet.RuleEmpty:137+ return fmt.Sprintf(head, "containsBlanks") + ">" + formulas("LEN(TRIM("+cell+"))=0") + "</cfRule>", true138+ case op == sheet.RuleNotEmpty:139+ return fmt.Sprintf(head, "notContainsBlanks") + ">" + formulas("LEN(TRIM("+cell+"))>0") + "</cfRule>", true140+ case textRules[op].typ != "" && !sheet.IsFormulaEntry(arg):141+ t := textRules[op]142+ return fmt.Sprintf(head, t.typ) + fmt.Sprintf(` operator="%s" text="%s">`, t.op, escapeXML(arg, true)) +143+ formulas(fmt.Sprintf(t.formula, cell, excelText(arg))) + "</cfRule>", true144+ case op == sheet.RuleExactly && !sheet.IsFormulaEntry(arg):145+ return fmt.Sprintf(head, "cellIs") + ` operator="equal">` + formulas(excelText(arg)) + "</cfRule>", true146+ case op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter:147+ return w.dateRule(head, cell, op, arg)148+ case op == sheet.RuleFormula:149+ fx, ok := toExcelFormula(arg, w.renamed)150+ return fmt.Sprintf(head, "expression") + ">" + formulas(fx) + "</cfRule>", ok151+ case cellIsOps[op] != "":152+ lo, ok1 := w.excelArg(f.Args[0])153+ fs := []string{lo}154+ ok2 := true155+ if op.Args() == 2 {156+ var hi string157+ hi, ok2 = w.excelArg(f.Args[1])158+ fs = append(fs, hi)159+ }160+ return fmt.Sprintf(head, "cellIs") + fmt.Sprintf(` operator="%s">`, cellIsOps[op]) + formulas(fs...) + "</cfRule>", ok1 && ok2161+ }162+ return "", false // a text test on a formula's value163+}164+165+// dateRule writes a date test: a timePeriod for "Date is today" and the166+// like, else an expression comparing the cell's day.167+func (w *xlsxWriter) dateRule(head, cell string, op sheet.RuleOp, arg string) (string, bool) {168+ days := map[string]string{"today": "TODAY()", "tomorrow": "TODAY()+1", "yesterday": "TODAY()-1"}169+ word := strings.ToLower(arg)170+ if op == sheet.RuleDateIs && days[word] != "" {171+ return fmt.Sprintf(head, "timePeriod") + fmt.Sprintf(` timePeriod="%s"><formula>FLOOR(%s,1)=%s</formula></cfRule>`, word, cell, days[word]), true172+ }173+ day := days[word]174+ if day == "" {175+ n, _, ok := sheet.ParseValue(arg)176+ if !ok {177+ return "", false178+ }179+ y, m, d := numfmt.Civil(int64(n))180+ day = fmt.Sprintf("DATE(%d,%d,%d)", y, m, d)181+ }182+ cmp := map[sheet.RuleOp]string{sheet.RuleDateIs: "=", sheet.RuleDateBefore: "<", sheet.RuleDateAfter: ">"}[op]183+ return fmt.Sprintf(head, "expression") + ">" + "<formula>" + escapeXML("INT("+cell+")"+cmp+day, false) + "</formula></cfRule>", true184+}185+186+// colorScaleXML writes a color scale of priority p.187+func colorScaleXML(ps []sheet.ScalePoint, p int) string {188+ var cfvo, colors strings.Builder189+ for _, pt := range ps {190+ fmt.Fprintf(&cfvo, `<cfvo type="%s"`, pt.Kind)191+ if pt.Kind.TakesValue() {192+ fmt.Fprintf(&cfvo, ` val="%s"`, escapeXML(strings.TrimSpace(pt.Value), true))193+ }194+ cfvo.WriteString("/>")195+ fmt.Fprintf(&colors, `<color rgb="%s"/>`, ruleRGB[pt.Color][2])196+ }197+ return fmt.Sprintf(`<cfRule type="colorScale" priority="%d"><colorScale>%s%s</colorScale></cfRule>`, p, cfvo.String(), colors.String())198+}199+200+// validTypes are Excel's names of the kinds that compare.201+var validTypes = map[sheet.ValidKind]string{sheet.ValidNumber: "decimal", sheet.ValidDate: "date", sheet.ValidLength: "textLength"}202+203+// validation writes rule v as a <dataValidation>, or reports false when204+// Excel can't take it: list items with commas or quotes, or longer than205+// Excel's 255 characters.206+func (w *xlsxWriter) validation(v sheet.Validation) (string, bool) {207+ typ, op := "list", ""208+ var fs []string209+ ok := true210+ switch v.Kind {211+ case sheet.ValidList:212+ list := strings.Join(v.Items, ",")213+ ok = len(list) <= 255 && !strings.ContainsAny(strings.Join(v.Items, ""), `,"`)214+ fs = []string{excelText(list)}215+ case sheet.ValidCheckbox:216+ fs = []string{`"TRUE,FALSE"`}217+ case sheet.ValidRange:218+ name, r, rok := v.SourceRange()219+ ref, _ := strings.CutPrefix(excelRange("x", r), sheet.QuoteSheet("x")+"!")220+ if name != "" {221+ if to, renamed := w.renamed[formula.SheetKey(name)]; renamed {222+ name = to223+ }224+ ref = excelRange(name, r)225+ }226+ fs, ok = []string{ref}, rok227+ case sheet.ValidFormula:228+ typ = "custom"229+ fx, fok := toExcelFormula(v.Args[0], w.renamed)230+ fs, ok = []string{fx}, fok231+ default:232+ typ, op = validTypes[v.Kind], cellIsOps[v.Op]233+ if v.Kind == sheet.ValidDate && v.Op == sheet.RuleNone {234+ op, fs = "greaterThan", []string{"0"} // any date235+ break236+ }237+ for k := range v.Op.Args() {238+ fx, aok := w.excelArg(v.Args[k])239+ fs, ok = append(fs, fx), ok && aok240+ }241+ }242+ if !ok {243+ return "", false244+ }245+ var b strings.Builder246+ fmt.Fprintf(&b, `<dataValidation type="%s"`, typ)247+ if op != "" {248+ fmt.Fprintf(&b, ` operator="%s"`, op)249+ }250+ style := "stop"251+ if !v.Reject {252+ style = "warning"253+ }254+ fmt.Fprintf(&b, ` allowBlank="1" showErrorMessage="1" errorStyle="%s"`, style)255+ if h := strings.TrimSpace(v.Help); h != "" {256+ fmt.Fprintf(&b, ` showInputMessage="1" prompt="%s" error="%s"`, escapeXML(h, true), escapeXML(h, true))257+ }258+ fmt.Fprintf(&b, ` sqref="%s">`, sqref(v.Ranges))259+ for k, fx := range fs {260+ fmt.Fprintf(&b, "<formula%d>%s</formula%d>", k+1, escapeXML(fx, false), k+1)261+ }262+ return b.String() + "</dataValidation>", true263+}264+265+// rulesNotes says which rules were left out.266+func (w *xlsxWriter) rulesNotes() []string {267+ var out []string268+ if n := w.rules.formats; n.n > 0 {269+ out = append(out, fmt.Sprintf("%s with no Excel equivalent left out, e.g. %s",270+ count(n.n, "conditional format", "conditional formats"), n.example))271+ }272+ if n := w.rules.validations; n.n > 0 {273+ out = append(out, fmt.Sprintf("%s Excel can't hold left out, e.g. %s (list items with commas or quotes, or over 255 characters, or a formula with no Excel equivalent)",274+ count(n.n, "data validation rule", "data validation rules"), n.example))275+ }276+ return out277+}@@ -0,0 +1,213 @@
1+package fileio2+3+import (4+ "context"5+ "reflect"6+ "strings"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// lines are rules as the file writes them, for comparing.15+func formatLines(s *sheet.Sheet) []string {16+ var out []string17+ for _, f := range s.CondFormats() {18+ out = append(out, f.JSON())19+ }20+ return out21+}22+23+func validationLines(s *sheet.Sheet) []string {24+ var out []string25+ for _, v := range s.Validations() {26+ out = append(out, v.JSON())27+ }28+ return out29+}30+31+func mustLoad(t *testing.T, s *sheet.Sheet, formats, validations []string) {32+ t.Helper()33+ var fs []sheet.CondFormat34+ for _, l := range formats {35+ f, err := sheet.ParseCondFormat(l)36+ if err != nil {37+ t.Fatalf("%s: %v", l, err)38+ }39+ fs = append(fs, f)40+ }41+ var vs []sheet.Validation42+ for _, l := range validations {43+ v, err := sheet.ParseValidation(l)44+ if err != nil {45+ t.Fatalf("%s: %v", l, err)46+ }47+ vs = append(vs, v)48+ }49+ if s.LoadCondFormats(fs)+s.LoadValidations(vs) > 0 {50+ t.Fatal("rules left out")51+ }52+}53+54+// Every rule 012 has goes out as Excel's and comes back the same.55+func TestXLSXRulesRoundTrip(t *testing.T) {56+ src := months(t)57+ lists, err := src.Book().AddSheet("Lists", 1)58+ if err != nil {59+ t.Fatal(err)60+ }61+ lists.Set(addr(t, "A1"), "North")62+ formats := []string{63+ `{"ranges":"B2:B5","condition":"gt","values":["100"],"fill":"green","bold":true}`,64+ `{"ranges":"B2:B5","condition":"between","values":["1","10"],"text":"red","fill":"yellow"}`,65+ `{"ranges":"C2:C5","condition":"contains","values":["o"],"italic":true}`,66+ `{"ranges":"C2:C5","condition":"not_contains","values":["x"],"text":"blue"}`,67+ `{"ranges":"C2:C5","condition":"starts_with","values":["re"],"underline":true}`,68+ `{"ranges":"C2:C5","condition":"ends_with","values":["s"],"strikethrough":true}`,69+ `{"ranges":"C2:C5","condition":"exactly","values":["Fees"],"fill":"magenta"}`,70+ `{"ranges":"A2:A5,C2:C5","condition":"empty","fill":"cyan"}`,71+ `{"ranges":"A2:A5","condition":"not_empty","text":"magenta"}`,72+ `{"ranges":"A2:A5","condition":"date_is","values":["today"],"fill":"red"}`,73+ `{"ranges":"A2:A5","condition":"date_before","values":["2026-09-30"],"fill":"red"}`,74+ `{"ranges":"A2:A5","condition":"date_after","values":["yesterday"],"fill":"blue"}`,75+ `{"ranges":"B2:B5","condition":"eq","values":["=B$2"],"fill":"blue"}`,76+ `{"ranges":"A2:C5","condition":"formula","values":["=$B2>=Lists!$B$1"],"text":"green"}`,77+ `{"ranges":"B2:B5","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`,78+ `{"ranges":"B2:B5","scale":[{"type":"num","value":"0","color":"cyan"},{"type":"percent","value":"90","color":"blue"}]}`,79+ }80+ validations := []string{81+ `{"ranges":"D2:D5","criteria":"list","items":["Yes","No"],"reject":true,"help":"Pick one"}`,82+ `{"ranges":"E2:E5","criteria":"range","source":"Lists!A1:A5"}`,83+ `{"ranges":"F2:F5","criteria":"range","source":"A2:A5","reject":true}`,84+ `{"ranges":"G2:G5","criteria":"checkbox"}`,85+ `{"ranges":"H2:H5","criteria":"number","condition":"between","values":["1","10"],"reject":true}`,86+ `{"ranges":"I2:I5","criteria":"date","condition":"gt","values":["2026-01-31"]}`,87+ `{"ranges":"J2:J5","criteria":"date"}`,88+ `{"ranges":"K2:K5","criteria":"length","condition":"le","values":["=$B$2"]}`,89+ `{"ranges":"L2:L5","criteria":"formula","values":["=L2<>A2"]}`,90+ }91+ mustLoad(t, src, formats, validations)92+ formats, validations = formatLines(src), validationLines(src)93+ name, got := exportImport(t, src)94+ if len(got.Notes) > 0 {95+ t.Errorf("notes %q", got.Notes)96+ }97+ back := got.Sheet.Book().Sheet(0)98+ if f := formatLines(back); !reflect.DeepEqual(f, formats) {99+ t.Errorf("conditional formats:\n%s\nwant\n%s", strings.Join(f, "\n"), strings.Join(formats, "\n"))100+ }101+ if v := validationLines(back); !reflect.DeepEqual(v, validations) {102+ t.Errorf("validations:\n%s\nwant\n%s", strings.Join(v, "\n"), strings.Join(validations, "\n"))103+ }104+ // excelize reads what Excel would.105+ x, err := excelize.OpenFile(name)106+ if err != nil {107+ t.Fatal(err)108+ }109+ defer x.Close()110+ // excelize keeps one rule per range, the last written there.111+ cfs, err := x.GetConditionalFormats("Sheet1")112+ if err != nil || len(cfs) != 5 { // by sqref: B2:B5, C2:C5, A2:A5 C2:C5, A2:A5, A2:C5113+ t.Errorf("excelize: %d ranges %v", len(cfs), err)114+ }115+ if r := cfs["B2:B5"]; len(r) != 1 || r[0].Type != "2_color_scale" || r[0].MinType != "num" || r[0].MaxValue != "90" {116+ t.Errorf("excelize B2:B5: %+v", r)117+ }118+ if r := cfs["C2:C5"]; len(r) != 1 || r[0].Type != "cell" || r[0].Criteria != "equal to" || r[0].Value != `"Fees"` {119+ t.Errorf("excelize C2:C5: %+v", r)120+ }121+ dvs, err := x.GetDataValidations("Sheet1")122+ if err != nil || len(dvs) != len(validations) {123+ t.Fatalf("excelize: %d validations %v", len(dvs), err)124+ }125+ if dv := dvs[0]; dv.Type != "list" || dv.Formula1 != `"Yes,No"` || dv.ErrorStyle == nil || *dv.ErrorStyle != "stop" || dv.Prompt == nil || *dv.Prompt != "Pick one" {126+ t.Errorf("excelize list: %+v", dv)127+ }128+ if dv := dvs[1]; dv.Formula1 != "Lists!$A$1:$A$5" {129+ t.Errorf("excelize range: %+v", dv)130+ }131+ // excelize reports colors only for files with a theme part.132+ style, err := x.GetConditionalStyle(0)133+ if err != nil || style.Font == nil || !style.Font.Bold || style.Fill.Pattern != 1 {134+ t.Errorf("excelize dxf 0: %+v %v", style, err)135+ }136+}137+138+// Rules only Excel has, or ones 012 can't write, are left out with a139+// note.140+func TestXLSXRulesLimits(t *testing.T) {141+ s := months(t)142+ mustLoad(t, s, []string{143+ `{"ranges":"A2:A5","condition":"formula","values":["=B2>1#AND#B2<9"],"fill":"red"}`,144+ }, []string{145+ `{"ranges":"D2:D5","criteria":"list","items":["a,b","c"]}`,146+ })147+ _, got := exportImport(t, s)148+ want := []string{149+ "1 conditional format with no Excel equivalent left out, e.g. A2",150+ "1 data validation rule Excel can't hold left out, e.g. D2 (list items with commas or quotes, or over 255 characters, or a formula with no Excel equivalent)",151+ }152+ if res, err := Export(context.Background(), t.TempDir()+"/x.xlsx", XLSX, SnapBook(s), ExportOptions{}); err != nil || !reflect.DeepEqual(res.Notes, want) {153+ t.Errorf("export notes %q %v", res.Notes, err)154+ }155+ if len(got.Sheet.CondFormats())+len(got.Sheet.Validations()) != 0 {156+ t.Error("rules came back")157+ }158+}159+160+// What Excel writes: theme and indexed colors, rules 012 doesn't have,161+// validation in the 2010 extension.162+func TestXLSXRulesImport(t *testing.T) {163+ p := oneSheetParts("", `<row r="1"><c r="A1"><v>5</v></c></row>`)164+ p["xl/styles.xml"] = `<styleSheet ` + mainNS + `><cellXfs count="1"><xf numFmtId="0" fontId="0"/></cellXfs><dxfs count="3">` +165+ `<dxf><font><color rgb="FF9C0006"/></font><fill><patternFill><bgColor rgb="FFFFC7CE"/></patternFill></fill></dxf>` +166+ `<dxf><font><b/><color theme="4"/></font></dxf>` +167+ `<dxf><numFmt numFmtId="164" formatCode="0.0"/><border><left style="thin"/></border></dxf></dxfs></styleSheet>`168+ p["xl/worksheets/sheet1.xml"] = `<worksheet ` + mainNS + ` xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" xmlns:xm="http://schemas.microsoft.com/office/excel/2006/main">` +169+ `<sheetData><row r="1"><c r="A1"><v>5</v></c></row></sheetData>` +170+ `<conditionalFormatting sqref="A1:A10"><cfRule type="dataBar" priority="4"><dataBar><cfvo type="min"/><cfvo type="max"/><color rgb="FF638EC6"/></dataBar></cfRule>` +171+ `<cfRule type="cellIs" dxfId="0" priority="2" operator="lessThan"><formula>10</formula></cfRule>` +172+ `<cfRule type="iconSet" priority="5"><iconSet><cfvo type="percent" val="0"/></iconSet></cfRule></conditionalFormatting>` +173+ `<conditionalFormatting sqref="B1:B10 D1"><cfRule type="expression" dxfId="1" priority="1"><formula>INT(B1)<DATE(2026,1,5)</formula></cfRule>` +174+ `<cfRule type="expression" dxfId="2" priority="3"><formula>B1>0</formula></cfRule>` +175+ `<cfRule type="colorScale" priority="6"><colorScale><cfvo type="min"/><cfvo type="max"/><color theme="0"/><color rgb="FF57BB8A"/></colorScale></cfRule>` +176+ `<cfRule type="timePeriod" dxfId="0" priority="7" timePeriod="lastWeek"><formula>x</formula></cfRule></conditionalFormatting>` +177+ `<dataValidations count="4"><dataValidation type="list" allowBlank="1" showErrorMessage="1" sqref="C1:C9"><formula1>"Yes,No"</formula1></dataValidation>` +178+ `<dataValidation type="whole" operator="greaterThanOrEqual" errorStyle="warning" showErrorMessage="1" error="Positive please" sqref="E1:E9"><formula1>0</formula1></dataValidation>` +179+ `<dataValidation type="time" sqref="F1"><formula1>0.5</formula1></dataValidation>` +180+ `<dataValidation type="list" sqref="G1"><formula1>Choices</formula1></dataValidation></dataValidations>` +181+ `<extLst><ext uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"><x14:dataValidations count="1"><x14:dataValidation type="list" allowBlank="1" showErrorMessage="1">` +182+ `<x14:formula1><xm:f>Lists!$A$1:$A$5</xm:f></x14:formula1><xm:sqref>H1:H9</xm:sqref></x14:dataValidation></x14:dataValidations></ext>` +183+ `<ext uri="{78C0D931-6437-407d-A8EE-F0AAD7539E65}"><x14:conditionalFormattings><x14:conditionalFormatting><x14:cfRule type="dataBar" id="{1}"/><xm:sqref>A1:A10</xm:sqref></x14:conditionalFormatting></x14:conditionalFormattings></ext></extLst></worksheet>`184+ path := writeParts(t, t.TempDir(), "excel.xlsx", p)185+ got, err := Import(context.Background(), path, Options{})186+ if err != nil {187+ t.Fatal(err)188+ }189+ s := got.Sheet190+ wantF := []string{191+ `{"ranges":"B1:B10,D1","condition":"date_before","values":["2026-01-05"],"text":"blue","bold":true}`,192+ `{"ranges":"A1:A10","condition":"lt","values":["10"],"text":"red","fill":"red"}`,193+ `{"ranges":"B1:B10,D1","scale":[{"type":"min","color":"yellow"},{"type":"max","color":"green"}]}`,194+ }195+ if f := formatLines(s); !reflect.DeepEqual(f, wantF) {196+ t.Errorf("conditional formats:\n%s\nwant\n%s", strings.Join(f, "\n"), strings.Join(wantF, "\n"))197+ }198+ wantV := []string{199+ `{"ranges":"C1:C9","criteria":"list","items":["Yes","No"],"reject":true}`,200+ `{"ranges":"E1:E9","criteria":"number","condition":"ge","values":["0"],"help":"Positive please"}`,201+ `{"ranges":"H1:H9","criteria":"range","source":"Lists!A1:A5","reject":true}`,202+ }203+ if v := validationLines(s); !reflect.DeepEqual(v, wantV) {204+ t.Errorf("validations:\n%s\nwant\n%s", strings.Join(v, "\n"), strings.Join(wantV, "\n"))205+ }206+ wantNotes := []string{207+ "5 conditional formats left out: data bars (1), dates in periods other than today, tomorrow and yesterday (1), icon sets (1), in Excel 2010's extension (data bars, icon sets) (1), without a text color, fill or text style 012 draws (1)",208+ "2 data validation rules left out: lists from named ranges or formulas (1), time rules (1)",209+ }210+ if !reflect.DeepEqual(got.Notes, wantNotes) {211+ t.Errorf("notes:\n%q\nwant\n%q", got.Notes, wantNotes)212+ }213+}@@ -0,0 +1,301 @@
1+package fileio2+3+import (4+ "cmp"5+ "fmt"6+ "maps"7+ "regexp"8+ "slices"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/numfmt"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// ruleSkips counts the rules an import left out, by why.17+type ruleSkips struct {18+ formats, validations map[string]int19+}20+21+func (k *ruleSkips) skip(format bool, why string) {22+ m := &k.validations23+ if format {24+ m = &k.formats25+ }26+ if *m == nil {27+ *m = map[string]int{}28+ }29+ (*m)[why]++30+}31+32+// notes says what was left out, e.g. "3 conditional formats left out:33+// data bars (2), icon sets (1)".34+func (k *ruleSkips) notes() []string {35+ var out []string36+ for _, part := range []struct {37+ m map[string]int38+ one, many string39+ }{{k.formats, "conditional format", "conditional formats"}, {k.validations, "data validation rule", "data validation rules"}} {40+ n := 041+ var whys []string42+ for _, why := range slices.Sorted(maps.Keys(part.m)) {43+ n += part.m[why]44+ whys = append(whys, fmt.Sprintf("%s (%d)", why, part.m[why]))45+ }46+ if n > 0 {47+ out = append(out, count(n, part.one, part.many)+" left out: "+strings.Join(whys, ", "))48+ }49+ }50+ return out51+}52+53+// loadRules puts the rules read from a sheet on s, counting those left54+// out.55+func (bk *xlsxBook) loadRules(s *sheet.Sheet, r *xlsxSheetReader) {56+ cfs := slices.Clone(r.cfs)57+ slices.SortStableFunc(cfs, func(a, b xlsxCF) int { return cmp.Compare(a.priority, b.priority) })58+ var fs []sheet.CondFormat59+ for _, c := range cfs {60+ f, why := bk.condFormat(c)61+ if why != "" {62+ bk.skips.skip(true, why)63+ continue64+ }65+ fs = append(fs, f)66+ }67+ for range s.LoadCondFormats(fs) {68+ bk.skips.skip(true, "without a text color, fill or text style 012 draws")69+ }70+ var vs []sheet.Validation71+ for _, d := range r.dvs {72+ v, why := validationOf(d)73+ switch {74+ case why == "any":75+ case why != "":76+ bk.skips.skip(false, why)77+ default:78+ vs = append(vs, v)79+ }80+ }81+ for range s.LoadValidations(vs) {82+ bk.skips.skip(false, "that 012 can't check")83+ }84+ for range r.extRules {85+ bk.skips.skip(true, "in Excel 2010's extension (data bars, icon sets)")86+ }87+}88+89+// sqrefRanges reads an sqref, "A1:A9 C1".90+func sqrefRanges(ref string) ([]sheet.Rect, bool) {91+ var out []sheet.Rect92+ for _, f := range strings.Fields(ref) {93+ r, ok := sheet.ParseRange(strings.ReplaceAll(f, "$", ""))94+ if !ok {95+ return nil, false96+ }97+ out = append(out, r)98+ }99+ return out, len(out) > 0100+}101+102+// cfOps are Excel's comparisons by name.103+var cfOps = func() map[string]sheet.RuleOp {104+ m := map[string]sheet.RuleOp{}105+ for op, name := range cellIsOps {106+ m[name] = op107+ }108+ return m109+}()110+111+// unsupported names the conditional formats 012 has no rule for.112+var unsupported = map[string]string{"dataBar": "data bars", "iconSet": "icon sets", "top10": "top or bottom values",113+ "aboveAverage": "above or below average", "duplicateValues": "duplicate values", "uniqueValues": "unique values",114+ "containsErrors": "errors", "notContainsErrors": "errors"}115+116+// condFormat is an Excel rule as 012's, or why it was left out.117+func (bk *xlsxBook) condFormat(c xlsxCF) (sheet.CondFormat, string) {118+ rs, ok := sqrefRanges(c.sqref)119+ if !ok {120+ return sheet.CondFormat{}, "ranges 012 can't read"121+ }122+ f := sheet.CondFormat{Ranges: rs}123+ if c.dxf >= 0 && c.dxf < len(bk.styles.dxfs) {124+ f.Style = bk.styles.dxfs[c.dxf]125+ }126+ first := func() string {127+ if len(c.formulas) == 0 {128+ return ""129+ }130+ return c.formulas[0]131+ }132+ switch c.typ {133+ case "cellIs":134+ return cellIsRule(f, c)135+ case "containsText", "notContainsText", "beginsWith", "endsWith":136+ f.Op = map[string]sheet.RuleOp{"containsText": sheet.RuleContains, "notContainsText": sheet.RuleNotContains,137+ "beginsWith": sheet.RuleStartsWith, "endsWith": sheet.RuleEndsWith}[c.typ]138+ f.Args[0] = c.text139+ case "containsBlanks":140+ f.Op = sheet.RuleEmpty141+ case "notContainsBlanks":142+ f.Op = sheet.RuleNotEmpty143+ case "timePeriod":144+ if c.period != "today" && c.period != "tomorrow" && c.period != "yesterday" {145+ return f, "dates in periods other than today, tomorrow and yesterday"146+ }147+ f.Op, f.Args[0] = sheet.RuleDateIs, c.period148+ case "expression":149+ if op, day, ok := dateExpression(first()); ok {150+ f.Op, f.Args[0] = op, day151+ break152+ }153+ f.Op, f.Args[0] = sheet.RuleFormula, fromExcelFormula(first())154+ case "colorScale":155+ return colorScaleRule(f, c)156+ default:157+ if why, ok := unsupported[c.typ]; ok {158+ return f, why159+ }160+ return f, c.typ + " rules"161+ }162+ return f, ""163+}164+165+// cellIsRule reads a comparison: equal to text is "Text is exactly".166+func cellIsRule(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {167+ op, ok := cfOps[c.op]168+ if !ok || len(c.formulas) < op.Args() {169+ return f, "comparisons 012 can't read"170+ }171+ f.Op = op172+ for k := range op.Args() {173+ f.Args[k] = fromExcelArg(c.formulas[k], false)174+ }175+ if text, quoted := unquoteExcel(c.formulas[0]); quoted && op == sheet.RuleEqual {176+ f.Op, f.Args[0] = sheet.RuleExactly, text177+ }178+ return f, ""179+}180+181+// colorScaleRule reads a color scale's points and colors.182+func colorScaleRule(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {183+ if len(c.cfvo) < 2 || len(c.cfvo) != len(c.colors) {184+ return f, "color scales 012 can't read"185+ }186+ for k, v := range c.cfvo {187+ kind, ok := sheet.ParsePointKind(map[string]string{"min": "min", "max": "max", "num": "num",188+ "percent": "percent", "percentile": "percentile"}[v[0]])189+ if !ok {190+ return f, "color scale points set by formulas"191+ }192+ f.Scale = append(f.Scale, sheet.ScalePoint{Kind: kind, Value: v[1], Color: c.colors[k].scaleColor()})193+ }194+ return f, ""195+}196+197+// dateRE matches the expressions dates are written with: INT(A1)<DATE(2026,9,30)198+// or INT(A1)=TODAY()+1.199+var dateRE = regexp.MustCompile(`^INT\(\$?[A-Za-z]{1,3}\$?\d+\)([=<>])(?:DATE\((\d+),(\d+),(\d+)\)|TODAY\(\)([+-]1)?)$`)200+201+// dateExpression reads a date test written as xlsxrules.go writes one.202+func dateExpression(fx string) (sheet.RuleOp, string, bool) {203+ m := dateRE.FindStringSubmatch(strings.ReplaceAll(fx, " ", ""))204+ if m == nil {205+ return 0, "", false206+ }207+ op := map[string]sheet.RuleOp{"=": sheet.RuleDateIs, "<": sheet.RuleDateBefore, ">": sheet.RuleDateAfter}[m[1]]208+ if m[2] != "" {209+ y, _ := strconv.Atoi(m[2])210+ mo, _ := strconv.Atoi(m[3])211+ d, _ := strconv.Atoi(m[4])212+ return op, fmt.Sprintf("%d-%02d-%02d", y, mo, d), true213+ }214+ return op, map[string]string{"": "today", "+1": "tomorrow", "-1": "yesterday"}[m[5]], true215+}216+217+// unquoteExcel reads a formula that is only a string, "text".218+func unquoteExcel(fx string) (string, bool) {219+ fx = strings.TrimSpace(fx)220+ if len(fx) < 2 || fx[0] != '"' || fx[len(fx)-1] != '"' || strings.Contains(strings.ReplaceAll(fx[1:len(fx)-1], `""`, ""), `"`) {221+ return "", false222+ }223+ return strings.ReplaceAll(fx[1:len(fx)-1], `""`, `"`), true224+}225+226+// fromExcelArg reads a rule's value: text, a number (a date as a date,227+// for date rules) or a formula.228+func fromExcelArg(fx string, date bool) string {229+ if text, ok := unquoteExcel(fx); ok {230+ return text231+ }232+ n, err := strconv.ParseFloat(strings.TrimSpace(fx), 64)233+ switch {234+ case err != nil:235+ return fromExcelFormula(fx)236+ case date:237+ y, m, d := numfmt.Civil(int64(fromExcelSerial(n, sheet.Format{Kind: sheet.FmtDate})))238+ return fmt.Sprintf("%d-%02d-%02d", y, m, d)239+ }240+ return strings.TrimSpace(fx)241+}242+243+// validKinds are Excel's validation types as 012's kinds.244+var validKinds = map[string]sheet.ValidKind{"whole": sheet.ValidNumber, "decimal": sheet.ValidNumber,245+ "date": sheet.ValidDate, "textLength": sheet.ValidLength, "custom": sheet.ValidFormula}246+247+// validationOf is an Excel rule as 012's, or why it was left out ("any"248+// for a rule that allows anything, which has nothing to keep).249+func validationOf(d xlsxDV) (sheet.Validation, string) {250+ rs, ok := sqrefRanges(d.sqref)251+ if !ok {252+ return sheet.Validation{}, "ranges 012 can't read"253+ }254+ v := sheet.Validation{Ranges: rs, Reject: d.showErr && d.style == "stop", Help: cmp.Or(d.prompt, d.err)}255+ switch k, ok := validKinds[d.typ]; {256+ case d.typ == "list":257+ return listRule(v, d.formulas[0])258+ case d.typ == "none" || d.typ == "":259+ return v, "any"260+ case !ok:261+ return v, d.typ + " rules"262+ case k == sheet.ValidFormula:263+ v.Kind, v.Args[0] = k, fromExcelFormula(d.formulas[0])264+ default:265+ op, ok := cfOps[d.op]266+ if !ok {267+ return v, "comparisons 012 can't read"268+ }269+ v.Kind, v.Op = k, op270+ if k == sheet.ValidDate && op == sheet.RuleGreater && strings.TrimSpace(d.formulas[0]) == "0" {271+ v.Op = sheet.RuleNone // any date272+ break273+ }274+ for i := range op.Args() {275+ v.Args[i] = fromExcelArg(d.formulas[i], k == sheet.ValidDate)276+ }277+ }278+ return v, ""279+}280+281+// listRule reads a list: items in quotes, or a range; TRUE and FALSE282+// alone are a checkbox.283+func listRule(v sheet.Validation, fx string) (sheet.Validation, string) {284+ if text, ok := unquoteExcel(fx); ok {285+ for _, it := range strings.Split(text, ",") {286+ if it = strings.TrimSpace(it); it != "" {287+ v.Items = append(v.Items, it)288+ }289+ }290+ v.Kind = sheet.ValidList291+ if len(v.Items) == 2 && strings.EqualFold(v.Items[0], "TRUE") && strings.EqualFold(v.Items[1], "FALSE") {292+ v.Kind, v.Items = sheet.ValidCheckbox, nil293+ }294+ return v, ""295+ }296+ v.Kind, v.Source = sheet.ValidRange, strings.ReplaceAll(strings.TrimPrefix(strings.TrimSpace(fx), "="), "$", "")297+ if _, _, ok := v.SourceRange(); !ok {298+ return v, "lists from named ranges or formulas"299+ }300+ return v, ""301+}@@ -0,0 +1,297 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "math"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Reading a sheet's conditional formatting and data validation (see13+// xlsxrules.go for how they map): the elements after the rows, and the14+// same rules in Excel 2010's extension (x14:dataValidation, used for15+// lists from other sheets). Colors come in as the nearest named color.16+17+// xlsxColor is a color as SpreadsheetML gives it: ARGB, a theme color,18+// or an index into the legacy palette.19+type xlsxColor struct {20+ rgb string21+ theme int22+ indexed int23+}24+25+func readColor(se xml.StartElement) xlsxColor {26+ c := xlsxColor{theme: intAttr(se, "theme", -1), indexed: intAttr(se, "indexed", -1)}27+ c.rgb, _ = attr(se, "rgb")28+ return c29+}30+31+// officeTheme is the default Office theme's colors by index, and32+// legacyPalette the first entries of the indexed palette.33+var (34+ officeTheme = []uint32{0xFFFFFF, 0x000000, 0xE7E6E6, 0x44546A, 0x4472C4, 0xED7D31, 0xA5A5A5, 0xFFC000, 0x5B9BD5, 0x70AD47}35+ legacyPalette = []uint32{0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF,36+ 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF,37+ 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080}38+)39+40+// value is the color's RGB, if it has one 012 knows.41+func (c xlsxColor) value() (uint32, bool) {42+ switch {43+ case len(c.rgb) >= 6:44+ v, err := strconv.ParseUint(c.rgb[len(c.rgb)-6:], 16, 32)45+ return uint32(v), err == nil46+ case c.theme >= 0 && c.theme < len(officeTheme):47+ return officeTheme[c.theme], true48+ case c.indexed >= 0 && c.indexed < len(legacyPalette):49+ return legacyPalette[c.indexed], true50+ }51+ return 0, false52+}53+54+// named is the named color nearest c: one of 012's own colors exactly,55+// else by hue, where grays (and a color 012 can't read) are none.56+func (c xlsxColor) named() sheet.Color {57+ v, ok := c.value()58+ if !ok {59+ return sheet.ColorNone60+ }61+ hex := strings.ToUpper(strconv.FormatUint(uint64(v)|0xFF000000, 16))62+ for i, rgb := range ruleRGB {63+ for _, own := range rgb {64+ if own == hex {65+ return sheet.Color(i)66+ }67+ }68+ }69+ return hueColor(v)70+}71+72+// hueColor is the named color of v's hue, or none for grays.73+func hueColor(v uint32) sheet.Color {74+ r, g, b := float64(v>>16&0xFF), float64(v>>8&0xFF), float64(v&0xFF)75+ hi, lo := max(r, g, b), min(r, g, b)76+ if hi == 0 || (hi-lo)/hi < 0.05 {77+ return sheet.ColorNone78+ }79+ var h float6480+ switch d := hi - lo; hi {81+ case r:82+ h = math.Mod((g-b)/d, 6)83+ case g:84+ h = (b-r)/d + 285+ default:86+ h = (r-g)/d + 487+ }88+ h = math.Mod(h*60+360, 360)89+ switch {90+ case h < 20 || h >= 330:91+ return sheet.ColorRed92+ case h < 70:93+ return sheet.ColorYellow94+ case h < 165:95+ return sheet.ColorGreen96+ case h < 200:97+ return sheet.ColorCyan98+ case h < 260:99+ return sheet.ColorBlue100+ }101+ return sheet.ColorMagenta102+}103+104+// scaleColor is the named color nearest c for a color scale, whose105+// points need one: grays, white included, take the scale color nearest106+// them.107+func (c xlsxColor) scaleColor() sheet.Color {108+ if n := c.named(); n != sheet.ColorNone {109+ return n110+ }111+ v, _ := c.value()112+ best, dist := sheet.ColorYellow, math.Inf(1)113+ for i := 1; i < sheet.NumColors; i++ {114+ s, _ := strconv.ParseUint(ruleRGB[i][2][2:], 16, 32)115+ dr, dg, db := float64(v>>16&0xFF)-float64(s>>16&0xFF), float64(v>>8&0xFF)-float64(s>>8&0xFF), float64(v&0xFF)-float64(s&0xFF)116+ if d := dr*dr + dg*dg + db*db; d < dist {117+ best, dist = sheet.Color(i), d118+ }119+ }120+ return best121+}122+123+// readDxf reads the rest of a <dxf>: its font's styles and color, and124+// its fill's color (a dxf's solid fill is its pattern's background).125+func readDxf(x *xmlStream) (sheet.RuleStyle, error) {126+ var st sheet.RuleStyle127+ var fg, bg xlsxColor128+ in := ""129+ for depth := x.depth; ; {130+ t, err := x.next()131+ if err != nil {132+ return st, eofAsUnexpected(err)133+ }134+ switch t := t.(type) {135+ case xml.StartElement:136+ if x.depth == depth+1 {137+ in = t.Name.Local138+ }139+ readDxfPart(t, in, &st, &fg, &bg)140+ case xml.EndElement:141+ if x.depth < depth {142+ st.Fill = bg.named()143+ if st.Fill == sheet.ColorNone {144+ st.Fill = fg.named()145+ }146+ return st, nil147+ }148+ }149+ }150+}151+152+// readDxfPart reads an element of a dxf's font or fill.153+func readDxfPart(t xml.StartElement, in string, st *sheet.RuleStyle, fg, bg *xlsxColor) {154+ switch {155+ case in == "font" && t.Name.Local == "b":156+ st.Bold = boolAttr(t, "val", true)157+ case in == "font" && t.Name.Local == "i":158+ st.Italic = boolAttr(t, "val", true)159+ case in == "font" && t.Name.Local == "strike":160+ st.Strikethrough = boolAttr(t, "val", true)161+ case in == "font" && t.Name.Local == "u":162+ st.Underline = attrOr(t, "val", "single") != "none"163+ case in == "font" && t.Name.Local == "color":164+ st.Text = readColor(t).named()165+ case in == "fill" && t.Name.Local == "bgColor":166+ *bg = readColor(t)167+ case in == "fill" && t.Name.Local == "fgColor":168+ *fg = readColor(t)169+ }170+}171+172+// xlsxCF is a <cfRule>, with the sqref of its <conditionalFormatting>.173+type xlsxCF struct {174+ sqref string175+ typ, op, text, period string176+ dxf, priority int177+ formulas []string178+ cfvo [][2]string // type and val179+ colors []xlsxColor180+}181+182+// xlsxDV is a <dataValidation>, of the main part or the extension.183+type xlsxDV struct {184+ typ, op, style, prompt, err, sqref string185+ showErr bool186+ formulas [2]string187+}188+189+// maxRules caps the rules read from one sheet.190+const maxRules = 10000191+192+// readRuleElement reads a <conditionalFormatting> or <dataValidation>193+// (of the main part or the extension) just started, at any depth, into194+// the reader's rules; it reports whether se was one.195+func (r *xlsxSheetReader) readRuleElement(se xml.StartElement) (bool, error) {196+ switch {197+ case len(r.cfs)+len(r.dvs) >= maxRules:198+ return false, nil199+ case se.Name.Local == "conditionalFormatting" && r.x.depth == 2:200+ return true, r.readCondFormatting(se)201+ case se.Name.Local == "dataValidation":202+ return true, r.readValidation(se)203+ case se.Name.Local == "conditionalFormatting":204+ r.extRules++ // Excel 2010's data bars, icon sets and the like205+ return true, r.x.skip()206+ }207+ return false, nil208+}209+210+func (r *xlsxSheetReader) readCondFormatting(se xml.StartElement) error {211+ ref, _ := attr(se, "sqref")212+ var cur *xlsxCF213+ for depth := r.x.depth; ; {214+ t, err := r.x.next()215+ if err != nil {216+ return eofAsUnexpected(err)217+ }218+ switch t := t.(type) {219+ case xml.EndElement:220+ if r.x.depth < depth {221+ return nil222+ }223+ case xml.StartElement:224+ if t.Name.Local == "cfRule" && len(r.cfs) < maxRules {225+ r.cfs = append(r.cfs, xlsxCF{sqref: ref, typ: attrOr(t, "type", ""), op: attrOr(t, "operator", ""),226+ text: attrOr(t, "text", ""), period: attrOr(t, "timePeriod", ""), dxf: intAttr(t, "dxfId", -1),227+ priority: intAttr(t, "priority", len(r.cfs)+1)})228+ cur = &r.cfs[len(r.cfs)-1]229+ } else if err := r.cfPart(t, cur); err != nil {230+ return err231+ }232+ }233+ }234+}235+236+// cfPart reads an element inside a <cfRule>: a formula, or a color237+// scale's point or color. Each rule keeps at most three of each.238+func (r *xlsxSheetReader) cfPart(t xml.StartElement, cur *xlsxCF) error {239+ switch t.Name.Local {240+ case "formula":241+ var err error242+ if r.buf, err = r.x.text(r.buf[:0]); err != nil {243+ return err244+ }245+ if cur != nil && len(cur.formulas) < 3 {246+ cur.formulas = append(cur.formulas, string(r.buf))247+ }248+ case "cfvo":249+ if cur != nil && len(cur.cfvo) < 3 {250+ cur.cfvo = append(cur.cfvo, [2]string{attrOr(t, "type", ""), attrOr(t, "val", "")})251+ }252+ case "color":253+ if cur != nil && len(cur.colors) < 3 {254+ cur.colors = append(cur.colors, readColor(t))255+ }256+ }257+ return nil258+}259+260+func (r *xlsxSheetReader) readValidation(se xml.StartElement) error {261+ dv := xlsxDV{typ: attrOr(se, "type", "none"), op: attrOr(se, "operator", "between"), style: attrOr(se, "errorStyle", "stop"),262+ prompt: attrOr(se, "prompt", ""), err: attrOr(se, "error", ""), sqref: attrOr(se, "sqref", ""),263+ showErr: boolAttr(se, "showErrorMessage", false)}264+ for depth := r.x.depth; ; {265+ t, err := r.x.next()266+ if err != nil {267+ return eofAsUnexpected(err)268+ }269+ switch t := t.(type) {270+ case xml.EndElement:271+ if r.x.depth < depth {272+ r.dvs = append(r.dvs, dv)273+ return nil274+ }275+ case xml.StartElement:276+ k := -1277+ switch t.Name.Local {278+ case "formula1":279+ k = 0280+ case "formula2":281+ k = 1282+ case "sqref":283+ k = 2284+ default:285+ continue286+ }287+ if r.buf, err = r.x.text(r.buf[:0]); err != nil {288+ return err289+ }290+ if k == 2 {291+ dv.sqref = string(r.buf)292+ } else {293+ dv.formulas[k] = string(r.buf)294+ }295+ }296+ }297+}@@ -56,6 +56,12 @@ type xlsxSheetReader struct {
5656 paneRead bool5757 frozenRows, frozenCols int5858 59+ // The rules after the rows, see xlsxrulesread.go, and how many60+ // conditional formats Excel 2010's extension holds.61+ cfs []xlsxCF62+ dvs []xlsxDV63+ extRules int64+5965 protected bool // the sheet has a <sheetProtection sheet="1">, read by readTail6066 6167 inData bool // inside <sheetData>@@ -18,6 +18,7 @@ type xlsxStyles struct {
1818 fonts []xlsxFont1919 xfs []xlsxXf2020 cache map[int]xlsxStyle21+ dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrulesread.go2122 }2223 2324 type xlsxFont struct{ bold, italic, strike, underline bool }@@ -118,8 +119,18 @@ func (s *xlsxStyles) read(p *xlsxPackage, part string) error {
118119 119120 // element reads one element inside a section of the styles part.120121 func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string, cur *xlsxXf) (*xlsxXf, error) {121- full := len(s.numFmts) >= x.g.p.lim.styles || len(s.fonts) >= x.g.p.lim.styles || len(s.xfs) >= x.g.p.lim.styles122+ full := len(s.numFmts) >= x.g.p.lim.styles || len(s.fonts) >= x.g.p.lim.styles || len(s.xfs) >= x.g.p.lim.styles ||123+ len(s.dxfs) >= x.g.p.lim.styles122124 switch {125+ case section == "dxfs" && se.Name.Local == "dxf" && x.depth == 3:126+ if full {127+ return nil, fmt.Errorf("the workbook has more than %d styles: %w", x.g.p.lim.styles, errXLSXLimit)128+ }129+ st, err := readDxf(x)130+ if err != nil {131+ return nil, err132+ }133+ s.dxfs = append(s.dxfs, st)123134 case section == "numFmts" && se.Name.Local == "numFmt" && x.depth == 3:124135 if full {125136 return nil, fmt.Errorf("the workbook has more than %d styles: %w", x.g.p.lim.styles, errXLSXLimit)@@ -59,7 +59,7 @@ type xlsxCustomFilter struct{ op, val string }
5959 const maxFilterValues = excelRows6060 6161 // readTail reads what follows the rows, returning the sheet's62-// autoFilter or nil.62+// autoFilter or nil, and keeping its rules in the reader.6363 func (r *xlsxSheetReader) readTail() (*xlsxAutoFilter, error) {6464 var af *xlsxAutoFilter6565 for {@@ -71,8 +71,14 @@ func (r *xlsxSheetReader) readTail() (*xlsxAutoFilter, error) {
7171 return nil, err7272 }7373 se, ok := t.(xml.StartElement)74+ if !ok {75+ continue76+ }77+ rule, err := r.readRuleElement(se)7478 switch {75- case !ok || r.x.depth != 2:79+ case err != nil:80+ return nil, err81+ case rule || r.x.depth != 2:7682 case se.Name.Local == "sheetProtection":7783 v, _ := attr(se, "sheet")7884 r.protected = v == "1" || v == "true"@@ -34,6 +34,7 @@ type xlsxWriter struct {
3434 renamed map[string]string // the names of sheets written under another, by key3535 dynamic bool // a dynamic array formula was written: see xlsxarray.go3636 spills map[sheet.Addr]sheet.Rect // the sheet being written's Snapshot.Spills37+ rules ruleNotes // rules left out, see xlsxrules.go3738 }3839 3940 // valueCount counts formulas written as values, keeping the first one's@@ -101,6 +102,7 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
101102 w.styledRows(bw, snap, styled, sheet.MaxRows)102103 bw.WriteString(`</sheetData>`)103104 writeAutoFilter(bw, snap)105+ w.writeRules(bw, ws, snap)104106 if len(snap.Notes) > 0 {105107 bw.WriteString(`<legacyDrawing r:id="` + vmlRelID + `"/>`)106108 }@@ -287,6 +289,49 @@ type xlsxStyleTable struct {
287289 xfs []xlsxStyle // index 0 is the default288290 codes []string // custom number formats, from id 164289291 fonts []sheet.Style292+ dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrules.go293+}294+295+// dxf is the index of the differential style for a rule's style.296+func (t *xlsxStyleTable) dxf(st sheet.RuleStyle) int {297+ if i := slices.Index(t.dxfs, st); i >= 0 {298+ return i299+ }300+ t.dxfs = append(t.dxfs, st)301+ return len(t.dxfs) - 1302+}303+304+// dxfsXML writes the differential styles: a font with the text styles305+// and color, and a solid fill. Excel reads a dxf's fill from the306+// pattern's background color and some readers from its foreground, so307+// both are the fill.308+func (t *xlsxStyleTable) dxfsXML() string {309+ if len(t.dxfs) == 0 {310+ return ""311+ }312+ var b strings.Builder313+ fmt.Fprintf(&b, `<dxfs count="%d">`, len(t.dxfs))314+ for _, st := range t.dxfs {315+ b.WriteString(`<dxf><font>`)316+ for _, on := range []struct {317+ set bool318+ tag string319+ }{{st.Bold, `<b/>`}, {st.Italic, `<i/>`}, {st.Strikethrough, `<strike/>`}, {st.Underline, `<u/>`}} {320+ if on.set {321+ b.WriteString(on.tag)322+ }323+ }324+ if st.Text != sheet.ColorNone {325+ fmt.Fprintf(&b, `<color rgb="%s"/>`, ruleRGB[st.Text][0])326+ }327+ b.WriteString(`</font>`)328+ if st.Fill != sheet.ColorNone {329+ c := ruleRGB[st.Fill][1]330+ fmt.Fprintf(&b, `<fill><patternFill patternType="solid"><fgColor rgb="%s"/><bgColor rgb="%s"/></patternFill></fill>`, c, c)331+ }332+ b.WriteString(`</dxf>`)333+ }334+ return b.String() + `</dxfs>`290335 }291336 292337 func newXLSXStyleTable() *xlsxStyleTable {@@ -363,7 +408,7 @@ func (t *xlsxStyleTable) xml() string {
363408 `<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +364409 `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`)365410 fmt.Fprintf(&b, `<cellXfs count="%d">%s</cellXfs>`, len(t.xfs), xfs.String())366- b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`)411+ b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` + t.dxfsXML() + `</styleSheet>`)367412 return b.String()368413 }369414 @@ -55,8 +55,8 @@ type Reader struct {
5555 evalState5656 // arena holds the arrays and LAMBDAs of the formulas being evaluated5757 // (array.go), emptied when the outermost is done; nest counts them.58- arena []any59- nest int58+ arena []any59+ nest int6060 outer []evalState // the states of the formulas nest counts, but the innermost6161 spilled *Array // the array the last formula computed; see Spilled6262 // ranges are the ranges read whole as arrays since Forget, shared by@@ -0,0 +1,403 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/numfmt"10+ "github.com/FelineStateMachine/012/internal/value"11+)12+13+// Conditional formats follow Sheets' Format > Conditional formatting: a14+// rule applies to ranges of the sheet and either formats the cells that15+// meet a test in a single color (a text color, a fill, text styles) or16+// colors every number along a color scale from its lowest to its17+// highest. Rules are tried in order and the first that applies to a cell18+// formats it; see looks.go for how they're evaluated.19+20+// CondFormat is a conditional format rule.21+type CondFormat struct {22+ Ranges []Rect23+ // Op is the test of a single-color rule and Args its values, as24+ // typed: "100", "=B2" (a formula, relative to the first range's25+ // top-left cell as a copied formula would be), a date, or for26+ // RuleFormula the formula, e.g. "=$C2>100".27+ Op RuleOp28+ Args [2]string29+ Style RuleStyle30+ // Scale, when set, makes the rule a color scale of 2 or 3 points,31+ // lowest first, and Op, Args and Style mean nothing.32+ Scale []ScalePoint33+}34+35+// ScalePoint is a point of a color scale: where it is among the values,36+// and its color.37+type ScalePoint struct {38+ Kind PointKind39+ Value string // the number, percent or percentile; unused by min and max40+ Color Color41+}42+43+// PointKind is how a color scale point is placed.44+type PointKind uint845+46+const (47+ PointMin PointKind = iota // the lowest value48+ PointMax // the highest value49+ PointNumber // a number50+ PointPercent // a percentage of the way from lowest to highest51+ PointPercentile // a percentile of the values52+ numPointKinds53+)54+55+var pointNames = [numPointKinds]string{"min", "max", "num", "percent", "percentile"}56+var pointTitles = [numPointKinds]string{"Min value", "Max value", "Number", "Percent", "Percentile"}57+58+func (k PointKind) String() string {59+ if k >= numPointKinds {60+ return ""61+ }62+ return pointNames[k]63+}64+65+// Title names the kind for people, e.g. "Percentile".66+func (k PointKind) Title() string {67+ if k >= numPointKinds {68+ return ""69+ }70+ return pointTitles[k]71+}72+73+// TakesValue reports whether the point needs a value.74+func (k PointKind) TakesValue() bool { return k >= PointNumber }75+76+// ParsePointKind is the inverse of PointKind.String.77+func ParsePointKind(s string) (PointKind, bool) {78+ i := slices.Index(pointNames[:], s)79+ return PointKind(max(i, 0)), i >= 080+}81+82+// PointKinds lists the kinds in the order the rules editor offers them.83+func PointKinds() []PointKind {84+ return []PointKind{PointMin, PointMax, PointNumber, PointPercent, PointPercentile}85+}86+87+// IsScale reports whether the rule is a color scale.88+func (f CondFormat) IsScale() bool { return len(f.Scale) > 0 }89+90+// Summary describes the rule in a few words, e.g. "Greater than 100".91+func (f CondFormat) Summary() string {92+ if f.IsScale() {93+ return "Color scale"94+ }95+ switch f.Op.Args() {96+ case 1:97+ return f.Op.Title() + " " + f.Args[0]98+ case 2:99+ return f.Op.Title() + " " + f.Args[0] + " and " + f.Args[1]100+ }101+ return f.Op.Title()102+}103+104+// clone copies the rule's slices, so a stored rule is never shared.105+func (f CondFormat) clone() CondFormat {106+ f.Ranges, f.Scale = slices.Clone(f.Ranges), slices.Clone(f.Scale)107+ return f108+}109+110+// Check reports why a rule can't be used, in words for the rules editor.111+func (f CondFormat) Check() error {112+ if len(f.Ranges) == 0 {113+ return errors.New("Enter the range the rule applies to, e.g. A2:A100")114+ }115+ if f.IsScale() {116+ return checkScale(f.Scale)117+ }118+ if !slices.Contains(CondFormatOps(), f.Op) {119+ return errors.New("Choose a condition")120+ }121+ for i := range f.Op.Args() {122+ if err := checkArg(f.Op, strings.TrimSpace(f.Args[i])); err != nil {123+ return err124+ }125+ }126+ if f.Style.IsZero() {127+ return errors.New("Choose a text color, a fill or a text style")128+ }129+ return nil130+}131+132+// checkArg checks one value of a test.133+func checkArg(op RuleOp, arg string) error {134+ switch {135+ case arg == "":136+ return errors.New("Enter a value")137+ case op == RuleFormula && !IsFormulaEntry(arg):138+ return errors.New("A custom formula starts with =, e.g. =$C2>100")139+ case IsFormulaEntry(arg):140+ if _, err := Parse(arg); err != nil {141+ return fmt.Errorf("Formula: %w", err)142+ }143+ case op == RuleDateIs || op == RuleDateBefore || op == RuleDateAfter:144+ if _, ok := dateArg(arg); !ok {145+ return errors.New("Enter a date, e.g. 2026-09-30, or today, tomorrow or yesterday")146+ }147+ case op == RuleBetween || op == RuleNotBetween:148+ if _, _, ok := ParseValue(arg); !ok {149+ return errors.New("Enter a number")150+ }151+ }152+ return nil153+}154+155+// checkScale checks a color scale's points: 2 or 3, each with a color156+// and a value where it takes one.157+func checkScale(ps []ScalePoint) error {158+ if len(ps) < 2 || len(ps) > 3 {159+ return errors.New("A color scale has a minimum, a maximum and at most one midpoint")160+ }161+ for _, p := range ps {162+ if p.Kind >= numPointKinds {163+ return errors.New("Unknown kind of point")164+ }165+ if p.Color == ColorNone {166+ return errors.New("Choose a color for every point")167+ }168+ if !p.Kind.TakesValue() {169+ continue170+ }171+ n, _, ok := ParseValue(strings.TrimSpace(p.Value))172+ switch {173+ case !ok:174+ return errors.New("Enter a number for the " + strings.ToLower(p.Kind.Title()))175+ case p.Kind != PointNumber && (n < 0 || n > 100):176+ return errors.New(p.Kind.Title() + " goes from 0 to 100")177+ }178+ }179+ return nil180+}181+182+// dateArg reads a date condition's value: a date as typed, a number of183+// days, or today, tomorrow or yesterday, as Sheets offers them.184+func dateArg(arg string) (float64, bool) {185+ today := float64(int64(numfmt.SerialOf(value.Now())))186+ switch strings.ToLower(strings.TrimSpace(arg)) {187+ case "today":188+ return today, true189+ case "tomorrow":190+ return today + 1, true191+ case "yesterday":192+ return today - 1, true193+ }194+ n, _, ok := ParseValue(strings.TrimSpace(arg))195+ return float64(int64(n)), ok196+}197+198+// CondFormats returns the sheet's conditional format rules, in the order199+// they're tried.200+func (s *Sheet) CondFormats() []CondFormat {201+ out := make([]CondFormat, len(s.rules.formats))202+ for i, f := range s.rules.formats {203+ out[i] = f.clone()204+ }205+ return out206+}207+208+// AddCondFormat adds a rule after the others, as one undo step.209+func (s *Sheet) AddCondFormat(f CondFormat) error {210+ if err := f.Check(); err != nil {211+ return err212+ }213+ r := s.rules214+ r.formats = append(slices.Clip(r.formats), f.clone())215+ s.setRules("add conditional format "+rangesText(f.Ranges), f.Ranges[0], r)216+ return nil217+}218+219+// SetCondFormat replaces rule i, as one undo step.220+func (s *Sheet) SetCondFormat(i int, f CondFormat) error {221+ if i < 0 || i >= len(s.rules.formats) {222+ return errors.New("That rule was removed")223+ }224+ if err := f.Check(); err != nil {225+ return err226+ }227+ r := s.rules228+ r.formats = slices.Clone(r.formats)229+ r.formats[i] = f.clone()230+ s.setRules("edit conditional format "+rangesText(f.Ranges), f.Ranges[0], r)231+ return nil232+}233+234+// DeleteCondFormat removes rule i.235+func (s *Sheet) DeleteCondFormat(i int) {236+ if i < 0 || i >= len(s.rules.formats) {237+ return238+ }239+ r := s.rules240+ r.formats = slices.Delete(slices.Clone(r.formats), i, i+1)241+ f := s.rules.formats[i]242+ s.setRules("remove conditional format "+rangesText(f.Ranges), f.Ranges[0], r)243+}244+245+// MoveCondFormat moves rule i to position to, changing which rule wins246+// where several apply.247+func (s *Sheet) MoveCondFormat(i, to int) {248+ n := len(s.rules.formats)249+ if i < 0 || i >= n || to < 0 || to >= n || i == to {250+ return251+ }252+ r := s.rules253+ r.formats = slices.Clone(r.formats)254+ f := r.formats[i]255+ r.formats = slices.Insert(slices.Delete(r.formats, i, i+1), to, f)256+ s.setRules("reorder conditional formats", f.Ranges[0], r)257+}258+259+// ClearCondFormats takes the cells of cr out of every rule, removing260+// rules left with no cells, as Sheets' "Clear formatting rules" for a261+// selection.262+func (s *Sheet) ClearCondFormats(cr Rect) {263+ r := s.rules264+ r.formats = nil265+ for _, f := range s.rules.formats {266+ f.Ranges = subtractAll(f.Ranges, cr)267+ if len(f.Ranges) > 0 {268+ r.formats = append(r.formats, f)269+ }270+ }271+ s.setRules("clear conditional formats from "+cr.String(), cr, r)272+}273+274+// LoadCondFormats adds rules as a loader does: without recording undo.275+// Rules that don't check are left out and counted.276+func (s *Sheet) LoadCondFormats(fs []CondFormat) (skipped int) {277+ for _, f := range fs {278+ if f.Check() != nil {279+ skipped++280+ continue281+ }282+ s.rules.formats = append(s.rules.formats, f.clone())283+ }284+ s.looks.reset()285+ return skipped286+}287+288+// subtractAll removes cut from every range of rs.289+func subtractAll(rs []Rect, cut Rect) []Rect {290+ var out []Rect291+ for _, r := range rs {292+ out = append(out, subtract(r, cut)...)293+ }294+ return out295+}296+297+// subtract is r without the cells of cut: r itself, nothing, or up to298+// four ranges around cut.299+func subtract(r, cut Rect) []Rect {300+ lo := Addr{Col: max(r.From.Col, cut.From.Col), Row: max(r.From.Row, cut.From.Row)}301+ hi := Addr{Col: min(r.To.Col, cut.To.Col), Row: min(r.To.Row, cut.To.Row)}302+ if lo.Col > hi.Col || lo.Row > hi.Row {303+ return []Rect{r}304+ }305+ var out []Rect306+ if r.From.Row < lo.Row { // above307+ out = append(out, Rect{From: r.From, To: Addr{Col: r.To.Col, Row: lo.Row - 1}})308+ }309+ if hi.Row < r.To.Row { // below310+ out = append(out, Rect{From: Addr{Col: r.From.Col, Row: hi.Row + 1}, To: r.To})311+ }312+ if r.From.Col < lo.Col { // left, beside cut313+ out = append(out, Rect{From: Addr{Col: r.From.Col, Row: lo.Row}, To: Addr{Col: lo.Col - 1, Row: hi.Row}})314+ }315+ if hi.Col < r.To.Col { // right, beside cut316+ out = append(out, Rect{From: Addr{Col: hi.Col + 1, Row: lo.Row}, To: Addr{Col: r.To.Col, Row: hi.Row}})317+ }318+ return out319+}320+321+// at is where a point sits among the values of a scale's ranges, lo322+// the lowest and hi the highest; vals, in any order and reordered here,323+// are needed only for a percentile.324+func (p ScalePoint) at(vals []float64, lo, hi float64) float64 {325+ n, _, _ := ParseValue(strings.TrimSpace(p.Value))326+ switch p.Kind {327+ case PointMin:328+ return lo329+ case PointMax:330+ return hi331+ case PointPercent:332+ return lo + (hi-lo)*n/100333+ case PointPercentile:334+ return percentile(vals, n/100)335+ }336+ return n337+}338+339+// percentile interpolates between the values around rank p, as340+// PERCENTILE.INC, finding them by selection rather than sorting.341+func percentile(vals []float64, p float64) float64 {342+ p = min(max(p, 0), 1)343+ x := p * float64(len(vals)-1)344+ i := int(x)345+ v := nth(vals, i)346+ if i >= len(vals)-1 {347+ return v348+ }349+ next := slices.Min(vals[i+1:]) // nth leaves the larger values after i350+ return v + (x-float64(i))*(next-v)351+}352+353+// nth reorders vals so that vals[k] is the value a sort would put there,354+// with no larger value before it and no smaller after, and returns it355+// (quickselect, with the median of three as the pivot).356+func nth(vals []float64, k int) float64 {357+ lo, hi := 0, len(vals)-1358+ for lo < hi {359+ mid := lo + (hi-lo)/2360+ if vals[mid] < vals[lo] {361+ vals[mid], vals[lo] = vals[lo], vals[mid]362+ }363+ if vals[hi] < vals[lo] {364+ vals[hi], vals[lo] = vals[lo], vals[hi]365+ }366+ if vals[hi] < vals[mid] {367+ vals[hi], vals[mid] = vals[mid], vals[hi]368+ }369+ pivot := vals[mid]370+ i, j := lo, hi371+ for i <= j {372+ for vals[i] < pivot {373+ i++374+ }375+ for vals[j] > pivot {376+ j--377+ }378+ if i <= j {379+ vals[i], vals[j] = vals[j], vals[i]380+ i++381+ j--382+ }383+ }384+ switch {385+ case k <= j:386+ hi = j387+ case k >= i:388+ lo = i389+ default:390+ return vals[k]391+ }392+ }393+ return vals[k]394+}395+396+// pointText writes a point as the rules editor shows it, e.g. "Min" or397+// "Percentile 50".398+func (p ScalePoint) String() string {399+ if p.Kind.TakesValue() {400+ return p.Kind.Title() + " " + p.Value401+ }402+ return p.Kind.Title()403+}@@ -67,6 +67,9 @@ type fileSheet struct {
6767 Cells map[string]json.RawMessage `json:"cells"`6868 Charts []fileChart `json:"charts,omitempty"`6969 Pivot *filePivot `json:"pivot,omitempty"` // version 570+ // Rules need no version: see rulefile.go.71+ CondFormats []fileCondFormat `json:"conditionalFormats,omitempty"`72+ Validations []fileValidation `json:"validations,omitempty"`7073 }7174 7275 // Write saves the workbook the sheet belongs to; see Workbook.Write.@@ -220,7 +223,7 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
220223 }221224 222225 // writeObjects writes the fields after the cells: the charts, one per223-// line, and the pivot table's definition.226+// line, the pivot table's definition, and the rules.224227 func (s *Sheet) writeObjects(b *bytes.Buffer, indent string) error {225228 if len(s.charts) > 0 {226229 b.WriteString(",\n" + indent + `"charts": [`)@@ -243,6 +246,7 @@ func (s *Sheet) writeObjects(b *bytes.Buffer, indent string) error {
243246 }244247 fmt.Fprintf(b, ",\n%s\"pivot\": %s", indent, raw)245248 }249+ s.writeRules(b, indent)246250 return nil247251 }248252 @@ -389,5 +393,8 @@ func (s *Sheet) read(f fileSheet, version int) error {
389393 }390394 s.pivot = pivotState{def: p, stale: true}391395 }396+ if err := s.readRules(f.CondFormats, f.Validations); err != nil {397+ return err398+ }392399 return s.readView(f.fileView)393400 }@@ -48,6 +48,9 @@ type step struct {
4848 charts map[*Sheet][]Chart4949 // pivots holds each touched sheet's pivot definition before the step.5050 pivots map[*Sheet]*Pivot51+ // rules holds each touched sheet's conditional formats and data52+ // validation before the step.53+ rules map[*Sheet]rulesState5154 // sheets is the sheet list before the step, when it changed.5255 sheets *sheetList5356 // decimal is the arithmetic setting before the step, when it changed.@@ -64,7 +67,7 @@ type colKey struct {
6467 6568 func newStep(label string, s *Sheet, focus Rect) *step {6669 return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{}, lines: map[lineKey]lineFmt{},67- names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}}70+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{}}6871 }6972 7073 func (st *step) empty() bool {@@ -73,7 +76,7 @@ func (st *step) empty() bool {
7376 7477 // widthOnly reports whether the step changed nothing but column widths.7578 func (st *step) widthOnly() bool {76- return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil && st.decimal == nil &&79+ return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.decimal == nil &&7780 st.macros == nil7881 }7982 @@ -240,6 +243,7 @@ func (w *Workbook) push(st *step) {
240243 // dropUnchanged removes from st what ended the step as it began.241244 func (w *Workbook) dropUnchanged(st *step) {242245 st.dropUnchangedLines()246+ st.dropUnchangedRules()243247 for l, c := range st.cells {244248 if c == nil && l.s.cells.get(l.a) == nil {245249 delete(st.cells, l)@@ -292,6 +296,16 @@ func (st *step) dropUnchangedLines() {
292296 }293297 }294298 299+// dropUnchangedRules removes the sheets' rules that ended the step as300+// they began.301+func (st *step) dropUnchangedRules() {302+ for s, r := range st.rules {303+ if r.equal(s.rules) {304+ delete(st.rules, s)305+ }306+ }307+}308+295309 // sameName reports whether two named ranges (nil for undefined) are the296310 // same.297311 func sameName(a, b *Name) bool {@@ -432,6 +446,11 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
432446 inv.pivots[s] = s.pivot.def433447 s.pivot.def, s.pivot.stale = p, true434448 }449+ for s, r := range st.rules {450+ inv.rules[s] = s.rules451+ s.rules = r452+ s.looks.reset()453+ }435454 if st.decimal != nil {436455 cur := w.decimal437456 inv.decimal, w.decimal = &cur, *st.decimal@@ -447,21 +466,14 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
447466 s.view = *v448467 s.hidden.valid = false449468 }450- changed = append(changed, h.dirty...) // anchors of spills the step touched451- h.dirty = nil452- if w.structural {453- w.structural = false454- w.recalcAll()455- } else {456- w.recalc(changed)457- }469+ w.recalcSwapped(changed)458470 if undo {459471 h.redo = append(h.redo, inv)460472 } else {461473 w.pushUndo(inv)462474 }463475 h.mergeWidths = false464- macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && st.sheets == nil476+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil465477 return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true466478 }467479 @@ -24,6 +24,7 @@ const (
2424 formulaBytes = 256 // a parsed formula's tree and references, plus perFormulaByte per byte of text2525 perFormulaByte = 162626 chartBytes = 12827+ ruleBytes = 256 // a rule with its ranges and values2728 )2829 2930 // keep records c as the before-image of the cell at l and counts it.@@ -40,6 +41,9 @@ func (st *step) size() int64 {
4041 for _, charts := range st.charts {4142 n += entryBytes + int64(len(charts))*chartBytes4243 }44+ for _, r := range st.rules {45+ n += entryBytes + int64(len(r.formats)+len(r.validations))*ruleBytes46+ }4347 if st.sheets != nil {4448 n += int64(len(st.sheets.order)) * 2 * entryBytes4549 }@@ -0,0 +1,368 @@
1+package sheet2+3+import (4+ "math"5+ "slices"6+ "strings"7+ "unicode/utf8"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/functions"11+)12+13+// Look is how a sheet's rules draw a cell: a conditional format's style14+// or place on a color scale, and what its validation rule shows (a15+// checkbox, a dropdown's marker, an invalid entry's mark).16+type Look struct {17+ // Styled is set when a single-color rule applies, with its Style.18+ Styled bool19+ Style RuleStyle20+ // Scaled is set when a color scale colors the cell, Pos of the way21+ // (0 to 1) from From to To.22+ Scaled bool23+ From, To Color24+ Pos float6425+26+ Checkbox bool // a checkbox, checked when Checked27+ Checked bool28+ Dropdown bool // offers a list to pick from29+ Invalid bool // holds what its validation rule doesn't accept30+}31+32+// Looks are worked out per cell as the screen asks for them, and kept33+// until the next recalculation or change of rules: work follows what's34+// drawn, and a color scale reads its range's values once per35+// recalculation, not per frame. The cache is dropped whole when it36+// grows past maxLooks, as scrolling through a long range would make it.37+type looksCache struct {38+ wb *Workbook39+ gen uint64 // the workbook's recalculation when filled40+ valid bool41+ cells map[Addr]Look42+ tests map[int]*ruleTest // compiled conditional formats by index43+ scales map[int]*scaleStats // color scales' points by index44+ lists map[int]map[string]bool // dropdown sources' values by validation index45+ rd *reader46+}47+48+const maxLooks = 1 << 1649+50+func (c *looksCache) reset() { c.valid = false }51+52+// fresh empties the cache if it was filled before the last53+// recalculation or rule change.54+func (c *looksCache) fresh(s *Sheet) {55+ if c.valid && c.wb == s.wb && c.gen == s.wb.gen {56+ return57+ }58+ *c = looksCache{wb: s.wb, gen: s.wb.gen, valid: true, cells: map[Addr]Look{}, tests: map[int]*ruleTest{},59+ scales: map[int]*scaleStats{}, lists: map[int]map[string]bool{}}60+}61+62+// HasRules reports whether the sheet has conditional formats or data63+// validation, so drawing can skip asking for looks when it has none.64+func (s *Sheet) HasRules() bool { return !s.rules.empty() }65+66+// Look returns how the sheet's rules draw the cell at a.67+func (s *Sheet) Look(a Addr) Look {68+ if s.rules.empty() {69+ return Look{}70+ }71+ c := &s.looks72+ c.fresh(s)73+ if l, ok := c.cells[a]; ok {74+ return l75+ }76+ l := c.look(s, a)77+ if len(c.cells) >= maxLooks {78+ clear(c.cells)79+ }80+ c.cells[a] = l81+ return l82+}83+84+func (c *looksCache) look(s *Sheet, a Addr) Look {85+ var l Look86+ v := s.Value(a)87+ for i, f := range s.rules.formats {88+ if !inRanges(f.Ranges, a) {89+ continue90+ }91+ if f.IsScale() {92+ if pos, from, to, ok := c.scale(s, i).place(v); ok {93+ l.Scaled, l.Pos, l.From, l.To = true, pos, from, to94+ break95+ }96+ continue97+ }98+ if c.test(s, i).match(c, s, a, v) {99+ l.Styled, l.Style = true, f.Style100+ break101+ }102+ }103+ if i := s.validationIndex(a); i >= 0 {104+ k := s.rules.validations[i].Kind105+ l.Checkbox, l.Dropdown = k == ValidCheckbox, k.Dropdown()106+ l.Checked = l.Checkbox && v.Kind == Bool && v.Num != 0107+ l.Invalid = !s.cells.get(a).Blank() && !c.check(s, i, a, v, s.DisplayFormat(a))108+ }109+ return l110+}111+112+// reader reads current values for rules' formulas.113+func (c *looksCache) reader(s *Sheet) *reader {114+ if c.rd == nil {115+ c.rd = s.wb.values(s)116+ }117+ return c.rd118+}119+120+// eval evaluates n as if in the cell at a.121+func (c *looksCache) eval(s *Sheet, n Node, a Addr) Value {122+ return functions.EvalAt(n, c.reader(s).lib, a)123+}124+125+// evalFrom evaluates a rule's formula n, written for the cell from, in126+// the cell at a: its relative references move as a copy's would.127+func (c *looksCache) evalFrom(s *Sheet, n Node, from, a Addr) Value {128+ if a != from {129+ n, _ = formula.Rewrite(n, formula.Shift(a.Col-from.Col, a.Row-from.Row))130+ }131+ return c.eval(s, n, a)132+}133+134+func truthy(v Value) bool { return (v.Kind == Bool || v.Kind == Number) && v.Num != 0 }135+136+// ruleTest is a single-color rule ready to run: its values parsed, or137+// kept as formulas to evaluate in each cell.138+type ruleTest struct {139+ op RuleOp140+ from Addr141+ args [2]Node // formulas among the values; nil for constants142+ nums [2]float64143+ isNum [2]bool144+ shared func(Value, string) bool // the filter's test, for the ops filters share with constant values145+}146+147+// filterOps maps the tests conditional formats share with filters.148+var filterOps = map[RuleOp]CondOp{RuleEmpty: CondEmpty, RuleNotEmpty: CondNotEmpty,149+ RuleContains: CondContains, RuleNotContains: CondNotContains, RuleStartsWith: CondStartsWith,150+ RuleEndsWith: CondEndsWith, RuleExactly: CondExactly, RuleGreater: CondGreater,151+ RuleGreaterEq: CondGreaterEq, RuleLess: CondLess, RuleLessEq: CondLessEq, RuleEqual: CondEqual,152+ RuleNotEqual: CondNotEqual}153+154+func (c *looksCache) test(s *Sheet, i int) *ruleTest {155+ if t, ok := c.tests[i]; ok {156+ return t157+ }158+ f := s.rules.formats[i]159+ t := &ruleTest{op: f.Op, from: anchor(f.Ranges)}160+ t.parse(f.Op, f.Args)161+ if cop, ok := filterOps[f.Op]; ok && t.args[0] == nil {162+ t.shared = Condition{Op: cop, Arg: f.Args[0]}.test()163+ }164+ c.tests[i] = t165+ return t166+}167+168+// parse reads the values of op: formulas are parsed, dates and numbers169+// read.170+func (t *ruleTest) parse(op RuleOp, args [2]string) {171+ for k := range op.Args() {172+ arg := strings.TrimSpace(args[k])173+ switch {174+ case IsFormulaEntry(arg):175+ t.args[k], _ = Parse(arg)176+ case op == RuleDateIs || op == RuleDateBefore || op == RuleDateAfter:177+ t.nums[k], t.isNum[k] = dateArg(arg)178+ default:179+ n, _, ok := ParseValue(arg)180+ t.nums[k], t.isNum[k] = n, ok181+ }182+ }183+}184+185+// arg is value k of the test in the cell at a.186+func (t *ruleTest) arg(c *looksCache, s *Sheet, k int, a Addr) Value {187+ if t.args[k] != nil {188+ return c.evalFrom(s, t.args[k], t.from, a)189+ }190+ if t.isNum[k] {191+ return Value{Kind: Number, Num: t.nums[k]}192+ }193+ return Value{}194+}195+196+func (t *ruleTest) match(c *looksCache, s *Sheet, a Addr, v Value) bool {197+ switch {198+ case t.shared != nil:199+ return t.shared(v, s.ShownText(a))200+ case t.op == RuleFormula:201+ return t.args[0] != nil && truthy(c.evalFrom(s, t.args[0], t.from, a))202+ }203+ if cop, ok := filterOps[t.op]; ok { // a shared test with a formula value204+ arg := t.arg(c, s, 0, a)205+ return Condition{Op: cop, Arg: FormatText(arg, Format{})}.test()(v, s.ShownText(a))206+ }207+ if v.Kind != Number {208+ return false209+ }210+ x, lo := v.Num, t.arg(c, s, 0, a)211+ if lo.Kind != Number {212+ return false213+ }214+ switch t.op {215+ case RuleDateIs:216+ return float64(int64(x)) == float64(int64(lo.Num))217+ case RuleDateBefore:218+ return float64(int64(x)) < float64(int64(lo.Num))219+ case RuleDateAfter:220+ return float64(int64(x)) > float64(int64(lo.Num))221+ }222+ hi := t.arg(c, s, 1, a)223+ return hi.Kind == Number && compareNum(t.op, x, lo.Num, hi.Num)224+}225+226+// compareNum compares x with one bound, or two for between.227+func compareNum(op RuleOp, x, lo, hi float64) bool {228+ switch op {229+ case RuleBetween:230+ return x >= min(lo, hi) && x <= max(lo, hi)231+ case RuleNotBetween:232+ return x < min(lo, hi) || x > max(lo, hi)233+ case RuleEqual:234+ return x == lo235+ case RuleNotEqual:236+ return x != lo237+ case RuleGreater:238+ return x > lo239+ case RuleGreaterEq:240+ return x >= lo241+ case RuleLess:242+ return x < lo243+ case RuleLessEq:244+ return x <= lo245+ }246+ return true247+}248+249+// scaleStats is a color scale's points among the values of its ranges.250+type scaleStats struct {251+ ok bool252+ at []float64253+ colors []Color254+}255+256+func (c *looksCache) scale(s *Sheet, i int) *scaleStats {257+ if st, ok := c.scales[i]; ok {258+ return st259+ }260+ f := s.rules.formats[i]261+ // Only a percentile needs the values themselves; the rest need the262+ // lowest and highest.263+ keep := slices.ContainsFunc(f.Scale, func(p ScalePoint) bool { return p.Kind == PointPercentile })264+ lo, hi, n := math.Inf(1), math.Inf(-1), 0265+ var vals []float64266+ for _, r := range f.Ranges {267+ for _, cell := range s.cells.inRange(r) {268+ if cell.Value.Kind != Number {269+ continue270+ }271+ v := cell.Value.Num272+ lo, hi, n = min(lo, v), max(hi, v), n+1273+ if keep {274+ vals = append(vals, v)275+ }276+ }277+ }278+ st := &scaleStats{}279+ if n > 0 {280+ st.ok = true281+ for _, p := range f.Scale {282+ st.at = append(st.at, p.at(vals, lo, hi))283+ st.colors = append(st.colors, p.Color)284+ }285+ }286+ c.scales[i] = st287+ return st288+}289+290+// place is where v falls on the scale: the fraction of the way between291+// the colors of the points around it.292+func (st *scaleStats) place(v Value) (pos float64, from, to Color, ok bool) {293+ if !st.ok || v.Kind != Number {294+ return 0, 0, 0, false295+ }296+ k := 0297+ if len(st.at) == 3 && v.Num > st.at[1] {298+ k = 1299+ }300+ lo, hi := st.at[k], st.at[k+1]301+ switch {302+ case hi <= lo:303+ pos = 0304+ if v.Num >= hi {305+ pos = 1306+ }307+ default:308+ pos = min(max((v.Num-lo)/(hi-lo), 0), 1)309+ }310+ return pos, st.colors[k], st.colors[k+1], true311+}312+313+// check reports whether value v, shown in format f, meets validation314+// rule i in the cell at a. Blanks always do.315+func (c *looksCache) check(s *Sheet, i int, a Addr, v Value, f Format) bool {316+ c.fresh(s)317+ r := s.rules.validations[i]318+ switch r.Kind {319+ case ValidList:320+ shown := strings.TrimSpace(FormatText(v, f))321+ return v.Kind == Empty || slices.ContainsFunc(r.Items, func(it string) bool { return strings.EqualFold(strings.TrimSpace(it), shown) })322+ case ValidRange:323+ return v.Kind == Empty || c.list(s, i)[strings.ToLower(strings.TrimSpace(FormatText(v, f)))]324+ case ValidCheckbox:325+ return v.Kind == Bool || v.Kind == Empty326+ case ValidFormula:327+ n, err := Parse(strings.TrimSpace(r.Args[0]))328+ return err == nil && truthy(c.evalFrom(s, n, anchor(r.Ranges), a))329+ case ValidDate:330+ if v.Kind != Number || f.Kind < FmtDate || f.Kind > FmtDateTime || f.Kind == FmtTime {331+ return false332+ }333+ if r.Op == RuleNone {334+ return true335+ }336+ case ValidNumber:337+ if v.Kind != Number {338+ return false339+ }340+ case ValidLength:341+ v = Value{Kind: Number, Num: float64(utf8.RuneCountInString(FormatText(v, f)))}342+ }343+ return c.bounds(s, r, a, v.Num)344+}345+346+// bounds compares x with a rule's values, evaluated in the cell at a.347+func (c *looksCache) bounds(s *Sheet, r Validation, a Addr, x float64) bool {348+ t := ruleTest{op: r.Op, from: anchor(r.Ranges)}349+ t.parse(r.Op, r.Args)350+ lo, hi := t.arg(c, s, 0, a), t.arg(c, s, 1, a)351+ if lo.Kind != Number || r.Op.Args() == 2 && hi.Kind != Number {352+ return false353+ }354+ return compareNum(r.Op, x, lo.Num, hi.Num)355+}356+357+// list is the values of a dropdown's source, by lower case.358+func (c *looksCache) list(s *Sheet, i int) map[string]bool {359+ if l, ok := c.lists[i]; ok {360+ return l361+ }362+ l := map[string]bool{}363+ for _, it := range s.sourceItems(s.rules.validations[i]) {364+ l[strings.ToLower(it)] = true365+ }366+ c.lists[i] = l367+ return l368+}@@ -146,6 +146,7 @@ func (s *Sheet) restructure(rows bool, sp formula.Span) {
146146 s.shiftLines(rows, sp)147147 s.shiftView(rows, sp)148148 s.shiftPivots(rows, sp)149+ s.shiftRules(rows, sp)149150 })150151 }151152 @@ -26,6 +26,7 @@ func (w *Workbook) RecalcAll() {
2626 // propagate: tracing dependents from each cell cost O(cells x range2727 // users).2828 func (w *Workbook) recalcAll() {29+ w.gen++2930 start := w.recalcStart()3031 n := 03132 for _, s := range w.sheets {@@ -57,6 +58,7 @@ func (w *Workbook) recalc(changed []loc) { w.recalcFrom(changed, true) }
5758 // transitively depends on them, and the volatile formulas with theirs5859 // when volatiles is set.5960 func (w *Workbook) recalcFrom(changed []loc, volatiles bool) {61+ w.gen++6062 start := w.recalcStart()6163 n := w.affected(changed, volatiles)6264 stale := w.stalePivots()@@ -0,0 +1,226 @@
1+package sheet2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "strings"8+)9+10+// Rules in the file are two optional fields after a sheet's cells and11+// charts, one rule per line, which older builds ignore (losing the12+// rules, as they lose charts), so they need no version:13+//14+// "conditionalFormats": [15+// {"ranges": "B2:B20", "condition": "gt", "values": ["100"], "fill": "green", "bold": true},16+// {"ranges": "C2:C20", "scale": [{"type": "min", "color": "red"}, {"type": "max", "color": "green"}]}17+// ],18+// "validations": [19+// {"ranges": "D2:D20", "criteria": "list", "items": ["Yes", "No"], "reject": true}20+// ]21+//22+// The same lines are the answers of the commands that add rules, so a23+// recorded macro adds a rule as the file would.24+25+type fileCondFormat struct {26+ Ranges string `json:"ranges"`27+ Condition string `json:"condition,omitempty"`28+ Values []string `json:"values,omitempty"`29+ Text string `json:"text,omitempty"`30+ Fill string `json:"fill,omitempty"`31+ Bold bool `json:"bold,omitempty"`32+ Italic bool `json:"italic,omitempty"`33+ Underline bool `json:"underline,omitempty"`34+ Strike bool `json:"strikethrough,omitempty"`35+ Scale []fileScaled `json:"scale,omitempty"`36+}37+38+type fileScaled struct {39+ Type string `json:"type"`40+ Value string `json:"value,omitempty"`41+ Color string `json:"color"`42+}43+44+type fileValidation struct {45+ Ranges string `json:"ranges"`46+ Criteria string `json:"criteria"`47+ Condition string `json:"condition,omitempty"`48+ Values []string `json:"values,omitempty"`49+ Items []string `json:"items,omitempty"`50+ Source string `json:"source,omitempty"`51+ Reject bool `json:"reject,omitempty"`52+ Help string `json:"help,omitempty"`53+}54+55+// values lists the first n of args, as files keep them.56+func values(args [2]string, n int) []string {57+ if n == 0 {58+ return nil59+ }60+ return args[:n:n]61+}62+63+// JSON writes the rule as a line of the file, e.g.64+// {"ranges":"B2:B20","condition":"gt","values":["100"],"fill":"green"}.65+func (f CondFormat) JSON() string {66+ fc := fileCondFormat{Ranges: rangesText(f.Ranges)}67+ if f.IsScale() {68+ for _, p := range f.Scale {69+ sp := fileScaled{Type: p.Kind.String(), Color: p.Color.String()}70+ if p.Kind.TakesValue() {71+ sp.Value = p.Value72+ }73+ fc.Scale = append(fc.Scale, sp)74+ }75+ } else {76+ st := f.Style77+ fc.Condition, fc.Values = f.Op.String(), values(f.Args, f.Op.Args())78+ fc.Text, fc.Fill = st.Text.String(), st.Fill.String()79+ fc.Bold, fc.Italic, fc.Underline, fc.Strike = st.Bold, st.Italic, st.Underline, st.Strikethrough80+ }81+ raw, _ := json.Marshal(fc)82+ return string(raw)83+}84+85+// ParseCondFormat reads a rule written by JSON, checking it.86+func ParseCondFormat(line string) (CondFormat, error) {87+ var fc fileCondFormat88+ if err := json.Unmarshal([]byte(line), &fc); err != nil {89+ return CondFormat{}, err90+ }91+ f, err := fc.rule()92+ if err != nil {93+ return CondFormat{}, err94+ }95+ return f, f.Check()96+}97+98+func (fc fileCondFormat) rule() (CondFormat, error) {99+ rs, ok := ParseRanges(fc.Ranges)100+ if !ok {101+ return CondFormat{}, fmt.Errorf("invalid ranges %q", fc.Ranges)102+ }103+ f := CondFormat{Ranges: rs}104+ for _, sp := range fc.Scale {105+ k, ok := ParsePointKind(sp.Type)106+ c, cok := ParseColor(sp.Color)107+ if !ok || !cok {108+ return CondFormat{}, fmt.Errorf("invalid color scale point %q %q", sp.Type, sp.Color)109+ }110+ f.Scale = append(f.Scale, ScalePoint{Kind: k, Value: sp.Value, Color: c})111+ }112+ if f.IsScale() {113+ return f, nil114+ }115+ op, ok := ParseRuleOp(fc.Condition)116+ text, tok := ParseColor(fc.Text)117+ fill, fok := ParseColor(fc.Fill)118+ if !ok || !tok || !fok || len(fc.Values) > 2 {119+ return CondFormat{}, fmt.Errorf("invalid rule %q", fc.Condition)120+ }121+ f.Op = op122+ copy(f.Args[:], fc.Values)123+ f.Style = RuleStyle{Text: text, Fill: fill, Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strike}124+ return f, nil125+}126+127+// JSON writes the rule as a line of the file, e.g.128+// {"ranges":"D2:D20","criteria":"list","items":["Yes","No"]}.129+func (v Validation) JSON() string {130+ fv := fileValidation{Ranges: rangesText(v.Ranges), Criteria: v.Kind.String(), Reject: v.Reject, Help: v.Help}131+ switch {132+ case v.Kind == ValidList:133+ fv.Items = v.Items134+ case v.Kind == ValidRange:135+ fv.Source = v.Source136+ case v.Kind == ValidFormula:137+ fv.Values = values(v.Args, 1)138+ case v.Kind.Compares():139+ fv.Condition, fv.Values = v.Op.String(), values(v.Args, v.Op.Args())140+ }141+ raw, _ := json.Marshal(fv)142+ return string(raw)143+}144+145+// ParseValidation reads a rule written by JSON, checking it.146+func ParseValidation(line string) (Validation, error) {147+ var fv fileValidation148+ if err := json.Unmarshal([]byte(line), &fv); err != nil {149+ return Validation{}, err150+ }151+ v, err := fv.rule()152+ if err != nil {153+ return Validation{}, err154+ }155+ return v, v.Check()156+}157+158+func (fv fileValidation) rule() (Validation, error) {159+ rs, ok := ParseRanges(fv.Ranges)160+ if !ok {161+ return Validation{}, fmt.Errorf("invalid ranges %q", fv.Ranges)162+ }163+ k, ok := ParseValidKind(fv.Criteria)164+ op, opOK := ParseRuleOp(fv.Condition)165+ if !ok || !opOK || len(fv.Values) > 2 {166+ return Validation{}, fmt.Errorf("invalid validation %q %q", fv.Criteria, fv.Condition)167+ }168+ v := Validation{Ranges: rs, Kind: k, Op: op, Items: fv.Items, Source: fv.Source, Reject: fv.Reject, Help: fv.Help}169+ copy(v.Args[:], fv.Values)170+ return v, nil171+}172+173+// writeRules writes the sheet's rules, one per line, after its other174+// fields.175+func (s *Sheet) writeRules(b *bytes.Buffer, indent string) {176+ list := func(key string, lines []string) {177+ if len(lines) == 0 {178+ return179+ }180+ fmt.Fprintf(b, ",\n%s%q: [", indent, key)181+ for i, l := range lines {182+ if i > 0 {183+ b.WriteByte(',')184+ }185+ fmt.Fprintf(b, "\n%s %s", indent, l)186+ }187+ b.WriteString("\n" + indent + "]")188+ }189+ var fs, vs []string190+ for _, f := range s.rules.formats {191+ fs = append(fs, f.JSON())192+ }193+ for _, v := range s.rules.validations {194+ vs = append(vs, v.JSON())195+ }196+ list("conditionalFormats", fs)197+ list("validations", vs)198+}199+200+// readRules restores the sheet's rules from a file. A rule that doesn't201+// make sense (a later build's criteria, say) is an error, as a bad cell202+// is.203+func (s *Sheet) readRules(fcs []fileCondFormat, fvs []fileValidation) error {204+ for i, fc := range fcs {205+ f, err := fc.rule()206+ if err == nil {207+ err = f.Check()208+ }209+ if err != nil {210+ return fmt.Errorf("conditional format %d: %s", i+1, strings.TrimSuffix(err.Error(), "."))211+ }212+ s.rules.formats = append(s.rules.formats, f)213+ }214+ for i, fv := range fvs {215+ v, err := fv.rule()216+ if err == nil {217+ err = v.Check()218+ }219+ if err != nil {220+ return fmt.Errorf("data validation %d: %s", i+1, strings.TrimSuffix(err.Error(), "."))221+ }222+ s.rules.validations = append(s.rules.validations, v)223+ }224+ s.looks.reset()225+ return nil226+}@@ -0,0 +1,367 @@
1+package sheet2+3+import (4+ "reflect"5+ "slices"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// Rules are what a sheet says about ranges of its cells beyond their own12+// formatting: conditional formats (condfmt.go), which change how cells13+// look by their values, and data validation (validation.go), which says14+// what may be entered. Both belong to the sheet, as in Sheets: they're15+// saved with it, follow inserted and deleted rows and columns, and every16+// change to them is an undo step. They never write cells.17+18+// Color is one of the named colors rules draw with. Each is an ANSI19+// color slot, so the terminal's palette or the color scheme applies:20+// the UI maps them to theme roles, never to fixed RGB.21+type Color uint822+23+const (24+ ColorNone Color = iota25+ ColorRed26+ ColorYellow27+ ColorGreen28+ ColorCyan29+ ColorBlue30+ ColorMagenta31+ numColors32+)33+34+var colorNames = [numColors]string{"", "red", "yellow", "green", "cyan", "blue", "magenta"}35+36+// NumColors is how many colors there are, ColorNone included, for37+// tables indexed by Color.38+const NumColors = int(numColors)39+40+// Colors lists the named colors, ColorNone first, in the order the rules41+// editor offers them.42+func Colors() []Color {43+ out := make([]Color, numColors)44+ for i := range out {45+ out[i] = Color(i)46+ }47+ return out48+}49+50+// String names the color as files store it; "" for none.51+func (c Color) String() string {52+ if c >= numColors {53+ return ""54+ }55+ return colorNames[c]56+}57+58+// Title names the color for people, e.g. "Green", or "None".59+func (c Color) Title() string {60+ if s := c.String(); s != "" {61+ return strings.ToUpper(s[:1]) + s[1:]62+ }63+ return "None"64+}65+66+// ParseColor is the inverse of Color.String, ignoring case.67+func ParseColor(s string) (Color, bool) {68+ i := slices.Index(colorNames[:], strings.ToLower(strings.TrimSpace(s)))69+ return Color(max(i, 0)), i >= 070+}71+72+// RuleStyle is what a conditional format rule does to the cells it73+// matches: a text color, a fill, and text styles added to the cell's own.74+type RuleStyle struct {75+ Text, Fill Color76+ Bold, Italic, Underline, Strikethrough bool77+}78+79+// IsZero reports whether the style changes nothing.80+func (s RuleStyle) IsZero() bool { return s == RuleStyle{} }81+82+// RuleOp is a test on a cell's value, as Sheets' conditional formatting83+// and data validation offer them. Conditional formats use them all;84+// validation uses the comparisons (and RuleNone for "any date").85+type RuleOp uint886+87+const (88+ RuleNone RuleOp = iota89+ RuleEmpty90+ RuleNotEmpty91+ RuleContains92+ RuleNotContains93+ RuleStartsWith94+ RuleEndsWith95+ RuleExactly96+ RuleDateIs97+ RuleDateBefore98+ RuleDateAfter99+ RuleGreater100+ RuleGreaterEq101+ RuleLess102+ RuleLessEq103+ RuleEqual104+ RuleNotEqual105+ RuleBetween106+ RuleNotBetween107+ RuleFormula108+ numRuleOps109+)110+111+var ruleOpNames = [numRuleOps]string{"", "empty", "not_empty", "contains", "not_contains",112+ "starts_with", "ends_with", "exactly", "date_is", "date_before", "date_after",113+ "gt", "ge", "lt", "le", "eq", "ne", "between", "not_between", "formula"}114+115+var ruleOpTitles = [numRuleOps]string{"None", "Is empty", "Is not empty", "Text contains",116+ "Text does not contain", "Text starts with", "Text ends with", "Text is exactly",117+ "Date is", "Date is before", "Date is after",118+ "Greater than", "Greater than or equal to", "Less than", "Less than or equal to",119+ "Is equal to", "Is not equal to", "Is between", "Is not between", "Custom formula is"}120+121+// String names the test as files store it.122+func (op RuleOp) String() string {123+ if op >= numRuleOps {124+ return ""125+ }126+ return ruleOpNames[op]127+}128+129+// Title names the test for people, e.g. "Greater than".130+func (op RuleOp) Title() string {131+ if op >= numRuleOps {132+ return ""133+ }134+ return ruleOpTitles[op]135+}136+137+// Args is how many values the test compares with: 0, 1, or 2 for138+// between.139+func (op RuleOp) Args() int {140+ switch op {141+ case RuleNone, RuleEmpty, RuleNotEmpty:142+ return 0143+ case RuleBetween, RuleNotBetween:144+ return 2145+ }146+ return 1147+}148+149+// ParseRuleOp is the inverse of RuleOp.String.150+func ParseRuleOp(s string) (RuleOp, bool) {151+ i := slices.Index(ruleOpNames[:], s)152+ return RuleOp(max(i, 0)), i >= 0153+}154+155+// CondFormatOps lists the tests a single-color conditional format may156+// use, in the order Sheets' menu shows them.157+func CondFormatOps() []RuleOp {158+ return []RuleOp{RuleEmpty, RuleNotEmpty, RuleContains, RuleNotContains, RuleStartsWith,159+ RuleEndsWith, RuleExactly, RuleDateIs, RuleDateBefore, RuleDateAfter, RuleGreater,160+ RuleGreaterEq, RuleLess, RuleLessEq, RuleEqual, RuleNotEqual, RuleBetween,161+ RuleNotBetween, RuleFormula}162+}163+164+// CompareOps are the comparisons data validation offers for numbers,165+// dates and text lengths.166+func CompareOps() []RuleOp {167+ return []RuleOp{RuleBetween, RuleNotBetween, RuleEqual, RuleNotEqual, RuleGreater,168+ RuleGreaterEq, RuleLess, RuleLessEq}169+}170+171+// rulesState is a sheet's rules. The slices are never changed in place:172+// a change replaces them, so undo steps can keep them as they were.173+type rulesState struct {174+ formats []CondFormat175+ validations []Validation176+}177+178+func (r rulesState) equal(o rulesState) bool {179+ return reflect.DeepEqual(r.formats, o.formats) && reflect.DeepEqual(r.validations, o.validations)180+}181+182+func (r rulesState) empty() bool { return len(r.formats) == 0 && len(r.validations) == 0 }183+184+// recordRules saves the sheet's rules before their first change in the185+// open step, and drops what was worked out from them.186+func (s *Sheet) recordRules() {187+ s.looks.reset()188+ st := s.wb.hist.open189+ if st == nil {190+ return191+ }192+ if _, seen := st.rules[s]; !seen {193+ st.rules[s] = s.rules194+ }195+}196+197+// setRules replaces the sheet's rules as one undo step.198+func (s *Sheet) setRules(label string, focus Rect, r rulesState) {199+ if r.equal(s.rules) {200+ return201+ }202+ s.change(label, focus, func() {203+ s.recordRules()204+ s.rules = r205+ })206+}207+208+// rangesText writes ranges as Sheets lists them, "A1:A9,C1:C9".209+func rangesText(rs []Rect) string {210+ parts := make([]string, len(rs))211+ for i, r := range rs {212+ parts[i] = r.String()213+ }214+ return strings.Join(parts, ",")215+}216+217+// ParseRanges reads ranges written as "A1:A9,C1:C9" (spaces or commas218+// between), as the rules editor takes them.219+func ParseRanges(s string) ([]Rect, bool) {220+ fields := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })221+ if len(fields) == 0 {222+ return nil, false223+ }224+ out := make([]Rect, 0, len(fields))225+ for _, f := range fields {226+ r, ok := ParseRange(strings.ReplaceAll(f, "$", ""))227+ if !ok {228+ return nil, false229+ }230+ out = append(out, r)231+ }232+ return out, true233+}234+235+// RangesText writes ranges as ParseRanges reads them.236+func RangesText(rs []Rect) string { return rangesText(rs) }237+238+// inRanges reports whether a is in one of rs.239+func inRanges(rs []Rect, a Addr) bool {240+ for _, r := range rs {241+ if r.Contains(a) {242+ return true243+ }244+ }245+ return false246+}247+248+// anchor is the top-left cell of the first range, which relative249+// references in a rule's formula are written for.250+func anchor(rs []Rect) Addr {251+ if len(rs) == 0 {252+ return Addr{}253+ }254+ return rs[0].From255+}256+257+// shiftRanges moves ranges with inserted or deleted lines, as a formula's258+// range would move, dropping those deleted entirely.259+func shiftRanges(rs []Rect, rng func(Rect) (Rect, bool)) []Rect {260+ var out []Rect261+ for _, r := range rs {262+ if to, ok := rng(r); ok {263+ out = append(out, to)264+ }265+ }266+ return out267+}268+269+// shiftFormula rewrites a rule's formula (with its "=") for moved cells,270+// or returns it unchanged when it doesn't parse.271+func shiftFormula(src string, rw formula.Rewriter) string {272+ if src == "" {273+ return src274+ }275+ n, err := Parse(src)276+ if err != nil {277+ return src278+ }279+ if out, changed := formula.Rewrite(n, rw); changed {280+ return formula.Text(out)281+ }282+ return src283+}284+285+// shiftRules keeps every sheet's rules in step with rows or columns286+// inserted or deleted on s: the ranges of s's rules move, and formulas287+// and list sources follow the cells they read, on s or on s by name.288+func (s *Sheet) shiftRules(rows bool, sp formula.Span) {289+ cell, rng := formula.AxisMaps(rows, sp)290+ for _, t := range s.wb.sheets {291+ if t.rules.empty() {292+ continue293+ }294+ on := func(name string) bool {295+ if name == "" {296+ return t == s297+ }298+ return formula.SheetKey(name) == formula.SheetKey(s.name)299+ }300+ rw := formula.Relocate(on, cell, rng)301+ next := t.rules.rewritten(func(rs []Rect) []Rect {302+ if t != s {303+ return rs304+ }305+ return shiftRanges(rs, rng)306+ }, rw)307+ if !next.equal(t.rules) {308+ t.recordRules()309+ t.rules = next310+ }311+ }312+}313+314+// renameRules rewrites the formulas and list sources of every rule that315+// name the sheet with key old, to name.316+func (w *Workbook) renameRules(old, name string) {317+ rename := func(sheet string) string {318+ if sheet != "" && formula.SheetKey(sheet) == old {319+ return name320+ }321+ return sheet322+ }323+ rw := formula.Rewriter{324+ Ref: func(n formula.Ref) Node { n.Sheet = rename(n.Sheet); return n },325+ Range: func(n formula.Range) Node { n.Sheet = rename(n.Sheet); return n },326+ }327+ for _, t := range w.sheets {328+ if t.rules.empty() {329+ continue330+ }331+ next := t.rules.rewritten(func(rs []Rect) []Rect { return rs }, rw)332+ if !next.equal(t.rules) {333+ t.recordRules()334+ t.rules = next335+ }336+ }337+}338+339+// rewritten is the rules with their ranges mapped by ranges and their340+// formulas and sources rewritten by rw; rules left with no range go.341+func (r rulesState) rewritten(ranges func([]Rect) []Rect, rw formula.Rewriter) rulesState {342+ var out rulesState343+ for _, f := range r.formats {344+ f.Ranges = ranges(f.Ranges)345+ if len(f.Ranges) == 0 {346+ continue347+ }348+ if f.Op == RuleFormula {349+ f.Args[0] = shiftFormula(f.Args[0], rw)350+ }351+ out.formats = append(out.formats, f)352+ }353+ for _, v := range r.validations {354+ v.Ranges = ranges(v.Ranges)355+ if len(v.Ranges) == 0 {356+ continue357+ }358+ switch v.Kind {359+ case ValidFormula:360+ v.Args[0] = shiftFormula(v.Args[0], rw)361+ case ValidRange:362+ v.Source = strings.TrimPrefix(shiftFormula("="+v.Source, rw), "=")363+ }364+ out.validations = append(out.validations, v)365+ }366+ return out367+}@@ -0,0 +1,337 @@
1+package sheet2+3+import (4+ "bytes"5+ "math"6+ "math/rand/v2"7+ "slices"8+ "strings"9+ "testing"10+)11+12+func ranges(s string) []Rect {13+ rs, ok := ParseRanges(s)14+ if !ok {15+ panic(s)16+ }17+ return rs18+}19+20+func mustAdd(t *testing.T, s *Sheet, f CondFormat) {21+ t.Helper()22+ if err := s.AddCondFormat(f); err != nil {23+ t.Fatal(err)24+ }25+}26+27+var green = RuleStyle{Fill: ColorGreen}28+29+func TestCondFormatTests(t *testing.T) {30+ fixedNow(t)31+ s := sheetOf(t, map[string]string{32+ "A1": "150", "A2": "50", "A3": "", "A4": "apple pie", "A5": "Pineapple", "A6": "2026-09-25",33+ "A7": "2026-09-27", "A8": "12", "B1": "=A1*2",34+ })35+ s.SetFormat(rng("A3"), Format{Kind: FmtNumber, Decimals: 2}) // formatted but blank36+ cases := []struct {37+ op RuleOp38+ args [2]string39+ match string // cells of A1:A8 that match40+ }{41+ {RuleGreater, [2]string{"100"}, "A1 A6 A7"},42+ {RuleLessEq, [2]string{"50"}, "A2 A8"},43+ {RuleBetween, [2]string{"60", "10"}, "A2 A8"},44+ {RuleNotBetween, [2]string{"10", "60"}, "A1 A6 A7"},45+ {RuleEmpty, [2]string{}, "A3"},46+ {RuleNotEmpty, [2]string{}, "A1 A2 A4 A5 A6 A7 A8"},47+ {RuleContains, [2]string{"APPLE"}, "A4 A5"},48+ {RuleStartsWith, [2]string{"apple"}, "A4"},49+ {RuleEndsWith, [2]string{"pie"}, "A4"},50+ {RuleExactly, [2]string{"pineapple"}, "A5"},51+ {RuleDateBefore, [2]string{"today"}, "A1 A2 A6 A8"},52+ {RuleDateIs, [2]string{"tomorrow"}, "A7"},53+ {RuleDateAfter, [2]string{"2026-09-26"}, "A7"},54+ {RuleEqual, [2]string{"=A$8"}, "A8"},55+ {RuleFormula, [2]string{"=$A1>=150"}, "A1 A4 A5 A6 A7"}, // text sorts after numbers,56+ {RuleFormula, [2]string{"=B1=300"}, "A1"},57+ }58+ for _, c := range cases {59+ s.rules = rulesState{}60+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A8"), Op: c.op, Args: c.args, Style: green})61+ var got []string62+ for row := range 8 {63+ if s.Look(Addr{Row: row}).Styled {64+ got = append(got, Addr{Row: row}.String())65+ }66+ }67+ if strings.Join(got, " ") != c.match {68+ t.Errorf("%s %v: matched %v, want %s", c.op, c.args, got, c.match)69+ }70+ }71+}72+73+func TestCondFormatOrderAndScale(t *testing.T) {74+ s := sheetOf(t, map[string]string{"A1": "0", "A2": "5", "A3": "10", "A4": "x"})75+ red := RuleStyle{Text: ColorRed, Bold: true}76+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A4"), Op: RuleGreater, Args: [2]string{"7"}, Style: red})77+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A4"), Scale: []ScalePoint{78+ {Kind: PointMin, Color: ColorRed}, {Kind: PointPercentile, Value: "50", Color: ColorYellow}, {Kind: PointMax, Color: ColorGreen}}})79+ if l := s.Look(at("A3")); !l.Styled || l.Style != red || l.Scaled {80+ t.Errorf("the first rule wins: %+v", l)81+ }82+ l := s.Look(at("A2"))83+ if !l.Scaled || l.From != ColorRed || l.To != ColorYellow || l.Pos != 1 {84+ t.Errorf("midpoint: %+v", l)85+ }86+ if l := s.Look(at("A1")); !l.Scaled || l.From != ColorRed || l.Pos != 0 {87+ t.Errorf("min: %+v", l)88+ }89+ if l := s.Look(at("A4")); l.Scaled || l.Styled {90+ t.Errorf("text on a scale: %+v", l)91+ }92+ s.MoveCondFormat(1, 0)93+ if l := s.Look(at("A3")); !l.Scaled || l.Pos != 1 || l.To != ColorGreen {94+ t.Errorf("after moving the scale first: %+v", l)95+ }96+ // The looks follow edits: the scale reads the new values.97+ s.Set(at("A3"), "20")98+ if l := s.Look(at("A2")); l.Pos != 1 || l.To != ColorYellow || math.Abs(s.Look(at("A3")).Pos-1) > 1e-9 {99+ t.Errorf("after an edit: %+v", l)100+ }101+ if l := s.Look(at("A1")); l.From != ColorRed || l.To != ColorYellow {102+ t.Errorf("min segment: %+v", l)103+ }104+}105+106+func TestCondFormatCheck(t *testing.T) {107+ bad := []CondFormat{108+ {Op: RuleGreater, Args: [2]string{"1"}, Style: green},109+ {Ranges: ranges("A1"), Op: RuleGreater, Style: green},110+ {Ranges: ranges("A1"), Op: RuleGreater, Args: [2]string{"1"}},111+ {Ranges: ranges("A1"), Op: RuleFormula, Args: [2]string{"A1>1"}, Style: green},112+ {Ranges: ranges("A1"), Op: RuleFormula, Args: [2]string{"=A1>"}, Style: green},113+ {Ranges: ranges("A1"), Op: RuleDateIs, Args: [2]string{"soon"}, Style: green},114+ {Ranges: ranges("A1"), Scale: []ScalePoint{{Kind: PointMin, Color: ColorRed}}},115+ {Ranges: ranges("A1"), Scale: []ScalePoint{{Kind: PointMin, Color: ColorRed}, {Kind: PointPercent, Value: "150", Color: ColorRed}}},116+ }117+ for _, f := range bad {118+ if f.Check() == nil {119+ t.Errorf("accepted %+v", f)120+ }121+ }122+ s := New()123+ if err := s.AddCondFormat(bad[2]); err == nil || s.CanUndo() {124+ t.Error("a bad rule was added")125+ }126+}127+128+func TestRulesUndoAndFile(t *testing.T) {129+ s := sheetOf(t, map[string]string{"A1": "1"})130+ mustAdd(t, s, CondFormat{Ranges: ranges("A1:A9,C1:C9"), Op: RuleFormula, Args: [2]string{"=$B1>100"}, Style: RuleStyle{Text: ColorBlue, Italic: true}})131+ mustAdd(t, s, CondFormat{Ranges: ranges("B1:B9"), Scale: []ScalePoint{{Kind: PointNumber, Value: "0", Color: ColorRed}, {Kind: PointMax, Color: ColorGreen}}})132+ if err := s.AddValidation(Validation{Ranges: ranges("D1:D9"), Kind: ValidList, Items: []string{"Yes", "No"}, Reject: true, Help: "Pick one"}); err != nil {133+ t.Fatal(err)134+ }135+ if err := s.AddValidation(Validation{Ranges: ranges("E1:E9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}}); err != nil {136+ t.Fatal(err)137+ }138+ if s.Book().UndoLabel() != "add data validation E1:E9" {139+ t.Errorf("label %q", s.Book().UndoLabel())140+ }141+ var buf bytes.Buffer142+ if err := s.Write(&buf); err != nil {143+ t.Fatal(err)144+ }145+ for _, want := range []string{146+ `"conditionalFormats": [`,147+ `{"ranges":"A1:A9,C1:C9","condition":"formula","values":["=$B1\u003e100"],"text":"blue","italic":true}`,148+ `{"ranges":"B1:B9","scale":[{"type":"num","value":"0","color":"red"},{"type":"max","color":"green"}]}`,149+ `{"ranges":"D1:D9","criteria":"list","items":["Yes","No"],"reject":true,"help":"Pick one"}`,150+ `{"ranges":"E1:E9","criteria":"number","condition":"between","values":["1","10"]}`,151+ `"version": 2,`,152+ } {153+ if !strings.Contains(buf.String(), want) {154+ t.Errorf("file lacks %s:\n%s", want, buf.String())155+ }156+ }157+ back, err := Read(bytes.NewReader(buf.Bytes()))158+ if err != nil {159+ t.Fatal(err)160+ }161+ if !back.rules.equal(s.rules) {162+ t.Errorf("round trip:\n%+v\n%+v", back.rules, s.rules)163+ }164+ for range 4 {165+ s.Undo()166+ }167+ if !s.rules.empty() {168+ t.Errorf("undo left %+v", s.rules)169+ }170+ s.Redo()171+ if len(s.CondFormats()) != 1 {172+ t.Errorf("redo: %+v", s.rules)173+ }174+}175+176+func TestRulesFollowInsertsAndRenames(t *testing.T) {177+ w := NewBook()178+ s := w.Sheet(0)179+ lists, _ := w.AddSheet("Lists", 1)180+ mustAdd(t, s, CondFormat{Ranges: ranges("B2:B9"), Op: RuleFormula, Args: [2]string{"=$C2>Lists!$A$1"}, Style: green})181+ if err := s.AddValidation(Validation{Ranges: ranges("D2:D9"), Kind: ValidRange, Source: "Lists!A1:A5"}); err != nil {182+ t.Fatal(err)183+ }184+ s.InsertRows(0, 2)185+ s.InsertCols(0, 1)186+ lists.InsertRows(0, 1)187+ f, v := s.CondFormats()[0], s.Validations()[0]188+ if RangesText(f.Ranges) != "C4:C11" || f.Args[0] != "=$D4>Lists!$A$2" {189+ t.Errorf("format after inserts: %s %s", RangesText(f.Ranges), f.Args[0])190+ }191+ if RangesText(v.Ranges) != "E4:E11" || v.Source != "Lists!A2:A6" {192+ t.Errorf("validation after inserts: %s %s", RangesText(v.Ranges), v.Source)193+ }194+ if err := w.RenameSheet(lists, "Choices"); err != nil {195+ t.Fatal(err)196+ }197+ if f := s.CondFormats()[0]; f.Args[0] != "=$D4>Choices!$A$2" || s.Validations()[0].Source != "Choices!A2:A6" {198+ t.Errorf("after rename: %s %s", f.Args[0], s.Validations()[0].Source)199+ }200+ s.DeleteCols(2, 1)201+ if len(s.CondFormats()) != 0 {202+ t.Error("a rule whose cells were deleted stays")203+ }204+ w.Undo()205+ if len(s.CondFormats()) != 1 {206+ t.Error("undo didn't bring the rule back")207+ }208+}209+210+func TestValidation(t *testing.T) {211+ fixedNow(t)212+ s := sheetOf(t, map[string]string{"A1": "Red", "A2": "Blue", "A3": "red", "A4": "", "A5": "Green", "B1": "Blue"})213+ add := func(v Validation) {214+ t.Helper()215+ if err := s.AddValidation(v); err != nil {216+ t.Fatal(err)217+ }218+ }219+ add(Validation{Ranges: ranges("B1:B9"), Kind: ValidRange, Source: "$A$1:$A$9", Reject: true})220+ add(Validation{Ranges: ranges("C1:C9"), Kind: ValidList, Items: []string{"Yes", "No"}})221+ add(Validation{Ranges: ranges("D1:D9"), Kind: ValidNumber, Op: RuleBetween, Args: [2]string{"1", "10"}})222+ add(Validation{Ranges: ranges("E1:E9"), Kind: ValidDate, Op: RuleGreater, Args: [2]string{"2026-01-01"}})223+ add(Validation{Ranges: ranges("F1:F9"), Kind: ValidLength, Op: RuleLessEq, Args: [2]string{"3"}})224+ add(Validation{Ranges: ranges("G1:G9"), Kind: ValidFormula, Args: [2]string{"=G1<>A1"}})225+ add(Validation{Ranges: ranges("H1:H9"), Kind: ValidCheckbox})226+ if got := strings.Join(s.DropdownItems(at("B2")), ","); got != "Red,Blue,Green" {227+ t.Errorf("items %q", got)228+ }229+ cases := []struct {230+ cell, input string231+ ok bool232+ }{233+ {"B1", "green", true}, {"B1", "Purple", false}, {"C1", "yes", true}, {"C1", "maybe", false},234+ {"D1", "10", true}, {"D1", "11", false}, {"D1", "=2*3", true}, {"D1", "ten", false},235+ {"E1", "2026-03-01", true}, {"E1", "2025-12-31", false}, {"E1", "45000", false},236+ {"F1", "abc", true}, {"F1", "abcd", false}, {"G1", "Red", false}, {"G1", "Pink", true},237+ {"H1", "TRUE", true}, {"H1", "yes", false}, {"Z1", "anything", true}, {"D1", "", true},238+ }239+ for _, c := range cases {240+ err := s.CheckEntry(at(c.cell), c.input)241+ if (err == nil) != c.ok {242+ t.Errorf("%s %q: %v", c.cell, c.input, err)243+ }244+ }245+ err := s.CheckEntry(at("B1"), "Purple")246+ if err == nil || !err.Reject || err.Help != "Input must fall within specified range" {247+ t.Errorf("reject: %+v", err)248+ }249+ if err := s.CheckEntry(at("D1"), "0"); err == nil || err.Reject || err.Help != "Input must be a number between 1 and 10" {250+ t.Errorf("warn: %+v", err)251+ }252+ s.Set(at("D2"), "42")253+ s.Set(at("H2"), "TRUE")254+ if l := s.Look(at("D2")); !l.Invalid {255+ t.Errorf("invalid mark: %+v", l)256+ }257+ if l := s.Look(at("H2")); !l.Checkbox || !l.Checked {258+ t.Errorf("checkbox: %+v", l)259+ }260+ if l := s.Look(at("H3")); !l.Checkbox || l.Checked || l.Invalid {261+ t.Errorf("blank checkbox: %+v", l)262+ }263+ if l := s.Look(at("C3")); !l.Dropdown {264+ t.Errorf("dropdown: %+v", l)265+ }266+ // A rule over cells of another takes them.267+ add(Validation{Ranges: ranges("C5:D6"), Kind: ValidCheckbox})268+ if got := RangesText(s.Validations()[1].Ranges); got != "C1:C4,C7:C9" {269+ t.Errorf("list rule's cells: %s", got)270+ }271+ if got := RangesText(s.Validations()[2].Ranges); got != "D1:D4,D7:D9" {272+ t.Errorf("number rule's cells: %s", got)273+ }274+ s.ClearValidations(rng("A1:XFD1048576"))275+ if len(s.Validations()) != 0 {276+ t.Errorf("clear left %+v", s.Validations())277+ }278+}279+280+func TestRuleJSON(t *testing.T) {281+ lines := []string{282+ `{"ranges":"A1:B2","condition":"between","values":["1","2"],"fill":"yellow","underline":true}`,283+ `{"ranges":"A:A","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`,284+ }285+ for _, l := range lines {286+ f, err := ParseCondFormat(l)287+ if err != nil || f.JSON() != l {288+ t.Errorf("%s: %v %s", l, err, f.JSON())289+ }290+ }291+ v, err := ParseValidation(`{"ranges":"C1:C9","criteria":"date","condition":"lt","values":["2026-12-31"],"reject":true}`)292+ if err != nil || v.Kind != ValidDate || v.Op != RuleLess || !v.Reject {293+ t.Errorf("%+v %v", v, err)294+ }295+ if _, err := ParseValidation(`{"ranges":"C1","criteria":"list"}`); err == nil {296+ t.Error("a list without items")297+ }298+ if _, err := ParseCondFormat(`{"ranges":"C1","condition":"gt","values":["1"],"fill":"mauve"}`); err == nil {299+ t.Error("an unknown color")300+ }301+}302+303+// Percentiles by selection agree with sorting, duplicates and all.304+func TestPercentileBySelection(t *testing.T) {305+ r := rand.New(rand.NewPCG(3, 4))306+ for _, n := range []int{1, 2, 3, 10, 101, 1000} {307+ vals := make([]float64, n)308+ for i := range vals {309+ vals[i] = float64(r.IntN(20))310+ }311+ sorted := slices.Sorted(slices.Values(vals))312+ for _, p := range []float64{0, 0.1, 0.25, 0.5, 0.9, 1} {313+ x := p * float64(n-1)314+ i := int(x)315+ want := sorted[i]316+ if i < n-1 {317+ want += (x - float64(i)) * (sorted[i+1] - sorted[i])318+ }319+ if got := percentile(slices.Clone(vals), p); math.Abs(got-want) > 1e-9 {320+ t.Errorf("n %d p %v: %v, want %v", n, p, got, want)321+ }322+ }323+ }324+}325+326+func TestSubtract(t *testing.T) {327+ got := RangesText(subtract(rng("A1:E5"), rng("B2:C3")))328+ if got != "A1:E1,A4:E5,A2:A3,D2:E3" {329+ t.Errorf("%s", got)330+ }331+ if got := RangesText(subtract(rng("A1:A5"), rng("C1:C5"))); got != "A1:A5" {332+ t.Errorf("disjoint: %s", got)333+ }334+ if got := subtract(rng("B2:B3"), rng("A1:C9")); len(got) != 0 {335+ t.Errorf("covered: %v", got)336+ }337+}@@ -104,6 +104,9 @@ type Sheet struct {
104104 105105 view viewState // frozen panes and the filter, see view.go106106 hidden hiddenCache // rows the filter hides, see filter.go107+108+ rules rulesState // conditional formats and data validation, see rules.go109+ looks looksCache // how the rules draw cells, see looks.go107110 }108111 109112 // New returns an empty worksheet, the only sheet of a new workbook.@@ -288,6 +288,19 @@ func sameArray(x, y *functions.Array) bool {
288288 return true289289 }290290 291+// recalcSwapped recalculates what an undo or redo changed, and the292+// anchors of the spills it touched (markDirty).293+func (w *Workbook) recalcSwapped(changed []loc) {294+ changed = append(changed, w.hist.dirty...)295+ w.hist.dirty = nil296+ if w.structural {297+ w.structural = false298+ w.recalcAll()299+ return300+ }301+ w.recalc(changed)302+}303+291304 // markDirty has the step being made or undone recalculate the cell at292305 // l, without recording it for undo.293306 func (w *Workbook) markDirty(l loc) {@@ -183,3 +183,24 @@ func TestSpillNotes(t *testing.T) {
183183 t.Errorf("A2 after the spill went = %+v", c)184184 }185185 }186+187+// Rules over a spill see its values: a conditional format colors spilled188+// cells and a validation rule marks spilled values it doesn't accept,189+// but neither stops the array from spilling, as rules only ever judge190+// what's typed.191+func TestSpillRules(t *testing.T) {192+ s := sheetOf(t, map[string]string{"A1": "=SEQUENCE(4)"})193+ if err := s.AddCondFormat(CondFormat{Ranges: []Rect{rect("A1:A9")}, Op: RuleGreater, Args: [2]string{"2"}, Style: RuleStyle{Bold: true}}); err != nil {194+ t.Fatal(err)195+ }196+ if err := s.AddValidation(Validation{Ranges: []Rect{rect("A1:A9")}, Kind: ValidNumber, Op: RuleLessEq, Args: [2]string{"3"}, Reject: true}); err != nil {197+ t.Fatal(err)198+ }199+ wantShown(t, s, map[string]string{"A4": "4"})200+ if l := s.Look(at("A3")); !l.Styled || l.Invalid {201+ t.Errorf("A3's look = %+v, want styled and valid", l)202+ }203+ if l := s.Look(at("A4")); !l.Styled || !l.Invalid {204+ t.Errorf("A4's look = %+v, want styled and invalid", l)205+ }206+}@@ -0,0 +1,423 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/functions"11+)12+13+// Data validation follows Sheets' Data > Data validation: a rule on14+// ranges of the sheet says what their cells may hold (an item of a15+// list, TRUE or FALSE for a checkbox, a number or date in bounds, text16+// of a length, or whatever meets a formula) and whether an entry that17+// doesn't is rejected or kept with a warning. Each cell has at most one18+// rule: adding one over cells that had another takes them from it.19+20+// ValidKind is what a validation rule checks.21+type ValidKind uint822+23+const (24+ ValidList ValidKind = iota // one of Items, picked from a dropdown25+ ValidRange // one of the values of Source, picked from a dropdown26+ ValidCheckbox // TRUE or FALSE, drawn as a checkbox27+ ValidNumber // a number meeting Op28+ ValidDate // a date meeting Op; any date with RuleNone29+ ValidLength // text whose length meets Op30+ ValidFormula // anything for which the formula Args[0] is TRUE31+ numValidKinds32+)33+34+var validNames = [numValidKinds]string{"list", "range", "checkbox", "number", "date", "length", "formula"}35+var validTitles = [numValidKinds]string{"Dropdown", "Dropdown (from a range)", "Checkbox", "Number",36+ "Date", "Text length", "Custom formula is"}37+38+func (k ValidKind) String() string {39+ if k >= numValidKinds {40+ return ""41+ }42+ return validNames[k]43+}44+45+// Title names the kind for people, e.g. "Dropdown (from a range)".46+func (k ValidKind) Title() string {47+ if k >= numValidKinds {48+ return ""49+ }50+ return validTitles[k]51+}52+53+// ParseValidKind is the inverse of ValidKind.String.54+func ParseValidKind(s string) (ValidKind, bool) {55+ i := slices.Index(validNames[:], s)56+ return ValidKind(max(i, 0)), i >= 057+}58+59+// ValidKinds lists the kinds in the order the rules editor offers them.60+func ValidKinds() []ValidKind {61+ out := make([]ValidKind, numValidKinds)62+ for i := range out {63+ out[i] = ValidKind(i)64+ }65+ return out66+}67+68+// Compares reports whether the kind compares with Op and Args.69+func (k ValidKind) Compares() bool { return k == ValidNumber || k == ValidDate || k == ValidLength }70+71+// Dropdown reports whether cells of the kind offer a list to pick from.72+func (k ValidKind) Dropdown() bool { return k == ValidList || k == ValidRange }73+74+// Validation is a data validation rule.75+type Validation struct {76+ Ranges []Rect77+ Kind ValidKind78+ // Op compares numbers, dates or lengths with Args, as typed; RuleNone79+ // with ValidDate accepts any date. With ValidFormula, Args[0] is the80+ // formula, relative to the first range's top-left cell.81+ Op RuleOp82+ Args [2]string83+ Items []string // ValidList's items84+ // Source is ValidRange's range, e.g. "A2:A9" or "Lists!A1:A20".85+ Source string86+ // Reject refuses an invalid entry; otherwise it's kept and marked.87+ Reject bool88+ // Help replaces the rule's own help text, shown on the context line.89+ Help string90+}91+92+func (v Validation) clone() Validation {93+ v.Ranges, v.Items = slices.Clone(v.Ranges), slices.Clone(v.Items)94+ return v95+}96+97+// Check reports why a rule can't be used, in words for the rules editor.98+func (v Validation) Check() error {99+ switch {100+ case len(v.Ranges) == 0:101+ return errors.New("Enter the range the rule applies to, e.g. B2:B100")102+ case v.Kind >= numValidKinds:103+ return errors.New("Choose the criteria")104+ case v.Kind == ValidList && len(v.Items) == 0:105+ return errors.New("Enter the items, separated by commas")106+ case v.Kind == ValidRange:107+ if _, _, ok := v.SourceRange(); !ok {108+ return errors.New("Enter the range the items are in, e.g. Lists!A1:A20")109+ }110+ case v.Kind == ValidFormula:111+ return checkArg(RuleFormula, strings.TrimSpace(v.Args[0]))112+ case v.Kind.Compares():113+ return v.checkBounds()114+ }115+ return nil116+}117+118+func (v Validation) checkBounds() error {119+ if v.Op == RuleNone && v.Kind == ValidDate {120+ return nil121+ }122+ if !slices.Contains(CompareOps(), v.Op) {123+ return errors.New("Choose a comparison")124+ }125+ for i := range v.Op.Args() {126+ arg := strings.TrimSpace(v.Args[i])127+ if IsFormulaEntry(arg) {128+ if err := checkArg(RuleFormula, arg); err != nil {129+ return err130+ }131+ continue132+ }133+ if _, _, ok := ParseValue(arg); !ok {134+ what := "a number"135+ if v.Kind == ValidDate {136+ what = "a date, e.g. 2026-09-30"137+ }138+ return errors.New("Enter " + what)139+ }140+ }141+ return nil142+}143+144+// SourceRange is ValidRange's source: its sheet as written ("" for the145+// rule's own) and range.146+func (v Validation) SourceRange() (string, Rect, bool) {147+ sheet, rest := SplitSheet(strings.TrimPrefix(strings.TrimSpace(v.Source), "="))148+ r, ok := ParseRange(strings.ReplaceAll(rest, "$", ""))149+ return sheet, r, ok150+}151+152+// Summary describes the rule in a few words, e.g. "Number between 1 and 10".153+func (v Validation) Summary() string {154+ switch {155+ case v.Kind == ValidList:156+ return "Dropdown: " + strings.Join(v.Items, ", ")157+ case v.Kind == ValidRange:158+ return "Dropdown from " + v.Source159+ case v.Kind == ValidFormula:160+ return "Custom formula " + v.Args[0]161+ case v.Kind.Compares():162+ return v.Kind.Title() + " " + compareText(v.Kind, v.Op, v.Args)163+ }164+ return v.Kind.Title()165+}166+167+// compareText is a comparison in words: "between 1 and 10", "after 1/1/2026".168+func compareText(k ValidKind, op RuleOp, args [2]string) string {169+ if k == ValidDate && op == RuleNone {170+ return "is a valid date"171+ }172+ words := map[RuleOp]string{RuleBetween: "between", RuleNotBetween: "not between",173+ RuleEqual: "equal to", RuleNotEqual: "not equal to", RuleGreater: "greater than",174+ RuleGreaterEq: "greater than or equal to", RuleLess: "less than", RuleLessEq: "less than or equal to"}175+ if k == ValidDate {176+ words[RuleEqual], words[RuleNotEqual] = "on", "not on"177+ words[RuleGreater], words[RuleGreaterEq] = "after", "on or after"178+ words[RuleLess], words[RuleLessEq] = "before", "on or before"179+ }180+ w := words[op]181+ if op.Args() == 2 {182+ return w + " " + args[0] + " and " + args[1]183+ }184+ return w + " " + args[0]185+}186+187+// HelpText is what the context line says about a cell under the rule:188+// the rule's own help text, or Sheets' message for its criteria.189+func (v Validation) HelpText() string {190+ if h := strings.TrimSpace(v.Help); h != "" {191+ return h192+ }193+ switch v.Kind {194+ case ValidList:195+ return "Input must be an item on the specified list"196+ case ValidRange:197+ return "Input must fall within specified range"198+ case ValidCheckbox:199+ return "Input must be TRUE or FALSE"200+ case ValidNumber:201+ return "Input must be a number " + compareText(v.Kind, v.Op, v.Args)202+ case ValidDate:203+ if v.Op == RuleNone {204+ return "Input must be a valid date"205+ }206+ return "Input must be a date " + compareText(v.Kind, v.Op, v.Args)207+ case ValidLength:208+ return "Input must be text whose length is " + compareText(v.Kind, v.Op, v.Args)209+ }210+ return "Input must satisfy the formula " + v.Args[0]211+}212+213+// Validations returns the sheet's validation rules.214+func (s *Sheet) Validations() []Validation {215+ out := make([]Validation, len(s.rules.validations))216+ for i, v := range s.rules.validations {217+ out[i] = v.clone()218+ }219+ return out220+}221+222+// Validation returns the rule of the cell at a, if it has one.223+func (s *Sheet) Validation(a Addr) (Validation, bool) {224+ if i := s.validationIndex(a); i >= 0 {225+ return s.rules.validations[i].clone(), true226+ }227+ return Validation{}, false228+}229+230+func (s *Sheet) validationIndex(a Addr) int {231+ for i, v := range s.rules.validations {232+ if inRanges(v.Ranges, a) {233+ return i234+ }235+ }236+ return -1237+}238+239+// AddValidation adds a rule, taking its cells from the rules they had,240+// as one undo step.241+func (s *Sheet) AddValidation(v Validation) error {242+ return s.putValidation(-1, v, "add data validation "+rangesText(v.Ranges))243+}244+245+// SetValidation replaces rule i, as one undo step.246+func (s *Sheet) SetValidation(i int, v Validation) error {247+ if i < 0 || i >= len(s.rules.validations) {248+ return errors.New("That rule was removed")249+ }250+ return s.putValidation(i, v, "edit data validation "+rangesText(v.Ranges))251+}252+253+// putValidation stores v in place of rule i (-1 adds it), taking its254+// cells from the others.255+func (s *Sheet) putValidation(i int, v Validation, label string) error {256+ if err := v.Check(); err != nil {257+ return err258+ }259+ v = v.clone()260+ r := s.rules261+ r.validations = nil262+ for j, o := range s.rules.validations {263+ if j == i {264+ r.validations = append(r.validations, v)265+ continue266+ }267+ for _, cut := range v.Ranges {268+ o.Ranges = subtractAll(o.Ranges, cut)269+ }270+ if len(o.Ranges) > 0 {271+ r.validations = append(r.validations, o)272+ }273+ }274+ if i < 0 {275+ r.validations = append(r.validations, v)276+ }277+ s.setRules(label, v.Ranges[0], r)278+ return nil279+}280+281+// DeleteValidation removes rule i.282+func (s *Sheet) DeleteValidation(i int) {283+ if i < 0 || i >= len(s.rules.validations) {284+ return285+ }286+ v := s.rules.validations[i]287+ r := s.rules288+ r.validations = slices.Delete(slices.Clone(r.validations), i, i+1)289+ s.setRules("remove data validation "+rangesText(v.Ranges), v.Ranges[0], r)290+}291+292+// ClearValidations takes the cells of cr out of every rule, as Sheets'293+// "Remove rule" does for a selection.294+func (s *Sheet) ClearValidations(cr Rect) {295+ r := s.rules296+ r.validations = nil297+ for _, v := range s.rules.validations {298+ v.Ranges = subtractAll(v.Ranges, cr)299+ if len(v.Ranges) > 0 {300+ r.validations = append(r.validations, v)301+ }302+ }303+ s.setRules("remove data validation from "+cr.String(), cr, r)304+}305+306+// LoadValidations adds rules as a loader does: without recording undo.307+// Rules that don't check are left out and counted.308+func (s *Sheet) LoadValidations(vs []Validation) (skipped int) {309+ for _, v := range vs {310+ if v.Check() != nil {311+ skipped++312+ continue313+ }314+ s.rules.validations = append(s.rules.validations, v.clone())315+ }316+ s.looks.reset()317+ return skipped318+}319+320+// InvalidEntry is an entry a cell's validation doesn't accept.321+type InvalidEntry struct {322+ Addr Addr323+ Help string // what the rule wants, e.g. "Input must be a number between 1 and 10"324+ Reject bool // the rule refuses it; otherwise it may be kept, marked invalid325+}326+327+func (e *InvalidEntry) Error() string {328+ return fmt.Sprintf("Invalid entry in %s: %s", e.Addr, e.Help)329+}330+331+// CheckEntry reports whether the cell at a would accept input: nil when332+// it has no rule or input meets it (blanks always do), or an333+// *InvalidEntry. A formula is checked by the value it computes.334+func (s *Sheet) CheckEntry(a Addr, input string) *InvalidEntry {335+ i := s.validationIndex(a)336+ if i < 0 || input == "" {337+ return nil338+ }339+ v := s.rules.validations[i]340+ val, f := s.entryValue(a, input)341+ if f.IsZero() {342+ f = s.DisplayFormat(a)343+ }344+ if v.Kind == ValidFormula {345+ // The formula reads the entry in its own cell.346+ s.looks.fresh(s)347+ rd := s.wb.lookupOn(s, func(t *Sheet, b Addr) Value {348+ if t == s && b == a {349+ return val350+ }351+ return t.Value(b)352+ })353+ n, err := Parse(strings.TrimSpace(v.Args[0]))354+ if err == nil {355+ from := anchor(v.Ranges)356+ n, _ = formula.Rewrite(n, formula.Shift(a.Col-from.Col, a.Row-from.Row))357+ if truthy(functions.EvalAt(n, rd.lib, a)) {358+ return nil359+ }360+ }361+ } else if s.looks.check(s, i, a, val, f) {362+ return nil363+ }364+ return &InvalidEntry{Addr: a, Help: v.HelpText(), Reject: v.Reject}365+}366+367+// entryValue is what input would compute to at a, and the format it368+// implies, without storing it.369+func (s *Sheet) entryValue(a Addr, input string) (Value, Format) {370+ n, f, err := classify(input)371+ switch {372+ case err != nil:373+ return ErrValue, f374+ case n == nil:375+ return Value{Kind: Text, Str: strings.TrimPrefix(input, "'")}, f376+ }377+ return s.looks.eval(s, n, a), f378+}379+380+// DropdownItems lists what a dropdown cell offers: the rule's items, or381+// the distinct values shown in its range, in order, blanks left out.382+func (s *Sheet) DropdownItems(a Addr) []string {383+ v, ok := s.Validation(a)384+ switch {385+ case !ok:386+ return nil387+ case v.Kind == ValidList:388+ return v.Items389+ case v.Kind == ValidRange:390+ return s.sourceItems(v)391+ }392+ return nil393+}394+395+// maxDropdown caps the items a dropdown from a range lists.396+const maxDropdown = 1000397+398+// sourceItems reads the distinct values shown in a rule's source range.399+func (s *Sheet) sourceItems(v Validation) []string {400+ name, r, ok := v.SourceRange()401+ t := s402+ if name != "" {403+ t = s.wb.resolve(s, name)404+ }405+ if !ok || t == nil {406+ return nil407+ }408+ seen := map[string]bool{}409+ var out []string410+ for a := range t.cells.inRange(r) {411+ text := strings.TrimSpace(t.ShownText(a))412+ key := strings.ToLower(text)413+ if text == "" || seen[key] {414+ continue415+ }416+ seen[key] = true417+ out = append(out, text)418+ if len(out) == maxDropdown {419+ break420+ }421+ }422+ return out423+}@@ -62,6 +62,10 @@ type Workbook struct {
6262 // sheet name may now resolve differently.6363 structural bool6464 65+ // gen counts recalculations, so what is worked out from values66+ // (the looks of rules) is kept until the next one.67+ gen uint6468+6569 // pivotDepth counts pivots refreshed because a pivot they read6670 // changed, to stop a loop; see pivotlayout.go.6771 pivotDepth int@@ -227,6 +231,7 @@ func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {
227231 cp.view = s.view228232 cp.view.filter = s.view.filter.clone()229233 cp.charts = slices.Clone(s.charts)234+ cp.rules = s.rules // never changed in place230235 cp.pivot = pivotState{def: s.pivot.def.clone(), stale: s.pivot.def != nil, out: s.pivot.out, fit: s.pivot.fit}231236 w.change(cp, "duplicate "+s.name, Rect{}, func() {232237 w.recordSheets()@@ -277,6 +282,7 @@ func (w *Workbook) RenameSheet(s *Sheet, name string) error {
277282 w.renameRefs(old, name)278283 }279284 w.renamePivots(old, name)285+ w.renameRules(old, name)280286 })281287 return nil282288 }@@ -49,6 +49,11 @@ type command struct {
4949 // macro says how recording and scripts treat the command; see5050 // macroUse.5151 macro macroUse52+53+ // typed, when set, lets a key bound to the command type itself54+ // where the command isn't available, as Space checks a checkbox and55+ // starts an entry anywhere else.56+ typed bool5257 }5358 5459 // macroUse is how macros treat a command.@@ -229,6 +229,10 @@ func (m *Model) resumeEntry(ref string) {
229229 // in EDIT mode with the caret at the problem and the reason on line 3.230230 func (m *Model) commit() bool {231231 input := m.line.Text()232+ warn, ok := m.checkEntry(input) // data validation, rules.go233+ if !ok {234+ return false235+ }232236 if err := m.set(m.cur, input); err != nil {233237 m.entryError(err, input)234238 return false@@ -236,6 +240,9 @@ func (m *Model) commit() bool {
236240 m.cancelEntry()237241 m.clearSelection()238242 m.recordEntry(input, false)243+ if warn != "" {244+ m.warn = warn245+ }239246 return true240247 }241248 @@ -49,10 +49,21 @@ func (t *terminal) wrap(seq string) string {
4949 return seq5050 }5151 52-// probes are the startup queries: kitty graphics support, and live53-// light and dark changes (mode 2031).52+// probes are the startup queries: kitty graphics support, live light53+// and dark changes (mode 2031), and the 16 palette colors (OSC 4), which54+// chart images and color scales draw with.5455 func (t *terminal) probes() tea.Cmd {55- return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark)56+ return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + paletteQuery())57+}58+59+// paletteQuery asks for the 16 ANSI colors; terminals that don't answer60+// leave xtermColors in use.61+func paletteQuery() string {62+ var q strings.Builder63+ for i := range 16 {64+ fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)65+ }66+ return q.String()5667 }5768 5869 // firstImageID is the image id of the first chart. Placeholders name@@ -69,14 +80,9 @@ func (t *terminal) handle(msg tea.Msg) tea.Cmd {
6980 case uv.KittyGraphicsEvent:7081 if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !t.kitty {7182 t.kitty = true72- // Ask for the cell size (for image proportions) and the73- // palette colors charts draw with.74- var q strings.Builder75- q.WriteString(ansi.WindowOp(16)) // 16: report the cell size in pixels76- for i := range 16 {77- fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)78- }79- return tea.Raw(q.String())83+ // Ask for the cell size, for image proportions; the palette84+ // was asked for at startup.85+ return tea.Raw(ansi.WindowOp(16)) // 16: report the cell size in pixels8086 }8187 case uv.CellSizeEvent:8288 if msg.Width > 0 && msg.Height > 0 {@@ -9,6 +9,7 @@ import (
99 "github.com/FelineStateMachine/012/internal/ui/findbar"1010 "github.com/FelineStateMachine/012/internal/ui/lineedit"1111 "github.com/FelineStateMachine/012/internal/ui/picker"12+ "github.com/FelineStateMachine/012/internal/ui/rules"1213 "github.com/FelineStateMachine/012/internal/ui/theme"1314 "github.com/FelineStateMachine/012/internal/ui/themepicker"1415 )@@ -26,6 +27,7 @@ var (
2627 _ cmdline.Host = host{}2728 _ findbar.Host = host{}2829 _ themepicker.Host = host{}30+ _ rules.Host = host{}2931 )3032 3133 // The in-package hosts the model implements itself.@@ -40,6 +40,8 @@ func spanStyle(th *theme.Theme, base lipgloss.Style, sp rowtext.Span) lipgloss.S
4040 s = s.Inherit(th.Link).Hyperlink(sp.Link)4141 case sp.Error:4242 s = s.Inherit(th.ErrorMark)43+ case sp.Invalid:44+ s = s.Inherit(th.Invalid)4345 }4446 return s4547 }@@ -0,0 +1,156 @@
1+package ui2+3+import (4+ "image/color"5+ "strings"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/rowtext"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// Drawing the sheet's rules (see sheet.Look): a conditional format's16+// fill, text color and text styles, a color scale's shade, checkboxes,17+// the ▾ of a dropdown and the mark of an invalid entry. Only the visible18+// cells are asked for their looks, and the sheet keeps them until the19+// next recalculation, so a rule over thousands of rows costs a frame20+// what the screen shows.21+22+// checkGlyphs are a checkbox's text, unchecked and checked, drawn23+// centered like a boolean. ASCII brackets keep one width in every24+// terminal, where ballot box glyphs don't.25+var checkGlyphs = [2]string{"[ ]", "[✓]"}26+27+// ruleSpan applies the looks of cell a and of the owner of its text to28+// a span: its text styles and a checkbox's glyph, and, where the cell29+// isn't colored already, the shade it's drawn on, if any (shaded).30+func (m *Model) ruleSpan(a sheet.Addr, sp *rowtext.Span, colored bool, rgb func(int) color.Color) (look sheet.Look, shade theme.Shade, shaded bool) {31+ look = m.sheet.Look(a)32+ own := look33+ if sp.Owner != a.Col && sp.Text != "" {34+ own = m.sheet.Look(sheet.Addr{Col: sp.Owner, Row: a.Row})35+ }36+ if look.Checkbox {37+ *sp = checkSpan(look.Checked, m.sheet.ColWidth(a.Col), m.sheet.CellStyle(a))38+ own = look39+ }40+ if own.Styled {41+ st := own.Style42+ sp.Style.Bold = sp.Style.Bold || st.Bold43+ sp.Style.Italic = sp.Style.Italic || st.Italic44+ sp.Style.Underline = sp.Style.Underline || st.Underline45+ sp.Style.Strikethrough = sp.Style.Strikethrough || st.Strikethrough46+ }47+ sp.Invalid = own.Invalid && sp.Text != ""48+ switch {49+ case colored:50+ case look.Scaled:51+ return look, m.th.ScaleFill(look.From, look.To, look.Pos, rgb), true52+ case look.Styled || own.Styled:53+ st := sheet.RuleStyle{}54+ if look.Styled {55+ st.Fill = look.Style.Fill56+ }57+ if own.Styled {58+ st.Text = own.Style.Text59+ }60+ if st.Fill != sheet.ColorNone || st.Text != sheet.ColorNone {61+ return look, m.th.RuleShade(st), true62+ }63+ }64+ return look, theme.Shade{}, false65+}66+67+// checkSpan is a checkbox drawn in a column w wide.68+func checkSpan(on bool, w int, st sheet.Style) rowtext.Span {69+ g := checkGlyphs[0]70+ if on {71+ g = checkGlyphs[1]72+ }73+ if w < 3 {74+ g = ansi.Truncate(g[1:], w, "")75+ }76+ gw := ansi.StringWidth(g)77+ lead := (w - gw) / 278+ st.Underline = false // an underline would run under the brackets79+ return rowtext.Span{Lead: lead, Text: g, Trail: w - gw - lead, Style: st}80+}81+82+// dropdownMark puts the ▾ of a dropdown cell in its last column.83+func (m *Model) dropdownMark(text string, w int, base lipgloss.Style, colored bool) string {84+ if w < 3 {85+ return text86+ }87+ mark := m.th.Dropdown.Render("▾")88+ if colored {89+ mark = base.Render("▾")90+ }91+ return ansi.Truncate(text, w-1, "") + mark92+}93+94+// slotColor is the terminal's color for an ANSI slot, as color scales95+// blend it.96+func (m *Model) slotColor(i int) color.Color { return m.term.color(i) }97+98+// checkboxAt reports where the glyph of the checkbox at a is on screen,99+// as columns from..to of the row, or false if a isn't a checkbox.100+func (m *Model) checkboxAt(a sheet.Addr, start int) (from, to int, ok bool) {101+ if !m.sheet.HasRules() || !m.sheet.Look(a).Checkbox {102+ return 0, 0, false103+ }104+ sp := checkSpan(false, m.sheet.ColWidth(a.Col), sheet.Style{})105+ return start + sp.Lead, start + sp.Lead + ansi.StringWidth(sp.Text) - 1, true106+}107+108+// ruleHit is what a click at x on the cell at a starting at start hits109+// among its rules: a checkbox's glyph or a dropdown's ▾.110+func (m *Model) ruleHit(a sheet.Addr, x, start int) hitKind {111+ if m.mode != modeReady || !m.sheet.HasRules() {112+ return hitCell113+ }114+ if from, to, ok := m.checkboxAt(a, start); ok && x >= from && x <= to {115+ return hitCheckbox116+ }117+ if w := m.sheet.ColWidth(a.Col); w >= 3 && x == start+w-1 && m.sheet.Look(a).Dropdown {118+ return hitDropdown119+ }120+ return hitCell121+}122+123+// validationLine is the context line for the active cell's validation:124+// why its entry is invalid, the rule's help text, or the keys of a125+// checkbox or dropdown.126+func (m *Model) validationLine() string {127+ if !m.sheet.HasRules() {128+ return ""129+ }130+ v, ok := m.sheet.Validation(m.cur)131+ if !ok {132+ return ""133+ }134+ look := m.sheet.Look(m.cur)135+ var keys string136+ switch {137+ case look.Checkbox:138+ keys = m.th.KeyHints("Space", "check or uncheck")139+ case look.Dropdown:140+ keys = m.th.KeyHints(m.shortcut("data.dropdown"), "choose from the list")141+ }142+ var text string143+ switch {144+ case look.Invalid:145+ text = m.th.Warning.Render("Invalid: " + v.HelpText())146+ case strings.TrimSpace(v.Help) != "":147+ text = m.th.Muted.Render(v.Help)148+ }149+ switch {150+ case text == "":151+ return keys152+ case keys == "" || ansi.StringWidth(text)+3+ansi.StringWidth(keys) > m.width:153+ return text154+ }155+ return text + " " + keys156+}@@ -122,6 +122,9 @@ func (h scriptHost) Enter(text string, fill bool, origin string) error {
122122 if fill {123123 return m.sheet.FillEntry(m.selection(), m.cur, text)124124 }125+ if bad := m.sheet.CheckEntry(m.cur, text); bad != nil && bad.Reject {126+ return bad127+ }125128 if err := m.sheet.Set(m.cur, text); err != nil {126129 return err127130 }@@ -69,7 +69,8 @@ var menuBar = []menuDef{
6969 {cmd: "insert.col_left", title: "Column left"}, {cmd: "insert.col_right", title: "Column right"}, sep,7070 {cmd: "insert.selection"}, sep,7171 {cmd: "sheet.new", title: "Sheet"}, sep,72- {cmd: "note.edit"}, {cmd: "insert.chart"},72+ {cmd: "note.edit"}, {cmd: "insert.chart"}, sep,73+ {cmd: "insert.checkbox"}, {cmd: "insert.dropdown"},7374 }},7475 {title: "Format", accel: 'o', items: []menuItem{7576 {title: "Number", items: []menuItem{@@ -83,6 +84,7 @@ var menuBar = []menuDef{
8384 {cmd: "format.bold", title: "Bold"}, {cmd: "format.italic", title: "Italic"}, {cmd: "format.underline", title: "Underline"}, {cmd: "format.strikethrough", title: "Strikethrough"}, sep,8485 {cmd: "format.align_left", title: "Align left"}, {cmd: "format.align_center", title: "Align center"}, {cmd: "format.align_right", title: "Align right"}, sep,8586 {cmd: "column.width"}, {cmd: "column.reset"}, sep,87+ {cmd: "format.conditional"}, {cmd: "format.conditional_clear"}, sep,8688 {cmd: "format.clear", title: "Clear formatting"},8789 }},8890 {title: "Data", accel: 'd', items: []menuItem{@@ -96,6 +98,7 @@ var menuBar = []menuDef{
9698 {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,9799 {cmd: "data.pivot"}, {cmd: "data.pivot_edit"}, {cmd: "data.frequency"}, sep,98100 {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, {cmd: "data.protect"}, sep,101+ {cmd: "data.validation"}, {cmd: "data.validation_clear"}, sep,99102 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,100103 {title: "Macros", items: macroItems}, sep,101104 {cmd: "jev.refresh"},@@ -300,7 +300,7 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
300300 m.showBarMenu(i)301301 return nil302302 }303- if id, ok := keymap[canonicalKey(key)]; ok {303+ if id, ok := keymap[canonicalKey(key)]; ok && (!commands[id].typed || commands[id].available(m)) {304304 return m.runCommand(id)305305 }306306 if cmd, ok := m.runShortcut(key); ok {@@ -49,6 +49,8 @@ const (
4949 hitStatus5050 hitFilterButton // the filter mark in a column header5151 hitFillHandle // the corner of the selection that drags out a fill52+ hitCheckbox // a checkbox's glyph, which a click toggles53+ hitDropdown // a dropdown's ▾, which a click opens5254 hitTab // a sheet's tab on the status line; addr.Col is its index5355 hitTabAdd // the + after the tabs5456 hitTabPrev // the ‹ before tabs scrolled off to the left@@ -111,7 +113,7 @@ func (m *Model) hitTest(x, y int) hit {
111113 if m.mode == modeReady && a == m.fillCorner() && x == start+m.sheet.ColWidth(col)-1 {112114 return hit{kind: hitFillHandle, addr: a}113115 }114- return hit{kind: hitCell, addr: a}116+ return hit{kind: m.ruleHit(a, x, start), addr: a}115117 case y == m.height-1:116118 if h, ok := m.tabAt(x); ok {117119 return h@@ -213,6 +215,13 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
213215 m.startFill()214216 case hitFilterButton:215217 m.openFilterPicker(h.addr.Col)218+ case hitCheckbox, hitDropdown:219+ m.cur = h.addr220+ m.clearSelection()221+ if h.kind == hitCheckbox {222+ return m.runCommand("data.checkbox_toggle")223+ }224+ return m.runCommand("data.dropdown")216225 case hitColBorder:217226 if double {218227 m.autofit(h.addr.Col)@@ -360,7 +369,7 @@ func (m *Model) pointerShape() tea.Cmd {
360369 case m.mouse.hover.kind == hitFormulaBar, m.mouse.hover.kind == hitEditLine:361370 shape = "text"362371 case m.mouse.hover.kind == hitColHeader, m.mouse.hover.kind == hitRowHeader, m.mouse.hover.kind == hitCorner, m.mouse.hover.kind == hitFilterButton,363- isTabHit(m.mouse.hover.kind):372+ m.mouse.hover.kind == hitCheckbox, m.mouse.hover.kind == hitDropdown, isTabHit(m.mouse.hover.kind):364373 shape = "pointer"365374 }366375 if shape == m.mouse.shape {@@ -106,7 +106,7 @@ const noteMaxRows = 8
106106 // cell, while nothing else is going on.107107 func (m *Model) noteBox() (overlay.Box, bool) {108108 h := m.mouse.hover109- if h.kind != hitCell || m.mode != modeReady || m.mouse.drag != dragNone {109+ if h.kind != hitCell && h.kind != hitCheckbox && h.kind != hitDropdown || m.mode != modeReady || m.mouse.drag != dragNone {110110 return overlay.Box{}, false111111 }112112 note := m.sheet.Note(h.addr)@@ -223,6 +223,9 @@ func (m *Model) contextLineText() string {
223223 if left == "" {224224 left = m.errorLine()225225 }226+ if left == "" {227+ left = m.validationLine() // looks.go228+ }226229 if left == "" {227230 left = m.noteLine()228231 }@@ -272,7 +275,7 @@ func (m *Model) spread(left, right string) string {
272275 func (m *Model) cursorPos() (x, y int, ok bool) {273276 if o, isText := m.overlay.(overlay.Text); isText {274277 x, y = o.Cursor()275- return x, y, true278+ return x, y, x >= 0276279 }277280 switch {278281 case m.mode == modeEnter, m.mode == modeEdit:@@ -79,6 +79,11 @@ type Picker struct {
7979 // changes the workbook: a recording keeps the title picked as the8080 // command's answer, and scripts answer it with a title.8181 Answers bool82+83+ // At, when set, is where the box's top-left corner goes, as a84+ // dropdown opens under its cell; the box stays on screen. Pickers85+ // open centered under the menu bar otherwise.86+ At *[2]int8287 }8388 8489 // ID identifies the picker's box in mouse events.@@ -309,6 +314,13 @@ func (p *Picker) box() (x, y, inner int) {
309314 }310315 inner = min(1+tw+3+dw+3+kw+1, p.maxW, width-2)311316 inner = max(inner, min(40, width-2))317+ if p.At != nil {318+ _, height := p.h.Size()319+ h := max(min(len(p.Items), 12), 1) + 4 // its tallest, so it never moves320+ x := min(max(p.At[0], 0), max(width-inner-2, 0))321+ y := min(max(p.At[1], 0), max(height-1-h, 0))322+ return x, y, inner323+ }312324 return (width - inner - 2) / 2, overlay.MenuLine + 1, inner313325 }314326 @@ -88,7 +88,8 @@ func colsRightTarget(m *Model) sheet.Rect {
8888 }8989 9090 // noCells is command.changes for commands that move cells without91-// changing any, such as inserting rows.91+// changing any, such as inserting rows, or that only put rules on them92+// (a checkbox, a dropdown).9293 func noCells(*Model) (sheet.Rect, bool) { return sheet.Rect{}, false }9394 9495 // selectedRowsTarget and selectedColsTarget are the whole rows or@@ -26,6 +26,9 @@ type Span struct {
2626 // Left for the caller to fill in from the owner's value.2727 Link string // the owner's link target, drawn as a hyperlink2828 Error bool // the owner shows an error: its text gets the error mark29+ // Invalid is set when the owner fails its data validation: its text30+ // gets the invalid mark.31+ Invalid bool2932 }3033 3134 // Layout lays out ncols columns of row from lo, each span exactly its@@ -0,0 +1,346 @@
1+package ui2+3+import (4+ "errors"5+ "image/color"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/macro"10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/picker"12+ "github.com/FelineStateMachine/012/internal/ui/rules"13+)14+15+// Rules follow Sheets: Format > Conditional formatting and Data > Data16+// validation open a panel of the sheet's rules at the right of the grid17+// (package rules), Insert > Checkbox and Insert > Dropdown add the two18+// rules people reach for most, Space toggles a checkbox and Alt+Down19+// opens a dropdown, and an entry a rule doesn't accept is refused or20+// kept with a warning on the context line. The panel's rules are saved21+// as the commands that add a rule from a line of the file, so a macro22+// recording keeps the rules added.23+24+func init() {25+ hasFormats := func(m *Model) bool { return len(m.sheet.CondFormats()) > 0 }26+ hasValidations := func(m *Model) bool { return len(m.sheet.Validations()) > 0 }27+ register(28+ &command{id: "format.conditional", macro: macroView, title: "Conditional formatting",29+ desc: "Color cells by their values: rules on ranges, and color scales",30+ run: func(m *Model) tea.Cmd {31+ m.openOverlay(rules.Formats(m.host()))32+ return nil33+ }},34+ &command{id: "format.conditional_add", title: "Add conditional format rule",35+ desc: `Add a rule written as a line of the file, e.g. {"ranges":"B2:B9","condition":"gt","values":["100"],"fill":"green"}`,36+ run: func(m *Model) tea.Cmd {37+ m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {38+ f, err := sheet.ParseCondFormat(text)39+ if err == nil {40+ err = m.host().SaveFormat(-1, f)41+ }42+ m.failOn(err)43+ return nil44+ })45+ return nil46+ }},47+ &command{id: "format.conditional_clear", title: "Clear conditional formats", enabled: hasFormats,48+ desc: "Take the selected cells out of every conditional format rule",49+ run: func(m *Model) tea.Cmd {50+ m.sheet.ClearCondFormats(m.selection())51+ m.changed = true52+ return nil53+ }},54+ &command{id: "data.validation", macro: macroView, title: "Data validation",55+ desc: "Rules on what cells may hold: dropdowns, checkboxes, numbers, dates, formulas",56+ run: func(m *Model) tea.Cmd {57+ m.openOverlay(rules.Validations(m.host()))58+ return nil59+ }},60+ &command{id: "data.validation_add", title: "Add data validation rule",61+ desc: `Add a rule written as a line of the file, e.g. {"ranges":"D2:D9","criteria":"list","items":["Yes","No"]}`,62+ run: func(m *Model) tea.Cmd {63+ m.openText("Rule:", "", func(m *Model, text string) tea.Cmd {64+ v, err := sheet.ParseValidation(text)65+ if err == nil {66+ err = m.host().SaveValidation(-1, v)67+ }68+ m.failOn(err)69+ return nil70+ })71+ return nil72+ }},73+ &command{id: "data.validation_clear", title: "Remove data validation", enabled: hasValidations,74+ desc: "Take the selected cells out of every data validation rule",75+ run: func(m *Model) tea.Cmd {76+ m.sheet.ClearValidations(m.selection())77+ m.changed = true78+ return nil79+ }},80+ &command{id: "insert.checkbox", title: "Checkbox", edits: (*Model).selection, changes: noCells,81+ desc: "Make the selected cells checkboxes: TRUE or FALSE, toggled with Space or a click",82+ run: func(m *Model) tea.Cmd {83+ m.failOn(m.host().SaveValidation(-1, sheet.Validation{Ranges: []sheet.Rect{m.selection()}, Kind: sheet.ValidCheckbox}))84+ return nil85+ }},86+ &command{id: "insert.dropdown", macro: macroView, title: "Dropdown", edits: (*Model).selection, changes: noCells,87+ desc: "Give the selected cells a list of items to pick from",88+ run: func(m *Model) tea.Cmd {89+ m.openOverlay(rules.Validations(m.host()).Add(true))90+ return nil91+ }},92+ &command{id: "data.dropdown", macro: macroView, title: "Open dropdown",93+ desc: "Pick the active cell's value from its dropdown list, or filter its column",94+ enabled: func(m *Model) bool { return m.onDropdown() || commands["data.filter_column"].available(m) },95+ run: func(m *Model) tea.Cmd {96+ if !m.onDropdown() {97+ return m.runCommand("data.filter_column")98+ }99+ if m.refuseEdit(sheet.Rect{From: m.cur, To: m.cur}, false) {100+ return nil // a pivot's result or a spilled value101+ }102+ m.openDropdown()103+ return nil104+ }},105+ &command{id: "data.checkbox_toggle", title: "Toggle checkbox", typed: true, edits: (*Model).selection,106+ desc: "Check or uncheck the selected checkboxes",107+ enabled: func(m *Model) bool { return m.sheet.HasRules() && m.sheet.Look(m.cur).Checkbox },108+ run: (*Model).toggleCheckboxes},109+ )110+ keymap["alt+down"] = "data.dropdown"111+ keymap["space"] = "data.checkbox_toggle"112+}113+114+// failOn shows err, if any, as ERROR mode does.115+func (m *Model) failOn(err error) {116+ if err != nil {117+ m.fail(err.Error())118+ }119+}120+121+// onDropdown reports whether the active cell has a dropdown list.122+func (m *Model) onDropdown() bool {123+ return m.sheet.HasRules() && m.sheet.Look(m.cur).Dropdown124+}125+126+// openDropdown opens the active cell's list in a picker under the cell;127+// picking an item enters it, as typing it would.128+func (m *Model) openDropdown() {129+ a := m.cur130+ items := m.sheet.DropdownItems(a)131+ current := m.sheet.ShownText(a)132+ var list []picker.Item133+ sel := 0134+ for _, it := range items {135+ pi := picker.Item{Title: it, Name: len(it), Desc: "Enter " + it + " in " + a.String()}136+ pi.Pick = func() tea.Cmd {137+ m.pickItem(a, it)138+ return nil139+ }140+ if it == current {141+ sel = len(list)142+ pi.Detail = "✓"143+ }144+ list = append(list, pi)145+ }146+ if len(list) == 0 {147+ m.note = "The dropdown of " + a.String() + " has no items"148+ return149+ }150+ p := m.newPicker(a.String(), "Search the items", 40, list)151+ p.Action = "choose"152+ p.Sel = sel153+ if y, ok := m.rowY(a.Row); ok {154+ p.At = &[2]int{m.colStart(a.Col), y + 1}155+ }156+ m.openOverlay(p)157+}158+159+// pickItem enters a dropdown's item in the cell at a.160+func (m *Model) pickItem(a sheet.Addr, item string) {161+ m.closeOverlay()162+ if m.askProtected(sheet.Rect{From: a, To: a}, func(m *Model) tea.Cmd {163+ m.pickItem(a, item)164+ return nil165+ }) {166+ return167+ }168+ if err := m.sheet.Set(a, item); err != nil {169+ m.note = err.Error()170+ return171+ }172+ m.changed = true173+ m.recordEntry(item, false)174+}175+176+// toggleCheckboxes checks the selected checkboxes, or unchecks them when177+// the active cell is checked, as Sheets' Space does. Blank checkboxes178+// are unchecked, so unchecking leaves them blank. Whole columns or rows179+// selected are cut to the data, so a million blank checkboxes aren't180+// written.181+func (m *Model) toggleCheckboxes() tea.Cmd {182+ s := m.sheet183+ on := !s.Look(m.cur).Checked184+ input := "FALSE"185+ if on {186+ input = "TRUE"187+ }188+ targets := []sheet.Addr{m.cur}189+ r := m.selection()190+ if used, ok := s.UsedRange(); r.AllRows() || r.AllCols() {191+ if !ok {192+ used = sheet.Rect{From: m.cur, To: m.cur}193+ }194+ r = intersect(r, used)195+ }196+ if m.hasRange() {197+ for _, v := range s.Validations() {198+ if v.Kind != sheet.ValidCheckbox {199+ continue200+ }201+ for _, vr := range v.Ranges {202+ targets = appendCells(targets, intersect(vr, r), m.cur, func(a sheet.Addr) bool { return on || !s.Cell(a).Blank() })203+ }204+ }205+ }206+ label := "uncheck " + r.String()207+ if on {208+ label = "check " + r.String()209+ }210+ err := s.Batch(sheet.Change{Label: label, Focus: r, Sheet: s}, func() error {211+ for _, a := range targets {212+ if err := s.Set(a, input); err != nil {213+ return err214+ }215+ }216+ return nil217+ })218+ if err != nil {219+ m.note = err.Error()220+ }221+ m.changed = true222+ return nil223+}224+225+// appendCells appends the cells of r that keep keeps to out, but for226+// skip; an empty r (From past To) adds nothing.227+func appendCells(out []sheet.Addr, r sheet.Rect, skip sheet.Addr, keep func(sheet.Addr) bool) []sheet.Addr {228+ for row := r.From.Row; row <= r.To.Row; row++ {229+ for col := r.From.Col; col <= r.To.Col; col++ {230+ if a := (sheet.Addr{Col: col, Row: row}); a != skip && keep(a) {231+ out = append(out, a)232+ }233+ }234+ }235+ return out236+}237+238+// intersect is the cells a and b share, From past To when none.239+func intersect(a, b sheet.Rect) sheet.Rect {240+ return sheet.Rect{241+ From: sheet.Addr{Col: max(a.From.Col, b.From.Col), Row: max(a.From.Row, b.From.Row)},242+ To: sheet.Addr{Col: min(a.To.Col, b.To.Col), Row: min(a.To.Row, b.To.Row)},243+ }244+}245+246+// checkEntry checks an entry against the cell's validation: a rule that247+// rejects it keeps the entry open with its help text, and one that248+// warns lets it in, saying so. It reports whether the entry may go in.249+func (m *Model) checkEntry(input string) (warn string, ok bool) {250+ bad := m.entrySheet().CheckEntry(m.cur, input)251+ switch {252+ case bad == nil:253+ return "", true254+ case bad.Reject:255+ m.entryError(bad, input)256+ return "", false257+ }258+ return bad.Error(), true259+}260+261+// The rules panel's host.262+263+func (h host) Sheet() *sheet.Sheet { return h.m.sheet }264+func (h host) Selection() sheet.Rect { return h.m.selection() }265+func (h host) Slot(i int) color.Color { return h.m.slotColor(i) }266+func (h host) refused(rs []sheet.Rect) bool {267+ for _, r := range rs {268+ if h.m.sheet.InPivot(r) {269+ return true270+ }271+ }272+ return false273+}274+275+// SaveFormat adds or replaces a conditional format; an added one is276+// recorded as the command that adds it.277+func (h host) SaveFormat(i int, f sheet.CondFormat) error {278+ m := h.m279+ var err error280+ if i < 0 {281+ err = m.sheet.AddCondFormat(f)282+ } else {283+ err = m.sheet.SetCondFormat(i, f)284+ }285+ if err == nil {286+ m.changed = true287+ if i < 0 {288+ m.recordRule("format.conditional_add", f.JSON())289+ } else {290+ m.noteUnrecorded()291+ }292+ }293+ return err294+}295+296+// Reworked follows a rule removed or moved in the panel.297+func (h host) Reworked() {298+ h.m.changed = true299+ h.m.noteUnrecorded()300+}301+302+// noteUnrecorded notes in a recording the last undo step, a change to303+// rules the panel made that no command replays, as dialogs are noted.304+func (m *Model) noteUnrecorded() {305+ if r := m.rec; r != nil && r.depth == 0 && r.pending == "" {306+ r.flush(m)307+ r.actions = append(r.actions, macro.Note("Not recorded: "+m.book().UndoLabel()))308+ r.acted = true309+ }310+}311+312+// SaveValidation adds or replaces a validation rule, refusing one over a313+// pivot table's results, whose cells take no entries; an added one is314+// recorded as the command that adds it.315+func (h host) SaveValidation(i int, v sheet.Validation) error {316+ m := h.m317+ if h.refused(v.Ranges) {318+ return errors.New("Data validation can't go on a pivot table's results")319+ }320+ var err error321+ if i < 0 {322+ err = m.sheet.AddValidation(v)323+ } else {324+ err = m.sheet.SetValidation(i, v)325+ }326+ if err == nil {327+ m.changed = true328+ if i < 0 {329+ m.recordRule("data.validation_add", v.JSON())330+ } else {331+ m.noteUnrecorded()332+ }333+ }334+ return err335+}336+337+// recordRule records a rule added from the panel as the command that338+// adds it, answered with its line. A command that adds one itself is339+// recorded where it runs.340+func (m *Model) recordRule(id, line string) {341+ if m.rec == nil || m.rec.depth > 0 || m.rec.pending != "" {342+ return343+ }344+ m.rec.flush(m)345+ m.rec.add(m, macro.Call("run", id).With("answer", line))346+}@@ -0,0 +1,274 @@
1+package rules2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/theme"8+)9+10+// cfKind lists conditional formats.11+type cfKind struct{}12+13+func (cfKind) title() string { return "Conditional format rules" }14+func (cfKind) count(s *sheet.Sheet) int { return len(s.CondFormats()) }15+func (cfKind) remove(s *sheet.Sheet, i int) { s.DeleteCondFormat(i) }16+17+func (cfKind) ordered() bool { return true }18+func (cfKind) listHint() string { return "The first rule that matches a cell wins" }19+20+func (cfKind) move(s *sheet.Sheet, i, to int) bool {21+ s.MoveCondFormat(i, to)22+ return true23+}24+25+// item is a rule's sample, its ranges and what it tests.26+func (cfKind) item(th *theme.Theme, h Host, i, w int, sel bool) string {27+ f := h.Sheet().CondFormats()[i]28+ base := th.MenuBar29+ if sel {30+ base = th.MenuSelected31+ }32+ text := base.Render(" ") + sample(th, h, f, 4) + base.Render(" "+sheet.RangesText(f.Ranges)+" ")33+ return spread(base, text+base.Render(f.Summary()), "", w)34+}35+36+// sample draws w columns of a rule's look: its style on "123", or its37+// color scale.38+func sample(th *theme.Theme, h Host, f sheet.CondFormat, w int) string {39+ if f.IsScale() {40+ return scaleBar(th, h, f.Scale, w)41+ }42+ // The fill spans the sample; text styles only its text, as in a cell.43+ base := th.Rule(f.Style)44+ s := th.Text(base, sheet.Style{Bold: f.Style.Bold, Italic: f.Style.Italic,45+ Underline: f.Style.Underline, Strikethrough: f.Style.Strikethrough})46+ text := theme.Center("123", w)47+ lead := len(text) - len(strings.TrimLeft(text, " "))48+ body := strings.TrimSpace(text)49+ return base.Render(text[:lead]) + s.Render(body) + base.Render(text[lead+len(body):])50+}51+52+// scaleBar draws a color scale's shades across w columns.53+func scaleBar(th *theme.Theme, h Host, ps []sheet.ScalePoint, w int) string {54+ var b strings.Builder55+ for x := range w {56+ pos := float64(x) / float64(max(w-1, 1))57+ from, to := ps[0].Color, ps[len(ps)-1].Color58+ if len(ps) == 3 {59+ if pos <= 0.5 {60+ from, to, pos = ps[0].Color, ps[1].Color, pos*261+ } else {62+ from, to, pos = ps[1].Color, ps[2].Color, (pos-0.5)*263+ }64+ }65+ b.WriteString(th.ScaleFill(from, to, pos, h.Slot).Wrap(" "))66+ }67+ return b.String()68+}69+70+// cfForm is a conditional format rule being edited.71+type cfForm struct {72+ i int73+ ranges string74+ scale int // 0 single color, 1 color scale75+ op int // index in sheet.CondFormatOps76+ args [2]string77+ text int // index in sheet.Colors78+ fill int79+ bold bool80+ italic bool81+ under bool82+ strike bool83+ pts [3]cfPoint84+}85+86+// cfPoint is a color scale point being edited: kind is an index in its87+// row's kinds, where the midpoint's first is none, and color a named88+// color less one, as points have no None.89+type cfPoint struct {90+ kind int91+ value string92+ color int93+}94+95+// pointKinds are the kinds each point offers: the minimum, the96+// midpoint (none first) and the maximum.97+var pointKinds = [3][]sheet.PointKind{98+ {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},99+ {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile}, // PointMin stands for none100+ {sheet.PointMax, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},101+}102+103+func (cfKind) form(h Host, i int, sel sheet.Rect) form {104+ f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen),105+ op: indexOf(sheet.CondFormatOps(), sheet.RuleNotEmpty),106+ pts: [3]cfPoint{{color: int(sheet.ColorRed) - 1}, {value: "50", color: int(sheet.ColorYellow) - 1}, {color: int(sheet.ColorGreen) - 1}}}107+ if i < 0 {108+ return f109+ }110+ r := h.Sheet().CondFormats()[i]111+ f.ranges = sheet.RangesText(r.Ranges)112+ if r.IsScale() {113+ f.scale = 1114+ for k, p := range r.Scale {115+ slot := k116+ if k == len(r.Scale)-1 {117+ slot = 2118+ }119+ f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: p.Value, color: int(p.Color) - 1}120+ }121+ return f122+ }123+ f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), r.Args124+ st := r.Style125+ f.text, f.fill = int(st.Text), int(st.Fill)126+ f.bold, f.italic, f.under, f.strike = st.Bold, st.Italic, st.Underline, st.Strikethrough127+ return f128+}129+130+func indexOf[T comparable](xs []T, x T) int {131+ for i, y := range xs {132+ if y == x {133+ return i134+ }135+ }136+ return -1137+}138+139+func (f *cfForm) title() string {140+ if f.i < 0 {141+ return "Add conditional format"142+ }143+ return "Edit conditional format"144+}145+146+// rule is the rule the form describes.147+func (f *cfForm) rule() (sheet.CondFormat, error) {148+ rs, ok := sheet.ParseRanges(f.ranges)149+ if !ok && strings.TrimSpace(f.ranges) != "" {150+ return sheet.CondFormat{}, errRange151+ }152+ r := sheet.CondFormat{Ranges: rs}153+ if f.scale == 1 {154+ for k, p := range f.pts {155+ if k == 1 && p.kind == 0 {156+ continue // no midpoint157+ }158+ r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: strings.TrimSpace(p.value), Color: sheet.Color(p.color + 1)})159+ }160+ return r, nil161+ }162+ r.Op = sheet.CondFormatOps()[f.op]163+ r.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}164+ r.Style = sheet.RuleStyle{Text: sheet.Color(f.text), Fill: sheet.Color(f.fill), Bold: f.bold,165+ Italic: f.italic, Underline: f.under, Strikethrough: f.strike}166+ return r, nil167+}168+169+func (f *cfForm) save(h Host) error {170+ r, err := f.rule()171+ if err != nil {172+ return err173+ }174+ return h.SaveFormat(f.i, r)175+}176+177+func (f *cfForm) rows(th *theme.Theme, h Host) []row {178+ rs := []row{179+ {kind: rowText, label: "Apply to", text: &f.ranges, placeholder: "A2:A100", hint: "The ranges the rule applies to, e.g. A2:A100 or A2:A9,C2:C9"},180+ {kind: rowChoice, label: "Format", choices: []string{"Single color", "Color scale"}, at: &f.scale, hint: "Color cells that meet a condition, or every number along a scale"},181+ {kind: rowSep},182+ }183+ if f.scale == 1 {184+ return append(rs, f.scaleRows(th, h)...)185+ }186+ op := sheet.CondFormatOps()[f.op]187+ rs = append(rs, row{kind: rowChoice, label: "Format if", choices: opTitles(), at: &f.op, hint: "The condition cells must meet"})188+ switch op.Args() {189+ case 1:190+ hint, holder := "The value to compare with, or a formula starting with =", "value or =formula"191+ if op == sheet.RuleFormula {192+ hint, holder = "TRUE for cells to format, written for the first cell: =$C2>100", "=$C2>100"193+ } else if op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter {194+ hint, holder = "A date, or today, tomorrow or yesterday", "2026-09-30 or today"195+ }196+ rs = append(rs, row{kind: rowText, label: "Value", text: &f.args[0], placeholder: holder, hint: hint})197+ case 2:198+ rs = append(rs, row{kind: rowText, label: "Between", text: &f.args[0], placeholder: "lowest", hint: "The lowest value"},199+ row{kind: rowText, label: "And", text: &f.args[1], placeholder: "highest", hint: "The highest value"})200+ }201+ swatch := func(fill bool) func(int) string {202+ return func(i int) string {203+ if i == 0 {204+ return th.MenuBar.Render(" ")205+ }206+ if fill {207+ return th.RuleFill[i].Render(" ")208+ }209+ return th.RuleText[i].Render("Aa")210+ }211+ }212+ rs = append(rs, row{kind: rowSep},213+ row{kind: rowChoice, label: "Text color", choices: colorTitles(), at: &f.text, swatch: swatch(false), hint: "The text's color, from the terminal's palette"},214+ row{kind: rowChoice, label: "Fill", choices: colorTitles(), at: &f.fill, swatch: swatch(true), hint: "The cell's background, from the terminal's palette"},215+ row{kind: rowCheck, label: "Bold", on: &f.bold, hint: "Bold text"},216+ row{kind: rowCheck, label: "Italic", on: &f.italic, hint: "Italic text"},217+ row{kind: rowCheck, label: "Underline", on: &f.under, hint: "Underlined text"},218+ row{kind: rowCheck, label: "Strikethrough", on: &f.strike, hint: "Struck through text"},219+ row{kind: rowSep},220+ row{kind: rowPreview, label: "Preview", preview: func(int) string {221+ r, _ := f.rule()222+ return sample(th, h, r, 12)223+ }})224+ return rs225+}226+227+// scaleRows are the rows of a color scale: each point's kind, value and228+// color, and a preview.229+func (f *cfForm) scaleRows(th *theme.Theme, h Host) []row {230+ var rs []row231+ names := [3]string{"Minpoint", "Midpoint", "Maxpoint"}232+ for k := range f.pts {233+ p := &f.pts[k]234+ kinds := make([]string, len(pointKinds[k]))235+ for j, pk := range pointKinds[k] {236+ kinds[j] = pk.Title()237+ }238+ if k == 1 {239+ kinds[0] = "None"240+ }241+ rs = append(rs, row{kind: rowChoice, label: names[k], choices: kinds, at: &p.kind, hint: "Where the point's color goes among the values"})242+ if k == 1 && p.kind == 0 {243+ continue244+ }245+ if pointKinds[k][p.kind].TakesValue() {246+ rs = append(rs, row{kind: rowText, label: " Value", text: &p.value, placeholder: "50", hint: "The point's number, percent or percentile"})247+ }248+ rs = append(rs, row{kind: rowChoice, label: " Color", choices: colorTitles()[1:], at: &p.color, swatch: func(i int) string {249+ return th.RuleFill[i+1].Render(" ")250+ }, hint: "The point's color, from the terminal's palette"})251+ }252+ return append(rs, row{kind: rowSep}, row{kind: rowPreview, label: "Preview", preview: func(w int) string {253+ r, _ := f.rule()254+ return scaleBar(th, h, r.Scale, min(w, 24))255+ }})256+}257+258+func opTitles() []string {259+ ops := sheet.CondFormatOps()260+ out := make([]string, len(ops))261+ for i, op := range ops {262+ out[i] = op.Title()263+ }264+ return out265+}266+267+func colorTitles() []string {268+ cs := sheet.Colors()269+ out := make([]string, len(cs))270+ for i, c := range cs {271+ out[i] = c.Title()272+ }273+ return out274+}@@ -0,0 +1,388 @@
1+// Package rules is the side panel for a sheet's rules, as Sheets'2+// Conditional formatting and Data validation sidebars: a list of the3+// rules, and a form to add or edit one. It is docked at the right of4+// the grid in the manner of lazygit's panels, and knows the UI only5+// through Host.6+package rules7+8+import (9+ "image/color"10+ "strconv"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/lineedit"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/theme"19+)20+21+// Host is what the rules panel needs of the UI.22+type Host interface {23+ Theme() *theme.Theme24+ Size() (width, height int)25+ // Line is the shared edit line text rows are typed in.26+ Line() *lineedit.Line27+ // Close closes the open overlay.28+ Close()29+ // Sheet is the sheet whose rules are edited.30+ Sheet() *sheet.Sheet31+ // Selection is the range a new rule applies to.32+ Selection() sheet.Rect33+ // SaveFormat and SaveValidation store rule i (-1 adds one), as the34+ // commands macros record do, reporting why they couldn't.35+ SaveFormat(i int, f sheet.CondFormat) error36+ SaveValidation(i int, v sheet.Validation) error37+ // Reworked follows a rule removed or moved: the file is modified,38+ // and a macro being recorded notes what it can't replay.39+ Reworked()40+ // Slot is the terminal's color for an ANSI slot, for color scales.41+ Slot(i int) color.Color42+}43+44+// kind is what the panel lists: conditional formats or validation.45+type kind interface {46+ title() string47+ count(s *sheet.Sheet) int48+ // item draws rule i of the list in w columns on base.49+ item(th *theme.Theme, h Host, i, w int, sel bool) string50+ // form opens rule i for editing, or a new rule over sel for -1.51+ form(h Host, i int, sel sheet.Rect) form52+ remove(s *sheet.Sheet, i int)53+ // move moves rule i to to, and reports whether rules have an order.54+ move(s *sheet.Sheet, i, to int) bool55+ // ordered reports whether the rules' order matters, and listHint56+ // says how rules combine, for the status line.57+ ordered() bool58+ listHint() string59+}60+61+// form is a rule being added or edited.62+type form interface {63+ rows(th *theme.Theme, h Host) []row64+ save(h Host) error65+ title() string66+}67+68+// Editor is the rules panel.69+type Editor struct {70+ h Host71+ k kind72+ f form // the form open, or nil for the list73+ sel int // the picked row of the form74+ msg string75+ list overlay.List // over "Add rule", then the rules76+ top int // the form's scroll77+}78+79+// ID identifies the panel's box in mouse events.80+const ID = "rules"81+82+// Formats opens the panel on the sheet's conditional formats.83+func Formats(h Host) *Editor { return &Editor{h: h, k: cfKind{}} }84+85+// Validations opens the panel on the sheet's data validation.86+func Validations(h Host) *Editor { return &Editor{h: h, k: dvKind{}} }87+88+// Add opens the form for a new rule over the selection, as Insert >89+// Dropdown and a new rule do; with dropdown, a dropdown list.90+func (e *Editor) Add(dropdown bool) *Editor {91+ e.openForm(-1)92+ if f, ok := e.f.(*dvForm); ok && dropdown {93+ f.kind = int(sheet.ValidList)94+ }95+ return e96+}97+98+func (e *Editor) Indicator() string { return "RULES" }99+100+func (e *Editor) openForm(i int) {101+ e.f, e.msg, e.top = e.k.form(e.h, i, e.h.Selection()), "", 0102+ e.sel = -1103+ e.step(e.rows(), 1)104+ e.enter()105+}106+107+// rows is the open form's rows.108+func (e *Editor) rows() []row { return e.f.rows(e.h.Theme(), e.h) }109+110+// enter puts the picked text row's text in the edit line.111+func (e *Editor) enter() {112+ if rs := e.rows(); e.sel < len(rs) && rs[e.sel].kind == rowText {113+ e.h.Line().Set(*rs[e.sel].text)114+ }115+}116+117+// leave keeps what was typed in the picked text row.118+func (e *Editor) leave() {119+ if rs := e.rows(); e.sel >= 0 && e.sel < len(rs) && rs[e.sel].kind == rowText {120+ *rs[e.sel].text = e.h.Line().Text()121+ }122+}123+124+// step picks the next row d away that can be picked.125+func (e *Editor) step(rs []row, d int) {126+ for i := e.sel + d; i >= 0 && i < len(rs); i += d {127+ if rs[i].picks() {128+ e.sel = i129+ return130+ }131+ }132+}133+134+// pickRow moves the form to row i.135+func (e *Editor) pickRow(i int) {136+ e.leave()137+ e.sel = i138+ e.enter()139+}140+141+func (e *Editor) Key(k tea.KeyPressMsg) tea.Cmd {142+ if e.f != nil {143+ e.formKey(k)144+ return nil145+ }146+ n := e.k.count(e.h.Sheet()) + 1147+ switch key := k.String(); key {148+ case "esc":149+ e.h.Close()150+ case "up", "ctrl+p", "shift+tab":151+ e.list.Move(-1, n)152+ case "down", "ctrl+n", "tab":153+ e.list.Move(1, n)154+ case "enter", "space":155+ e.openForm(e.list.Sel - 1)156+ case "a", "+":157+ e.openForm(-1)158+ case "delete", "backspace", "x", "-":159+ if e.list.Sel > 0 {160+ e.k.remove(e.h.Sheet(), e.list.Sel-1)161+ e.h.Reworked()162+ e.list.Sel = min(e.list.Sel, e.k.count(e.h.Sheet()))163+ }164+ case "shift+up", "shift+down":165+ e.reorder(key == "shift+down")166+ }167+ return nil168+}169+170+// reorder moves the highlighted rule up or down the list.171+func (e *Editor) reorder(down bool) {172+ i := e.list.Sel - 1173+ to := i - 1174+ if down {175+ to = i + 1176+ }177+ if i < 0 || to < 0 || to >= e.k.count(e.h.Sheet()) {178+ return179+ }180+ if e.k.move(e.h.Sheet(), i, to) {181+ e.list.Sel = to + 1182+ e.h.Reworked()183+ }184+}185+186+func (e *Editor) formKey(k tea.KeyPressMsg) {187+ rs := e.rows()188+ r := rs[e.sel]189+ switch key := k.String(); {190+ case key == "esc":191+ e.f, e.msg = nil, ""192+ case key == "enter":193+ e.leave()194+ if err := e.f.save(e.h); err != nil {195+ e.msg = err.Error()196+ return197+ }198+ e.f, e.msg = nil, ""199+ case key == "up" || key == "shift+tab" || key == "ctrl+p":200+ e.leave()201+ e.step(rs, -1)202+ e.enter()203+ case key == "down" || key == "tab" || key == "ctrl+n":204+ e.leave()205+ e.step(rs, 1)206+ e.enter()207+ case r.kind == rowChoice && (key == "left" || key == "right" || key == "space"):208+ r.cycle(map[bool]int{true: -1, false: 1}[key == "left"])209+ case r.kind == rowCheck && key == "space":210+ *r.on = !*r.on211+ case r.kind == rowText:212+ e.h.Line().Key(k)213+ *r.text = e.h.Line().Text()214+ }215+}216+217+func (e *Editor) Mouse(ev overlay.MouseEvent) tea.Cmd {218+ if ev.Box != ID {219+ if ev.Kind == overlay.MousePress {220+ e.h.Close()221+ }222+ return nil223+ }224+ up := ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelUp225+ down := ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelDown226+ press := ev.Kind == overlay.MousePress && ev.Button == tea.MouseLeft227+ if e.f == nil {228+ n := e.k.count(e.h.Sheet()) + 1229+ i := e.list.Top + ev.Row - 1230+ switch {231+ case up:232+ e.list.Move(-1, n)233+ case down:234+ e.list.Move(1, n)235+ case press && i >= 0 && i < n && i == e.list.Sel:236+ e.openForm(i - 1)237+ case press && i >= 0 && i < n:238+ e.list.Sel = i239+ }240+ return nil241+ }242+ rs := e.rows()243+ i := e.top + ev.Row - 1244+ switch {245+ case up:246+ e.formKey(tea.KeyPressMsg{Code: tea.KeyUp})247+ case down:248+ e.formKey(tea.KeyPressMsg{Code: tea.KeyDown})249+ case !press || i < 0 || i >= len(rs) || !rs[i].picks():250+ case i != e.sel:251+ e.pickRow(i)252+ case rs[i].kind == rowChoice:253+ rs[i].cycle(1)254+ case rs[i].kind == rowCheck:255+ *rs[i].on = !*rs[i].on256+ }257+ return nil258+}259+260+// box places the panel at the right of the grid.261+func (e *Editor) box() (x, y, inner int) {262+ width, _ := e.h.Size()263+ inner = min(46, width-2)264+ return max(width-inner-2, 0), GridTop, inner265+}266+267+// GridTop is the first screen row of the grid, under the control panel268+// and the column headers.269+const GridTop = 4270+271+// visible is how many rows of n fit above the status line.272+func (e *Editor) visible(n int) int {273+ _, height := e.h.Size()274+ return max(min(n, height-1-GridTop-2), 1)275+}276+277+func (e *Editor) Layout() []overlay.Box {278+ th := e.h.Theme()279+ x, y, inner := e.box()280+ if e.f == nil {281+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, e.k.title(), e.footer(), e.listLines(inner))}}282+ }283+ rs := e.rows()284+ n := e.visible(len(rs))285+ e.top = max(e.sel-n+1, min(e.top, e.sel), 0)286+ var lines []string287+ for i := e.top; i < min(e.top+n, len(rs)); i++ {288+ lines = append(lines, rs[i].draw(th, inner, i == e.sel, e.h.Line().Text()))289+ }290+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, e.f.title(), "", lines)}}291+}292+293+func (e *Editor) footer() string {294+ n := e.k.count(e.h.Sheet())295+ if n == 1 {296+ return "1 rule"297+ }298+ return strconv.Itoa(n) + " rules"299+}300+301+func (e *Editor) listLines(inner int) []string {302+ th := e.h.Theme()303+ n := e.k.count(e.h.Sheet()) + 1304+ e.list.Sel = min(e.list.Sel, n-1)305+ rows := e.visible(n)306+ e.list.Show(rows)307+ var lines []string308+ for r := range rows {309+ i := e.list.Top + r310+ if i >= n {311+ break312+ }313+ base := th.MenuBar314+ if i == e.list.Sel {315+ base = th.MenuSelected316+ }317+ if i == 0 {318+ lines = append(lines, theme.Cells(base, " + Add rule", inner))319+ continue320+ }321+ lines = append(lines, e.k.item(th, e.h, i-1, inner, i == e.list.Sel))322+ }323+ return lines324+}325+326+// Cursor is the caret in a text row being typed into.327+func (e *Editor) Cursor() (int, int) {328+ if e.f == nil {329+ return -1, -1330+ }331+ rs := e.rows()332+ if e.sel >= len(rs) || rs[e.sel].kind != rowText {333+ return -1, -1334+ }335+ x, y, _ := e.box()336+ return x + 1 + textX() + ansi.StringWidth(e.h.Line().Head()), y + 1 + e.sel - e.top337+}338+339+// Changed keeps what's typed in the picked text row.340+func (e *Editor) Changed() {341+ if e.f != nil {342+ e.leave()343+ }344+}345+346+func (e *Editor) Status() (string, string) {347+ th := e.h.Theme()348+ width, _ := e.h.Size()349+ var desc string350+ var pairs []string351+ switch {352+ case e.f == nil && e.list.Sel == 0:353+ desc, pairs = "Add a rule over the selection", []string{"Enter", "add", "Esc", "close"}354+ case e.f == nil:355+ desc, pairs = e.k.listHint(), []string{"Enter", "edit", "Del", "remove", "Esc", "close"}356+ if e.k.ordered() {357+ pairs = []string{"Enter", "edit", "Del", "remove", "Shift+↑/↓", "move", "Esc", "close"}358+ }359+ default:360+ r := e.rows()[e.sel]361+ desc = r.hint362+ pairs = []string{"Enter", "save", "Esc", "back"}363+ switch r.kind {364+ case rowChoice:365+ pairs = append([]string{"←/→", "change"}, pairs...)366+ case rowCheck:367+ pairs = append([]string{"Space", "on/off"}, pairs...)368+ }369+ }370+ if e.msg != "" {371+ desc = th.Warning.Render(e.msg)372+ } else {373+ desc = th.Muted.Render(desc)374+ }375+ for {376+ keys := th.KeyHints(pairs...)377+ switch {378+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:379+ return desc, keys380+ case e.msg == "" && desc != "":381+ desc = ""382+ case len(pairs) > 4:383+ pairs = pairs[2:]384+ default:385+ return desc, keys386+ }387+ }388+}Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.