microsheet is a terminal UI application for viewing and editing CSV files as a
spreadsheet, with support for a small formula language. It is built in Rust
using ratatui (+ crossterm for the terminal backend).
The guiding principle: the CSV file is the source of truth. A cell whose
text begins with = is treated as a formula. Formulas are stored as text in
the CSV (so they round-trip through save/load and remain editable in other
tools), and are evaluated locally for display and for use by other formulas.
- Open, browse, edit, and save CSV files quickly from the terminal.
- Spreadsheet-like grid navigation, cell editing, formula bar.
- A formula language: arithmetic, cell/range references, common aggregate functions, conditionals, string operations.
- Automatic recalculation on edit, with dependency tracking (no full recompute of the whole sheet on every keystroke).
- Reasonable performance on files up to ~100k rows / ~50 columns.
- Single static binary, no runtime dependencies.
- Multiple sheets/tabs.
- Cell styling, colors, merged cells, frozen panes (beyond a basic frozen header row).
- Charts, pivot tables, macros.
- Full Excel-formula compatibility (no array formulas, no external
references, no volatile functions like
NOW()driving live updates). - Undo/redo, OS clipboard integration, and relative-reference adjustment on paste are not yet implemented (see "Development notes" below).
src/
main.rs - entry point, CLI args, terminal setup/teardown
app.rs - App state, event loop, mode dispatch
csv_io.rs - CSV load/save (using `csv` crate), dialect detection
sheet.rs - Sheet data model: cells, dimensions, raw vs evaluated
formula/
lexer.rs - tokenizer for formula strings
parser.rs - recursive-descent parser -> AST
ast.rs - AST node definitions
eval.rs - evaluator (AST -> Value)
functions.rs - builtin function table (SUM, IF, etc.)
refs.rs - cell/range reference parsing (A1, B2:D10)
deps.rs - dependency graph + topological recalculation
ui/
mod.rs - top-level layout (grid, formula bar, status/help line)
grid.rs - grid rendering, viewport/scroll logic
editor.rs - in-cell / formula-bar text editing widget
dialogs.rs - save-as, error, quit-confirm dialogs
clipboard.rs - copy/cut/paste buffer (internal; OS clipboard optional)
ratatui— TUI renderingcrossterm— terminal backend, input eventscsv— RFC 4180 CSV parsing/writingclap— CLI argument parsing- (formula lexer/parser hand-rolled; no parser-combinator dependency needed given the small grammar)
struct Sheet {
headers: Option<Vec<String>>, // first row, if --header is set
cells: Vec<Vec<Cell>>, // row-major
col_widths: Vec<u16>,
dirty: bool,
}
struct Cell {
raw: String, // exact text as in CSV ("", "42", "=A1+B2")
value: Value, // cached evaluated value
error: Option<EvalError>, // last evaluation error, if any
}
enum Value {
Empty,
Number(f64),
Text(String),
Bool(bool),
Error(EvalError),
}A cell is a formula cell iff raw.starts_with('='). All other cells are
literal cells; their value is derived from raw by trying number parse,
then falling back to text. Booleans are formula-internal (TRUE/FALSE
literals and comparison results) — literal cells from CSV are never
auto-coerced to Bool.
- Loaded with the
csvcrate in non-strict mode (variable column counts tolerated; short rows are padded with empty cells, long rows extend the sheet's column count). - Delimiter: configurable via
--delimiter(default,); auto-detect not attempted. - Header row:
--headerflag treats row 1 as column headers. Column letters (A, B, C, ...) stay visible at all times;--headeradds the header text as an additional pinned row below them. Formulas always use A1-style references regardless of headers. - Quoting/escaping handled by the
csvcrate on both read and write (RFC 4180). On save, fields are quoted only when necessary (contain comma, quote, or newline), matchingcsv::WriterBuilderdefaults. - Line ending preserved from the source file (CRLF vs LF) where detectable; default LF for new files.
- Save writes raw cell text (formulas as
=...text), never the evaluated value — this is what makes formulas round-trip.
┌─────────────────────────────────────────────────────────────┐
│ D3 =B3*C3 -> 19.99 │ <- formula bar (A1 ref + raw text [+ eval preview])
├───┬─────────┬─────────┬─────────┬────────────────────────────┤
│ │ A │ B │ C │ D │ <- column header row
├───┼─────────┼─────────┼─────────┼────────────────────────────┤
│ 1 │ Name │ Qty │ Price │ Total │
│ 2 │ Widget │ 3 │ 9.99 │ 29.97 │
│ 3 │ Gadget │ 1 │ 19.99 │ 19.99 │
│...│ │ │ │ │
├───┴─────────┴─────────┴─────────┴────────────────────────────┤
│ file.csv [+] ^O Write Out ^X Exit ^W Search ^K Cut ... │ <- shortcut bar
└─────────────────────────────────────────────────────────────┘
- Row numbers and column letters (A, B, C, ... AA, AB, ...) shown as permanent headers, similar to Excel.
- Active cell highlighted.
- Formula bar always shows the active cell's A1 reference followed by
its raw text; for formula/numeric cells it also appends a
raw -> evaluatedpreview (e.g."=B3*C3" -> 19.99). While editing, it instead shows the live edit buffer with a cursor marker. The grid always shows the evaluated value (right-aligned for numbers, left for text, with errors shown as#ERRin reverse video). - Shortcut bar (bottom line) shows the filename + dirty marker and a row of
nano-style
^-prefixed shortcut hints (^G Helpfor the full list). Transient messages (save confirmations, errors) and active^O/^Xprompts temporarily replace the hints on this line. - Column widths auto-sized on load (capped at a max, configurable), and user-resizable (see keybindings).
- Inline Markdown styling (display-only): cell display text is scanned
for basic Markdown markers --
**bold**,*italic*, and~~strikethrough~~-- which are rendered with the corresponding text style (bold/italic/crossed-out) and stripped from the on-screen text. This affects only the grid's rendering (and width/alignment calculations); the underlying cell value and the raw text shown in the formula bar are never modified, so the markers are preserved verbatim on save. Markers are not nested, and an unterminated marker (e.g. a lone**) is shown literally.
Nano-inspired: no "press a key to start typing" step -- the active cell can
be edited immediately, and commands are Ctrl-prefixed shortcuts advertised
in the shortcut bar rather than :ex-style commands. Arrow keys, Home/End,
and Page Up/Down handle all navigation.
| Key | Action |
|---|---|
| Arrow keys | Move active cell |
Home / End |
Jump to first / last column in the row |
Ctrl-Home / Ctrl-End |
Jump to first / last row |
PageUp / PageDown |
Page up / down |
| Any printable character | Start editing the active cell, overwriting its previous content, cursor placed after the typed character |
Enter |
Start editing the active cell, cursor at the end of the existing text (append/tweak) |
Backspace / Delete |
Clear active cell (or marked range) content |
^O |
Write Out: save prompt, pre-filled with the current path |
^X |
Exit (prompts to save first if there are unsaved changes) |
^W |
Search |
^K / ^U |
Cut / paste active cell or marked range |
^^ (Ctrl-6) |
Toggle Mark: start a range selection, extended with arrow keys |
^_ |
Go to cell (A1 ref or row number) |
^G |
Help overlay (keybinding cheat-sheet) |
Ctrl-Left / Ctrl-Right |
Resize the active column (shrink / grow by 1, clamped to min/max width) |
Entered by typing a character (overwrite) or Enter (append) on the active
cell.
| Key | Action |
|---|---|
| Printable chars | Insert at cursor in the formula bar |
Left/Right/Home/End |
Move cursor within cell text |
Backspace/Delete |
Edit text |
Enter |
Commit edit, move active cell down |
Tab / Shift-Tab |
Commit edit, move active cell right / left |
Esc |
Cancel edit, revert to previous raw value |
| Key | Action |
|---|---|
| Arrow keys | Extend selection from the marked cell |
^K |
Cut marked range (tab/newline-delimited, for clipboard interop) |
Esc / ^^ |
Cancel selection, back to Navigation |
Entered by ^O (Write Out) or ^X with unsaved changes; rendered in the
shortcut bar in place of the hint row.
| Prompt | Keys | Action |
|---|---|---|
Save as: <path> (^O) |
type to edit path, Enter saves and updates the current path, Esc cancels |
Save |
Save modified buffer? Y/N/^C (^X with unsaved changes) |
y/Y save then exit, n/N discard and exit, Esc/^C cancel |
Exit |
A formula is any cell whose raw text starts with =. Everything after = is
parsed as an expression.
expr := comparison
comparison := concat ( ('=' | '<>' | '<' | '>' | '<=' | '>=') concat )*
concat := additive ( '&' additive )* // string concatenation
additive := term ( ('+' | '-') term )*
term := unary ( ('*' | '/') unary )*
unary := ('-' | '+') unary | power
power := atom ('^' atom)*
atom := NUMBER | STRING | BOOL | reference | range | funccall
| '(' expr ')'
reference := [A-Z]+[0-9]+ // e.g. A1, BC23
| IDENT '!' [A-Z]+[0-9]+ // e.g. other!A1 (inter-sheet)
range := reference ':' reference // e.g. A1:A10, other!A1:B2
| COLUMN ':' COLUMN // e.g. B:B, A:C (whole column(s))
| ROW ':' ROW // e.g. 1:1, 2:5 (whole row(s))
COLUMN := [A-Z]+
ROW := [0-9]+
funccall := IDENT '(' (expr (',' expr)*)? ')'
BOOL := 'TRUE' | 'FALSE'
STRING := '"' ... '"' // with "" as escaped quote
- Operator precedence (low to high): comparison < concat (
&) <+ -<* /< unary+/-<^. - Comparisons return
Bool.=is equality (not assignment — there's no assignment in formula context). - References are case-insensitive (
a1==A1), absolute row/col model only (no$anchors — no copy/paste-with-relative-adjustment, see §7.4). - Ranges are only valid as arguments to range-aware functions
(
SUM,AVG, etc.) and in array-producing contexts (IFover a range is out of scope). - Whole-column (
B:B,A:C) and whole-row (1:1,2:5) ranges are bound to the sheet's current data extent at evaluation time —B:Bmeans "B1 through the last row currently in the sheet", not an unbounded range. If the sheet grows (e.g. a new row is appended via Enter-commit at the bottom, per §8), any formula whose whole-column/row range covers the new row/column is recomputed (see §7.4).
A formula may reference a cell or range in another CSV file in the same
directory: other!A1, other!A1:B2 (the grammar's reference/range
production gains an optional IDENT '!' prefix, where IDENT is the
filename without .csv, matched case-insensitively).
- Referenced sheets are loaded once, read-only, the first time a formula referencing them is parsed (on file open, or when a formula is typed/ pasted). They are never re-read during the session, even if the file on disk changes — there's no live sync or write-through.
- A referenced sheet's own formulas (if any) are evaluated once on load, but
it can't itself contain inter-sheet refs — nesting isn't supported, to
avoid unbounded reference chains. A nested
other!A1inside a referenced sheet is simply never resolved (its cached value reflects an unevaluated formula). - Whole-column/whole-row ranges (
other!B:B) are not supported for external refs — onlyother!A1andother!A1:B2-style closed ranges. - A missing file, unreadable CSV, or out-of-bounds cell in the external
sheet all resolve to
#REF!(§7.5) — there's no separate error code.
enum Value {
Empty,
Number(f64),
Text(String),
Bool(bool),
Error(EvalError),
}Coercion rules:
- Arithmetic operators require
Numberoperands;Emptycoerces to0;Boolcoerces to1/0;Textthat doesn't parse as a number ->#VALUE!error. &(concat) coerces any value to its display-text form (Numberformatted without trailing zeros,Bool->TRUE/FALSE,Empty->"").- Comparisons:
Number/Numbernumeric compare;Text/Textlexicographic; mixed types compare unequal (no implicit coercion across type for=/<>, but</>etc. on mixed types ->#VALUE!). - Computed formula results are displayed trimmed to ~10 significant digits; raw text of literal cells from the CSV is never reformatted.
| Function | Signature | Notes |
|---|---|---|
SUM(range|expr, ...) |
numeric | sums numbers, ignores text/empty in ranges |
AVG(range|expr, ...) |
numeric | average of numeric cells in range |
MIN(range|expr, ...) |
numeric | |
MAX(range|expr, ...) |
numeric | |
COUNT(range|expr, ...) |
numeric | counts non-empty numeric cells |
COUNTA(range) |
numeric | counts non-empty cells (any type) |
IF(cond, then, else) |
any | cond must evaluate to Bool |
AND(expr, ...) / OR(expr, ...) |
bool | short-circuit not required, small operand counts |
NOT(expr) |
bool | |
CONCAT(expr, ...) |
text | alternative to & for >2 args |
ROUND(number, digits) |
numeric | |
ABS(number) |
numeric | |
LEN(text) |
numeric | |
UPPER(text) / LOWER(text) |
text | |
TRIM(text) |
text |
All function names are case-insensitive, matched case-insensitively against the table above (stored canonically uppercase).
- On parse, each formula's AST is scanned for
Reference/Rangenodes to build its dependency list (set of(row, col)cells it reads). deps.rsmaintains a dependency graph (HashMap<(row,col), HashSet<(row,col)>>for "depends on" and the reverse "dependents of" map).- On a cell edit:
- Re-parse the new raw text (if it's a formula) and update the dep graph edges for that cell.
- Detect cycles via DFS from the edited cell through its dependents; if a
cycle is introduced, mark all cells in the cycle as
#CIRCULAR!and abort recompute for them. - Topologically walk the affected subgraph (the edited cell + all transitive dependents) and re-evaluate each in dependency order.
- No relative-reference adjustment on copy/paste: pasting a formula
pastes its literal text unchanged (Excel-style relative-ref shifting is a
possible future enhancement — would require
$anchor syntax design first; see "Development notes"). - Self-reference and circular references produce
#CIRCULAR!in every cell in the cycle; the sheet remains otherwise usable. - Open (whole-column/row) range dependencies: a formula containing
B:Bor1:1depends on the entire column/row as it exists at evaluation time. The dep graph records these as "open" edges keyed by column or row index (not individual cells). When the sheet grows (a row or column is added, per §8's Enter-commit growth), every formula with an open dependency on that row/column index is added to the recompute set, alongside the normal cell-edit recompute pass.
| Error | Meaning |
|---|---|
#REF! |
Reference points outside sheet bounds |
#VALUE! |
Type mismatch in operator/function argument |
#DIV0! |
Division by zero |
#NAME! |
Unknown function name |
#CIRCULAR! |
Cell is part of a circular dependency |
#ERR |
Generic parse error (malformed formula text) |
Errors propagate through arithmetic/concat (any operand error -> result is
that error, first one encountered). For SUM/AVG/etc.: if any referenced
cell in a range is an error, the aggregate function itself returns that
error (avoids silently hiding broken data).
- Editing a cell does not commit until
Enter/Tab/click-away;Escreverts. - An empty raw string is a valid "empty" cell; saving preserves the empty
field (not a literal
""). - Typing a value that looks like a number (
42,-3.5,1e10) is stored as-is inraw; numeric formatting on save is not normalized — original text preserved unless the user edits it. - Pasting multi-cell text (tab/newline separated, from yank or external clipboard) fills a block starting at the active cell, growing the sheet (adding rows/columns) if the paste extends past current bounds.
- The cursor can move freely past the sheet's current bounds during navigation; editing a cell past the current extent auto-grows the sheet (adding the needed rows/columns) when the edit is committed.
microsheet [OPTIONS] <FILE>
OPTIONS:
--delimiter <CHAR> Field delimiter (default: ',')
--header Treat first row as column headers
--readonly Open in read-only mode (no edits/saves)
If <FILE> doesn't exist, microsheet opens an empty single-cell sheet and
treats ^O (Write Out) as "save as <FILE>".
This section is an appendix for contributors; end users can stop reading at
§9. See CLAUDE.md for up-to-date build/test/run instructions and current
priorities.
The v1 feature set described in §1-9 is complete: read-only viewing, editing and saving, the formula engine and built-in functions, and UX polish (visual-select mode, yank/paste incl. block paste, search, help overlay, column resize).
Not yet implemented (potential future work, out of scope unless explicitly planned):
- Undo/redo history.
- OS clipboard integration (the internal yank/paste buffer in
clipboard.rsis the only mechanism for v1). - Relative-reference adjustment on paste (would require
$anchor syntax design first). - A
--no-formulasexport mode that writes evaluated values instead of raw formula text.
formula/: unit tests per grammar rule (lexer token streams, parser ASTs for representative expressions, evaluator results incl. error cases) — pure functions, no terminal needed.deps.rs: unit tests for cycle detection and topological recompute order on hand-built graphs.csv_io.rs: round-trip tests (load -> save -> byte-identical for unedited files, modulo line-ending normalization) using fixture CSVs covering quoting, embedded commas/newlines, ragged rows.- UI:
ratatui'sTestBackendfor snapshot-style tests of grid rendering at fixed terminal sizes; integration tests driving theAppevent loop with synthetic key events to verify navigation/edit/save flows end-to-end. - The formula lexer/parser/evaluator are kept free of any UI/ratatui dependencies so they remain unit-testable in isolation (see §3).