Skip to content

Latest commit

 

History

History
439 lines (380 loc) · 20.1 KB

File metadata and controls

439 lines (380 loc) · 20.1 KB

microsheet — User Manual

1. Overview

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.

2. Features / Non-features

Features

  • 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.

Non-features

  • 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).

3. Architecture

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)

Core dependencies

  • ratatui — TUI rendering
  • crossterm — terminal backend, input events
  • csv — RFC 4180 CSV parsing/writing
  • clap — CLI argument parsing
  • (formula lexer/parser hand-rolled; no parser-combinator dependency needed given the small grammar)

Data model

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.

4. CSV I/O

  • Loaded with the csv crate 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: --header flag treats row 1 as column headers. Column letters (A, B, C, ...) stay visible at all times; --header adds the header text as an additional pinned row below them. Formulas always use A1-style references regardless of headers.
  • Quoting/escaping handled by the csv crate on both read and write (RFC 4180). On save, fields are quoted only when necessary (contain comma, quote, or newline), matching csv::WriterBuilder defaults.
  • 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.

5. UI Layout

┌─────────────────────────────────────────────────────────────┐
│ 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 -> evaluated preview (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 #ERR in reverse video).
  • Shortcut bar (bottom line) shows the filename + dirty marker and a row of nano-style ^-prefixed shortcut hints (^G Help for the full list). Transient messages (save confirmations, errors) and active ^O/^X prompts 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.

6. Modes & Keybindings

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.

Navigation (default state)

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)

Edit mode

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

Mark (range select) mode

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

Prompt mode

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

7. Formula Language

7.1 Syntax

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 (IF over 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:B means "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).

Inter-sheet refs

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!A1 inside 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 — only other!A1 and other!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.

7.2 Value types

enum Value {
    Empty,
    Number(f64),
    Text(String),
    Bool(bool),
    Error(EvalError),
}

Coercion rules:

  • Arithmetic operators require Number operands; Empty coerces to 0; Bool coerces to 1/0; Text that doesn't parse as a number -> #VALUE! error.
  • & (concat) coerces any value to its display-text form (Number formatted without trailing zeros, Bool -> TRUE/FALSE, Empty -> "").
  • Comparisons: Number/Number numeric compare; Text/Text lexicographic; 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.

7.3 Built-in functions

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).

7.4 References, dependencies, recalculation

  • On parse, each formula's AST is scanned for Reference/Range nodes to build its dependency list (set of (row, col) cells it reads).
  • deps.rs maintains a dependency graph (HashMap<(row,col), HashSet<(row,col)>> for "depends on" and the reverse "dependents of" map).
  • On a cell edit:
    1. Re-parse the new raw text (if it's a formula) and update the dep graph edges for that cell.
    2. 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.
    3. 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:B or 1:1 depends 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.

7.5 Error values

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).

8. Editing semantics

  • Editing a cell does not commit until Enter/Tab/click-away; Esc reverts.
  • 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 in raw; 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.

9. CLI

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>".

10. Development notes

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.

Status

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.rs is the only mechanism for v1).
  • Relative-reference adjustment on paste (would require $ anchor syntax design first).
  • A --no-formulas export mode that writes evaluated values instead of raw formula text.

Testing strategy

  • 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's TestBackend for snapshot-style tests of grid rendering at fixed terminal sizes; integration tests driving the App event 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).