Apps
Sheet
# Sheet — reactive deft spreadsheet
A spreadsheet where every cell is a deft expression. Cells are evaluated in a
calculation sandbox inside the app's own runtime (one namespace per workbook);
each cell's value is bound as a live variable, so other cells reference it
directly.
The UI is a hand-drawn Lotus 1-2-3-style panel: a four-row control panel on
top (cell address + contents, the cursor cell's metadata line —
`every 5s · watch … · pub · num=%.2f · align=center`, with `(row)`/`(col)`
tags when formatting is inherited from an overlay —, the command/menu line,
a description line), the cell grid with column letters and a row-number
gutter, and a status line at the bottom. The mode indicator sits in the
top-right corner.
## Cells
- `=` prefix → expression. The rest of the cell is a deft expression:
`=[abs $a1]`, `=($a1 + 8)`, `=[somefunc $b5]`. Infix arithmetic must be
parenthesized (deft rule) — also inside call arguments:
`=[triple ($b3 * 2)]`, not `=[triple $b3*2]`. Multi-line expressions
are fine.
- No `=` → literal. Numeric-looking text becomes a number (typing `5`
gives `5`, so `=($b1 * 2)` works); other text stays a string, and
numeric-looking literals right-align (numbers that don't fit show
`>>>>>>>`). Literal text spills into empty right-neighbour cells,
Lotus 1-2-3 style. `==` forces the string form.
- `~` prefix → symbolic expression. The rest of the cell is read by a
symbolic mini-language: infix arithmetic over the exact golden field
Q(√5) with `phi`, `sqrt5`, `√`, `^` and `$refs` —
`~(phi * phi + 1)`, `~√5 * $a1`, `~(1 / $a1)`, `~(x + 1) * (x - 1)`.
Arithmetic stays exact: φ² renders `(3 + √5)/2`, φ+1/φ renders `√5`,
and `phi^n` is Fibonacci-exact (to φ⁷⁸, the f64 coefficient horizon).
`$refs` create normal dependencies; empty cells read as 0. Unknown
names (`x`) stay symbolic. A `:num` printf format applies to the
numeric projection (`φ` with `%.4f` shows `1.6180`); without one the
cell shows the exact form. Requires `source "deft/sheet/sheet-sym"`
in the workbook prog (the template includes it for new workbooks).
- Cell values are deft data: numbers, strings, booleans, maps, vectors.
- References: `$a1`, `$b5`, … resolve to those cells' latest values
(referencing an empty cell yields nil — guard with `[or $a1 0]`).
Bare Excel-style refs work too: `=A1*2`.
- Feeds: `[sub "btc/price"]` reads the latest value published on that
pub/sub channel. When a message lands, the cell and everything downstream
recompute and re-publish.
- Live cells: a `# every N` suffix re-evaluates the cell every N seconds
(sync deft only — `/proc`, sqlite, math): `=[gc/stats] # every 5`. The
marker is INPUT syntax only — it is stripped into the cell's `every`
metadata (shown on the meta line), never stored in or displayed as part
of the source. Content edits keep the interval; manage it with
`/Cell Every…` (0 clears).
- Shared defs: `<name>.dfs/prog.dft` (next to the workbook's snapshot DB)
loads into the calculation sandbox at open (`/File Program` opens it in
the editor; saving reloads it live). Its source is snapshotted along
with the workbook on every save, and a timeline restore writes the
matching program back.
- Function library (`deft/sheet/sheet-fns`): the prog template starts
with `source "deft/sheet/sheet-fns"` — deft's textual-include statement
— which loads the bundled library of spreadsheet functions (sum, avg,
count, rounding, text/date helpers, …) into the same namespace as your
defs. Defs written below the `source` line override library functions.
Existing workbooks: add the line at the top of the prog file.
- Ranges in formulas expand to their live values: a single cell → scalar,
one row or one column → a flat vector (`=[sum A1:A10]` passes one
list), a 2D block → a vector of rows. Library aggregates flatten any
of these shapes via `sheet/values`, so `=[sum A1:A10]` and
`=[sum A1:C3]` both just work; empty cells are nil (aggregates skip).
`:` is the only range separator (`..` is left free for deft's spread
syntax).
- Errors show as `ERR …` in the cell; cycle cells are marked without
evaluating.
- Formatting — see Formatting: number formats (printf
specs via deft's `format`) and left/center/right alignment, per cell,
per range, or as whole-column/whole-row overlays.
- Watches (`/Cell Watch…`): an expression that runs whenever the
cell's live value changes — user edits, upstream recomputes, feed
ticks, `# every` ticks. It runs in the calculation sandbox (so
prog-file defs like `rds-set` are in scope) with `$new` and `$old`
bound: `[rds-set "Ha" $new]` pushes every change to Redis. Empty
submit clears the watch. Watch errors surface on the status line,
never in the cell. Scrubbing the timeline never fires; restores/opens
fire only when a recompute genuinely changes a value.
## Programmatic control (prog.dft)
`prog.dft` defs — and cell formulas — can manipulate the workbook itself
through the `[sheets/…]` user API, referred into the calculation sandbox
at open (the same namespace as the sheet-fns library — no `source` line
needed). `[sheet/…]` remains as the legacy spelling of the same
functions. Every call is one undo step.
def seed {|| [sheets/set-cells %{ "A1" "Item" "B1" "Cost" }] [sheets/fill "A2" @{ @{"rent" 1200} @{"food" 400} }] [sheets/set-cell "B4" "=[sum B2:B3]"] [sheets/set-format "B2:B4" %{ :num "%.2f" :align :right }] [sheets/append-row @{"total" [sheets/cell-value "B4"]}] # log rows [sheets/set-column-width "B" 12] }
- Writes — `sheets/set-cell` (typed-input source), `sheets/set-cells`
(bulk map), `sheets/fill` (a value block from an anchor),
`sheets/append-row` (one row below a column's last used cell),
`sheets/clear-cell`, `sheets/clear-range`.
- Reads — `sheets/cell-value`, `sheets/cell-meta` (source, kind, value,
error, effective format, every/watch/pub, table info, refs, names),
`sheets/cells` (sorted addresses, optional range filter),
`sheets/get-range`, `sheets/named-ranges`.
- Formats — `sheets/set-format` (cells in a range),
`sheets/set-column-format` / `sheets/set-row-format` (overlays that cover
empty cells), `sheets/set-column-width`. Patch maps take
`%{:num "%.2f" :align :left|:center|:right}`; `:default` clears.
- Cell settings — `sheets/set-every` (`# every N` seconds),
`sheets/set-watch` (`$new`/`$old` bound), `sheets/set-publish`
(the `sheet/<wb>/cells` feed; an optional trailing name overrides the
feed key so chart series read `rss` instead of `H14`).
- Named ranges — `sheets/define-range`, `sheets/remove-range`,
`sheets/get-range`.
- Tables — `sheets/table`, `sheets/table-rows`, `sheets/table-cols`,
the view verbs (`sheets/table-transpose` / `-hide-cols` / `-filter` /
`-view`), `sheets/table-spill`, `sheets/table-refresh(-all)` and
`sheets/table-owner` (see "Tables" above).
- Workbook — `sheets/save` (a snapshot, like Ctrl+S),
`sheets/workbook-name`, `sheets/prog-file` / `prog-source` /
`write-prog` / `reload-prog`.
Writes from a formula or watch run after the current recalculation pass
(the status says `(queued)`), so they can't corrupt an in-flight recalc.
Bad addresses, format patches and intervals return `%Err` instead of
raising.
## Selecting ranges
- shift+arrows (or click-drag) select a range from the cursor;
ctrl+shift+arrows extend it to the edge of the data block (the
anchor stays put; repeat to extend across discontiguous blocks). Plain
arrows drop the selection and move, and ctrl+arrows drop it and jump
to the edge of the data block (ADD mode keeps the selection).
- ctrl+click / ctrl+drag add a non-contiguous region (Excel-style);
ctrl+click inside a selected region removes that region. shift+click
extends the active region to the clicked cell.
- Shift+F8 toggles ADD mode (the mode indicator shows `ADD`):
shift+arrows start a new region and plain arrows move the cursor without
dropping the selection, so several regions can be built entirely from
the keyboard. Esc clears the selection and leaves ADD mode.
- The control panel shows the selection as an address list (`B2:D4,F6`).
Range commands take the whole selection: `/Range Erase` and
`/Range Format` apply to every region, `/Copy` and `/Move` use the
selection's bounding box with only the selected cells travelling
(holes stay empty), `/Worksheet` Insert/Delete merge the selected
rows/columns into spans, and `/Range Names Name…` names the active
region (a name is a single rect). `Delete` clears every selected
region.
## Tables (spilled results)
A cell whose expression produces `%Table{:name "NAME" :value rows}` spills
the result over the cells below/right of it as virtual members (no
per-cell storage — the data lives in the anchor cell's result and its
source, e.g. a database). The name is obligatory and is auto-registered
as a named range over the table's region on every evaluation, so `$NAME`
always tracks the dataset's size — charts/subscribers get change events
when it grows or shrinks.
- `=[table "USERS" @{ @{1 "alice"} @{2 "bob"} }]` — a flat vector spills
across one row, nested rows spill as a block; ragged rows pad with nil;
`nil` gives an empty (degenerate) table whose name still resolves.
- The anchor cell's value is its top-left element; every member reads as an
ordinary cell: `=[$b2]`, `=[myfn $b2 $c2]`, `=[sum A1:C20]` all work.
The whole dataset rides the named range: `=[sum $USERS]`.
- Members can't be edited (`cell is part of the table at A1`); erasing a
table's anchor removes the table; a second table (or a manual named
range) claiming a taken name errors `#NAME!`; a table whose region
collides with real cells or an earlier table errors `#TABLE!`.
- Spilled members count as filled cells for navigation: `Ctrl`+arrows
and `End`+arrow jumps stop at the spill's edges instead of skipping
over the table.
- Copying a section of a spill copies computed values only: members
paste as literals (`Ctrl+C` on a member copies its value; with a
selection active, `Ctrl+C` copies the WHOLE selection as a block and
`Ctrl+V` pastes its shape at the cursor, re-selecting what it pasted),
and when the selection includes spill members the anchor copies as a
value too, so the pasted block never re-spills. A selection of just the
anchor still copies its formula, so `/Copy` can duplicate a table
(rename the copy's table name).
- Snapshot/scrub, `get-range` RPC, and charts read spilled members
transparently.
- Kernel caveat (open-repo): deft-ify'd expansions and sandbox
compiles top out well below the 450-cell formula cap (~a few dozen to a
hundred cells in one expression) — keep formula ranges over tables
modest, or read whole datasets through the named-range RPC path.
### Query tables (async)
`sheet/dbquery` (from the sheet-fns library) is an `@async` function: it
returns a Future that resolves to the query's matrix — await it where you
call it:
=[table "USERS" [await [sheet/dbquery "data/app.db" "select id, name from users order by id"]]] # async
Cells whose expression calls `sheet/dbquery`, carries an `[await …]`, or a
`# async` marker evaluate asynchronously: the sync recalc skips them,
they run as a coroutine on the app runtime's event loop (the query itself
rides the sqlite executor threads — the UI never waits on the database),
and when the result lands the value spills and dependents recompute.
Combined with `# every N` a table re-queries on a cadence:
=[table "PRICES" [await [sheet/dbquery ".deft/btc.db" "select ts, price from ticks order by ts desc limit 500"]]] # async # every 30
The optional third argument is a bind-params vector or
`%{ :headers true :params @{} }` (a header row of column names prepended).
### Table views (pivot-style derives)
`%Table` is a real `deftype` — view verbs derive NEW tables (the
snapshot is never mutated) and record what was applied as recipe
metadata (`:hidden-cols`/`:filter`/`:transposed`). Because a named
range reads as a plain row matrix inside a formula, the verbs work both
as methods on a table object and as plain functions over matrices:
=[table "EU" $USERS(hide-columns @{"region"})] # postfix on the range matrix =[table "TT" [transpose $USERS]] # bracket form, same fns =[table "FF" $USERS(filter-rows "\$age > 30")] # infix expression over $columns =[table "CH" [table "W" rows %{ :headers true }](hide-columns @{0} |> transpose)] # chain
- `transpose` — flip axes (ragged rows nil-pad; headers do not survive).
- `hide-columns cols` — drop columns by header-row name or 0-based
index, scalar or vector. Unknown columns error the cell.
- `filter-rows spec` — a predicate fn (a `%{col val}` row map when the
table has `:headers true`, else the row vector), ANDed
`%{ :col :op :value }` triples (`= != < <= > >=`), or an infix
expression string over `\$`-prefixed column names (needs a header
row). Name-based specs keep the header row; a row whose predicate
errors is dropped.
- `col-names` — the header row as strings.
- `table "NAME" value %{ :headers true :source … }` — the wrapper takes
opts: `:headers` marks row 0 as column names, `:source` attaches
provenance metadata. Wrapping a derived view re-names it for
spilling.
The `sheets/` API drives the same transforms by NAME (move-proof —
like `sheets/table-owner`) from prog code, cells, RPC and agents:
`sheets/table` (the live Table object), `sheets/table-rows`,
`sheets/table-cols`, `sheets/table-transpose`,
`sheets/table-hide-cols`, `sheets/table-filter`,
`sheets/table-view` (spec map `%{ :filter :hide :transpose }`, fixed
pipeline filter → hide → transpose).
`sheets/table-spill "NAME" "CELL" src` spills a derived table in one
call as a LIVE expression (re-derives when the source refreshes — the
spilled cell is registered as a reader of the source name and a
dependent of its anchor). src forms: a spec map
`%{ :table "SRC" :hide … }`, a call-expression string, or a literal
rows matrix (a frozen copy — no refresh propagation):
[sheets/table-spill "EU" "D2" %{ :table "USERS" :hide @{"region"} }] [sheets/table-refresh "USERS"] # EU re-derives via the dep graph
Refresh: `/Data Refresh` (or `Ctrl+R`) re-runs the datasource
behind the table under the cursor — query tables re-query, derived
views follow; `/Data Refresh All` re-runs every table anchor.
Programmatically: `sheets/table-refresh "NAME"` (also accepts an anchor
cell id), `sheets/table-refresh-all`.
## Formatting
- Number formats are printf-style specs evaluated with deft's
[`format`](https://deft/help/format) at draw time — stored values stay
raw (recalc, `get-range` RPC and publishing all see the unformatted
number): `%.2f` → `3.14`, `%05d` → `00042`, `%+.1f` → `+3.1`, and
literal text around the verb works (`$%.2f`). Formats apply to numeric
values only; strings pass through untouched.
- Alignment: left / center / right overrides the type default
(numbers right, text left). Centered and right-aligned text never
spills into neighbouring cells.
- Precedence, per property: cell format → row overlay → column
overlay → default. A cell that sets only alignment still inherits the
column's number format.
- `/Range Format` — `Number…` (prompts for the spec; empty submit
clears), `Left`, `Center`, `Right`, `Clear`. Works selection-first or
POINT mode like the other range commands, and applies to the
existing cells in the range — empty cells carry no metadata, so
format those regions with the column/row overlays below.
- `/Worksheet Column Format…` and `/Worksheet Row Format…` — number
format for a whole column/row; the `Align` submenus set alignment.
These overlays cover empty and future cells too.
- `/Copy` and `/Move` carry cell formats with the cells; editing a
cell's contents keeps its format; erasing a cell drops it. Inserting
or deleting columns/rows shifts the overlays and widths with the data.
- Column formats, row formats and column widths persist in every
snapshot; older snapshots (saved before the feature) simply load with
none.
## Undo and redo
`Ctrl+Z` / `Ctrl+Y` (or `/Worksheet Undo` / `Redo`) step back and forward
through your commands — one undo step per command: cell edits and
clears, paste, `/Copy`, `/Move`, `/Range Erase`, `/Worksheet Insert` /
`Delete`, range/column/row formatting, column widths, named ranges, and
cell settings (watch, every, publish).
- The document state (cells, named ranges, format overlays, column
widths) is snapshotted before each command and rebuilt on undo, so
formulas recompute — undoing an edit to `A1` also reverts everything
that reads `$a1`. The cursor returns to where the change was made.
- Not covered: cursor movement, scrolling, the snapshot timeline
(scrub/restore are the history for that), prog-file edits, and
`/Worksheet Erase` (which clears the history along with everything
else).
- The history also clears on workbook open and timeline restore, and is
capped by the `sheet/undo-max` config key (default 200; 0 disables
undo).
## The / menu
`/` opens the classic command menu on the control panel's second row —
move with the arrow keys, jump with an item's first letter, Enter selects,
Esc backs out one level. The third row describes the highlighted item.
- `/Worksheet` — Undo/Redo (Ctrl+Z/Ctrl+Y — one step per command,
see Undo and redo), Column (Set-Width live-adjusts
the current column with the arrows, Reset restores defaults,
Format… sets a printf number format for the whole column,
Align sets whole-column alignment, Insert inserts blank
columns at the cursor — point or type the span; cells shift
right/down, refs follow, ranges spanning the insert expand, cells
pushed off the grid are dropped — and Delete removes columns —
cells shift left/up, refs into the deleted region become `#REF!`
errors, ranges shrink), Row (Format… / Align — same rules for
rows, Insert / Delete), Erase (whole workbook), GoTo
- `/Range` — Erase (POINT a range), Format (Number… / Left / Center
/ Right / Clear for the range's existing cells), GoTo, Names
(manage named ranges: `Name…` POINTs/types a range then prompts for
the name, `Delete…` picks a name to remove, `GoTo` jumps the cursor
to a named range's top-left — F3 in READY picks a name to jump to,
F5 accepts names alongside cell addresses, and F3 while pointing
inserts a name into the formula/range)
- `/Copy` — point a source range, then a destination: a single cell
copies once, a destination the source's size copies once, and an exact
multiple of the source tiles to fill it (non-multiples error).
Relative refs shift per copy; `$`-anchored refs stay put. Spilled
table members copy as their computed values (see Tables)
- `/Move` — point a source range, then a destination: the block lands
with its top-left at the destination (point or type a single cell);
relative refs inside shift, external refs into the block follow
- `/Cell` — Edit, Clear, Delete (Left / Up: remove the selected cells
and pull the cells to the right / below back into the gap; refs into
the removed band become `#REF!`), Publish toggle, Every…, Watch… (fires
an expression with `$new`/`$old` when the value changes)
- `/File` — Save, Save As… (save a copy under a new name and switch to
it in the same panel — same name grammar as New…; the copy starts from
the current state with a fresh timeline and the original keeps its own;
submitting the open sheet's own name is a plain save), Open… (pick a
sheet directory and open it in its own
panel — an already-open sheet is focused instead of duplicated), New…
(prompt for a name and open it in a new panel), Close (save this sheet
and start a new empty one in the same panel — the app stays open),
Program (edit in the editor), Reload (force a
prog-file reload now — editor saves reload automatically, Reload is the
deterministic fallback)
- `/Snapshot` — Take, Restore…, Scrub, Live
- `/Data` — Recalc
- `/Quit`
POINT mode: while a command waits for a range, arrows extend the
highlight from the anchor (ctrl+arrows jump the corner to the block edge),
typing enters a range directly (`A1:B10` or a defined name), Enter accepts,
Esc cancels.
## Keys
| Key | Action |
|-----|--------|
| `/` | open the command menu |
| typing | edit the cursor cell (VALUE mode) |
| arrows / PgUp / PgDn / Home / End | move the grid cursor |
| `Ctrl`+arrows | jump to the edge of a data block: from a filled cell to the block's last cell, from a blank cell to the next filled cell (or the grid edge) |
| `Ctrl`+`Shift`+arrows | extend the selection to the edge of a data block (anchor stays put; repeat to keep extending) |
| arrows (while entering) | point at a cell when an operand is expected (see below), else commit the entry and move |
| `Enter` · `Tab` | edit the cursor cell · commit + move right |
| `F2` | edit the existing text; F2 again forces POINT mode (arrows point anywhere in the formula) |
| `F3` | NAME: pick a defined range name — insert it into the formula/range being pointed, fill the GOTO / Range-Name prompt, or jump to it in READY |
| `Escape` | cancel the pointed ref (pointer returns to the formula cell) / cancel edit / menu / point, drop the selection and ADD mode, or return to live while scrubbing |
| `Delete` | clear the selection (every region) or the cursor cell |
| `shift`+arrows / shift+click | select a range (extends the active region) |
| `ctrl`+click / ctrl+drag | add a non-contiguous region (ctrl+click a selected region removes it) |
| `Shift+F8` | toggle ADD mode for keyboard multi-selection |
| `F4` | cycle the ref's anchors — `B2` → `$B$2` → `B$2` → `$B2` → `B2` (the pointed ref, the ref at the edit cursor, or the typed ref in the GOTO prompt) |
| `F5` | go to a cell (`A1`, `B2:D4`) or a defined range name — works while editing/pointing too |
| `F9` | recalculate every cell |
| `[` / `]` | scrub the snapshot timeline (graphs follow) |
| `Ctrl+Z` / `Ctrl+Y` | undo / redo the last command |
| `Ctrl+S` | save the workbook |
| click / drag / wheel | move cursor, extend point ranges, scroll |
## Point editing (Lotus 1-2-3 semantics)
While entering a formula, arrows pick cells whenever the text before the
cursor expects an operand: after an operator (`= + - * / ( [ , :`), at a
call head (`=[sum` — the first argument) or at an argument boundary inside
an unclosed call (`=[sum 5 ` — a trailing space). The pointed ref splices
into the formula and keeps updating as you move; typing an operator
re-arms pointing from the pointer's cell, so `+` → `→` → `+` → `→`
builds `+B1+C1`.
- `.` anchors a range (`B2:D4` grows with the arrows); pressing `.` again
cycles the free corner clockwise — arrows then reshape the range from
the new corner.
- `F3` NAME shows the defined names: picking one splices `$NAME` into the
formula (replacing an active pointed ref) and ends pointing.
- `F4` cycles the pointed ref's anchors: `B2` → `$B$2` → `B$2` → `$B2`
→ `B2`; with no active point it cycles the ref at the edit cursor
(a range cycles both endpoints together).
- `Esc` cancels the pointed spec: the ref is removed and the pointer
returns to the formula cell.
- `Enter`/`Tab` commit; the pointer moves down/right from the formula
cell, not the last pointed cell.
- While pointing: `Home` jumps to A1, `End` + arrow (or `Ctrl`+arrow)
jumps to the last cell of a data block (or the next block / grid edge),
`End Home` jumps to the bottom-right of the used range, `F5` (goto)
moves the pointer — by cell address or defined name (`F3` picks the
name, `F4` cycles a typed ref's anchors) — and the ref
follows.
- A complete operand followed by an arrow (e.g. `=[sum b1` with no
trailing space) commits the entry, Lotus-style; press `F2` to force
pointing instead.
## Storage
A workbook is a `<name>.dfs/` directory you place anywhere in the
project — there is no storage root (`.deft/` holds metadata only). A
reference is either a bare name, resolved in the CWD (`main` →
`./main.dfs`), or a path (`sheets/main` → `./sheets/main.dfs`).
- `<name>.db` — SQLite WAL snapshot store, the single source of truth.
Every save (Ctrl+S) appends a snapshot row of the full state (cells +
named ranges + format overlays + column widths + program source); the
latest row is the current save. Manual snapshots share the same
timeline (labelled by time, saves by `save HH:MM:SS`); `[`/`]` scrub
across all of them.
- `prog.dft` — the live-editable program file (watched, reloaded on save).
Each sheet opens in its own panel (multiple sheets side by side): `/File`
Open…` recursively lists the `.dfs` directories found under the project`
directory (CWD) — nested sheets show as `sheets/main` — and launches a
fresh sheet panel for the chosen one. `/File New…` takes a name (a typed
path places it anywhere; a bare name lands next to the current sheet),
`/File Save As…` saves the current sheet under a new name in the same
panel (a fresh snapshot store; the original keeps its history), and
`/File Close` saves the sheet and leaves an empty new sheet (`untitled`,
`untitled-2`, …) in the SAME panel — the app stays open. The panel is
tagged `sheet:<dir>` so opening an already-open sheet focuses it instead
of duplicating it, and the open workbook's path is recorded in config
`sheet/last-workbook` (`.deft/config.dft`) so the next launch
auto-opens it.
Legacy layouts migrate on first open: a workbook directory left in the
old `.deft/sheets/` root moves to the resolved location, a bare
`.deft/sheets/<name>/` directory is renamed to `<name>.dfs/`, flat files
(`<name>.db`, `<name>.prog.dft` — next to the workbook or in the old
root) move into the directory, and the old `<name>.dft` — which always
held the latest save — is imported once as the newest snapshot (label
`migrated`) and then left in place as a backup, no longer written.
## Debugging
The app starts a debug repl on 127.0.0.1:9312 at launch — attach a
shell (or the editor) with *Session → Attach Runtime → "Connect to
port…"*. The shell's roster-driven attach (broadcast) also finds the
panel without the port. Override or disable via the config key
`sheet/debug-port` (0 disables).
Handy from the attached repl: `[keys _cells]` (every cell id), `dbg-cells`
(same), `_wb-name`, `[get-cell :a1 "value"]`, `[reload-prog]`.
## Publishing
After each recalc the workbook publishes
`sheet/<name>/cells` → `%{ :ts :seq :cells %{ a1 42 … } }` — but only
cells that opted in via `/Cell Publish` (publishing is off by
default; toggling re-sends the full snapshot, so a cell turned off
simply drops out of the next one). A published cell may carry a
publish name (`sheet/set-publish "H14" true "rss"`), which becomes
the feed key — chart legends show the metric, not the address.
Scrubbing replays snapshots through the same channel, so a subscribing
chart app follows the timeline automatically.
## Named ranges
A named range binds a name to an `A1:B10`-style rect (`/Range Names…`).
Names are 1-40 chars of letters/digits/`_`/`-` and must not look like a
cell ref. They persist with the workbook and are the chart-facing data
path:
- They follow their cells: `/Move` carries a name whose rect is
entirely inside the moved selection (single cells included) and its
formula readers recompile against the new location; `/Worksheet`
Insert`/`Delete` and `/Cell Delete Left|Up` shift names in the affected`
band (a name fully swallowed by a delete is removed). A name only
partially covered by a move is left untouched.
- RPC (any app can call them; the runtime name is the panel's app
name, e.g. `"sheet"`). The full workbook surface is exposed — cells,
formatting, cell settings, named ranges, tables, prog file, save. RPC
args travel as source text (strings, numbers, booleans, nil, keywords),
so collection-shaped methods take flat/CSV forms over RPC and rich
forms in-process:
- `[await [rpc "sheet" "get-range" "sales"]]` — rows of values (list
of lists, nil for empty cells; accepts a name or `A1:B10`).
- `[await [rpc "sheet" "named-ranges"]]` — sorted
`%{:name :range :topic}` maps.
- `[await [rpc "sheet" "workbook-name"]]` — the workbook this runtime
has open (`"main"`, …); lets a consumer (charts) target one of
several sheet panels.
- `[await [rpc "sheet" "define-range" "sales" "A1:B10"]]` /
`[await [rpc "sheet" "remove-range" "sales"]]`.
- `[await [rpc "sheet" "set-cells" "A1" "Item" "B1" "Cost"]]` — flat
address/source pairs (the map form works in-process).
- `[await [rpc "sheet" "set-format" "B2:B4" "%.2f" :right]]` — num
spec + align keyword, either optional.
- `[await [rpc "sheet" "cell-meta" "B4"]]` / `"cells"` / `"fill"` /
`"append-row"` / `"clear-range"` / `"set-column-width"` /
`"set-every"` / `"set-watch"` / `"set-publish"` / `"save"` — the
same verbs as the `[sheets/…]` API.
- `[await [rpc "sheet" "table-rows" "USERS"]]` and the other
`table-*` verbs — `table-filter` takes an infix expression string
(`"$age > 30"`), `table-hide-cols` a `"a,b"` string,
`table-view` flat `filter hide transpose` args.
- Change events: `sheet/<workbook>/range/<name>` receives
`%{:ts :seq :range "A1:B10"}` whenever a recalc, scrub, restore or
workbook open touches cells inside the range (a `:removed true` event
fires when the name is deleted). Events are signals only — refetch
the data via `get-range`. Named ranges ignore the per-cell `:pub`
toggle; naming a range is the authorization.
## Example: sysmon live model
`examples/sysmon/prog.dft` turns a workbook into a live system/memory
monitor: one `# every N` cell samples process RSS/VmData, CPU, GC heap,
per-app heap (`tui/app-threads`) and the workbook's own stored-cell
count, checks per-metric boundary cells (min / max / max Δ per tick),
and — instead of accumulating a growing history in the grid — records a
workbook snapshot every `snapshot every (ticks)` ticks (`B3`, 0
disables). The snapshot timeline is the time-series store: scrub it with
`[`/`]` (or reopen the workbook later) and the recorded samples replay.
Breaches record to `SYSMON_ALERTS` — a single latest-alert row (per-app
heap/CPU alerts carry the app name; process metrics log `sys`) whose
history lives in the snapshot timeline — and publish on `sysmon/alert`;
the worst status cell is watched and re-broadcasts transitions. The
blocks are movable: cross-block refs are fully anchored (`$A$23` —
`$a23` pins only the column, and a vertical /Move would rewrite the
row and stall the table), the bounds block is read through the
`SYSMON_BOUNDS` named range (names follow /Move; inline range literals
in formulas do not), and `sheet/table-owner` resolves a table's live
anchor wherever it lands.
```sh
mkdir -p sheets/sysmon.dfs
cp <packages>/sheet/examples/sysmon/prog.dft sheets/sysmon.dfs/prog.dft
````
Open the `sysmon` sheet (`/File Open…` → `sheets/sysmon`) or run it
headless (`deft packages/sheet/src/run.dft sheets/sysmon`) — the prog auto-seeds an
empty workbook. One region per concern: the tick anchor IS the
`SYSMON_NOW` table (metric | value | min | max | Δ | trend | status),
`SYSMON_APPS` (per-app heap/peak/cpu plus the gc/stats columns gc |
freed | next-gc) and `SYSMON_ALERTS` (time | app | metric | kind |
value | bound — latest alert, single row; app is `sys` for process
metrics). Each table starts with a header row inside the range,
is titled by the label cell one row above its anchor, and charts
labels bar/pie series by column (`rss·value`, `heap·peak`, …) and skips
the header when drawing lines. The published `I17:I23` strip (labelled
at `I16`) mirrors the latest values as real published cells — spilled
table members can't carry publish flags — and gives charts' cells mode
its own live rolling window. Replacing the prog in an existing
workbook? Run `[sysmon-reseed]` once (debug repl) to clear the old
layout and re-seed. See `workspace-sysmon.dft` for a
sheet+charts workspace and the manual page
`help/manual/07-TUI/08-sheet-sysmon.md`.
## Headless daemon (model only)
The calculation model lives in `model.dft` (cells, dependency graph,
recalc, tables, async cells, watches, intervals, named ranges,
snapshots, undo, RPC, tickers); the visual panel in `sheet.dft` sources
it. The model runs with no panel at all:
deft packages/sheet/src/run.dft [workbook] [--backend NAME] [--dsn DSN]
- Boots the workbook — a reference (bare name in the CWD or a path) or
the config `sheet/last-workbook` default, else `main` — marks the
runtime live so async cells (`# async`, `sheet/dbquery`, `[await …]`)
evaluate, and starts the tickers: `# every N` intervals and the
auto-snapshot cron.
- `sheet/auto-snapshot-s` (config; default 0 = off) snapshots the
live workbook every N seconds, so the timeline accrues autonomously.
- The debug repl listens on `127.0.0.1:9312` (`sheet/debug-port`); a
shell/editor can attach via Session → Attach Runtime, or any process
can drive the `@rpc` surface.
- Stop with Ctrl+C; the cron snapshots are the durable record. Open the
same workbook later in retro/retra and scrub the timeline to browse
what accrued.
### Snapshot backends
Snapshots are recorded data (cell sources plus the values they held
at save time); the timeline replays them frozen, with no re-evaluation.
The store is pluggable via the `SnapshotStore` contract
(`snapshot-store.dft`) with three implementations:
| Backend | Store | Notes |
|---------|-------|-------|
| `sqlite` (default) | `<wb>.dfs/<wb>.db` (workbook dir anywhere) | the panel's local store |
| `postgres` | shared `sheet_snapshots` table, `(workbook, ts)` key | for headless daemons |
| `memory` | process memory | tests / ephemeral runs |
Select with `--backend` (daemon) or config:
- `sheet/snapshot-backend` — `"sqlite"` (default), `"postgres"`, `"memory"`.
- `sheet/snapshot-dsn` — Postgres DSN (e.g.
`postgres://user:pass@host:5432/db?sslmode=require`); also read from
`SHEET_SNAPSHOT_DSN`, and `pg/connect` honors the `PG*` env vars.
- `sheet/snapshot-table` — table name (default `sheet_snapshots`,
sanitized to `[A-Za-z0-9_]+`). One table holds many workbooks, keyed
by a `workbook` column.
deft packages/sheet/src/run.dft prices \
--backend postgres --dsn 'postgres://user:pass@host:5432/deft'
The Postgres backend awaits the native pg client, so it is intended for
the daemon; panels stay on sqlite (a panel configured for Postgres would
block its thread on every snapshot). The VM shutdown save is skipped for
non-sqlite stores — the cron snapshots are the durable path.
Cells that pull from databases/HTTP are just cells — e.g.
=[table "PRICES" [await [sheet/dbquery "prices.db" "select * from t"]]] # every 60 # async
re-derives the table every minute while the daemon runs.