# @acheron-grid/core

An experimental headless TypeScript data-grid engine. Core owns data, selection, layout, editing, TSV clipboard operations and delta history. It has no runtime dependencies or browser/framework types.

## Build

From the engine repository root, use Node.js 22 or later:

```sh
npm ci
npm run build
npm run typecheck
npm test
```

Build runs core before Canvas. This package exports ESM JavaScript and TypeScript declarations from `dist/`. Version 0.1.0 is prepared for publication; it has not yet been published. To use the built package in another project:

```sh
npm install /path/to/acheron-grid-engine/packages/core
```

## Usage

Structured clipboard blocks can carry optional per-cell `formats` (`background`, `textColor`, `contentFormat`). The content hint is `plain`, `html` or `markdown`; core stores this sparse metadata without parsing markup. Formatted paste checks paste/writable/formatting permissions and commits values and formatting in one history entry. `format:change` events include the `paste` source for that operation. Plain TSV remains value-only. Clipboard helpers `encodeBlocks`, `decodeBlocks` and `blocksToTsv` are exported for renderer integration.

The root entry exports `createGridEngine`, `LocalDataSource`, `LocalDataView` and their public types. It runs in Node without DOM, Canvas or framework globals. The earlier @acheron-grid/core/headless subpath remains an alias. Browser rendering is provided by [@acheron-grid/canvas](../canvas/README.md).

```js
import { createGridEngine, LocalDataSource } from '@acheron-grid/core';

const engine = createGridEngine({
  columns: [{ key: 'name', title: 'Name', editable: true }],
  dataSource: new LocalDataSource([{ id: 1, name: 'Ada' }], row => row.id),
});
engine.select(0, 0);
engine.editCell(0, 0, 'Grace');
engine.copySelection(); // 'Grace'; no system clipboard access
engine.undo();
engine.destroy();
```

The engine owns selection/anchor, sparse row/column layout, text parsing, TSV operations, data commands and delta history. The Canvas grid uses this same engine. Browser focus, scrolling, editor drafts, menus, dialogs, OS clipboard and frame scheduling stay in the browser layer.

`select(rowIndex, columnIndex, extend?)` rejects invalid coordinates and returns whether selection changed; denied targets return false; `clearSelection()` clears the range. `getSelection()` and `getSelectionRange()` return copies for the active cell/range. `addSelection(rowIndex, columnIndex)` retains earlier rectangles and starts a new active range. `getSelectionRanges()` returns copies in insertion order, active range last. Extending changes only that last range; plain select replaces all ranges. Selection is bounded to 128 rectangles, allows overlaps and does not allocate per selected cell. `rows` and `columnsLayout` expose read-only `size`, `position`, `indexAt` and `range` geometry queries; mutations use `setRowHeight` and `setColumnWidth`. Columns are frozen snapshots. `getValue`, `canEdit`, `canPaste`, `canUndo` and `canRedo` provide queries. `editCell(rowIndex, columnIndex, text)` applies the same editable/parser rules as the DOM editor; `updateCells` retains its programmatic, already-validated-value semantics. `canPaste` checks the starting cell and write capability; `paste` validates the entire rectangle before writing.

An optional synchronous `onInvalidate(change)` renderer hook receives cells, selection, layout or structure notifications after committed state/history. These notifications describe repaint needs, separate from public domain events. The hook may query committed state; if it throws, the exception propagates and does not roll back an already committed mutation. `destroy()` drops the hook and history, clears selection without notification and is idempotent. Mutations/copy/paste throw after destruction; undo/redo return false. As with the browser API, source values are shallow references and external source writes are outside history. Row count changes only through structural commands; external source structure changes require a new mount.

Build/typecheck includes a separate ES2022-only TypeScript configuration with no DOM or ambient Node types. Unit tests also compile the headless dependency closure and verify it excludes browser modules.

## DataSource and mutation contracts

DataSource exposes synchronous getRowCount/getRowId/getValue and optional setValue/setValues. Row IDs remain stable through core-owned structure changes. LocalDataSource copies the row array and shallow row snapshots, with unique stable row IDs; nested values remain caller-owned.

updateCells accepts already-validated values and intentionally does not apply column parsers or the editor's editable flag. editCell and paste apply resolved editable/pasteable permissions and text parsers. All writes, including API updates and undo/redo, require writable permission. Batches with more than one changed cell require `setValues`; a source with only `setValue` can accept single-cell writes. Batch setters must be synchronous and atomic, leaving data unchanged on failure. Validation completes before writes. Duplicate updates use the last value, Object.is no-ops preserve history, and undo/redo retain at most 100 delta commands with shallow value references. External writes are outside history; replay rejects row identity/current value conflicts. Explicit resize and freeze changes share this history.

Clipboard processing is limited to 100,000 cells and 10 million UTF-16 code units. Async sources are not implemented. React and Vue adapters live in separate packages. Multiple selection ranges support packed TSV and an internal structured clipboard payload.

## Browser import migration

This unpublished preview moved createGrid, Grid and GridOptions from core to @acheron-grid/canvas. Install both local packages and change imports:

```ts
import { createGrid } from '@acheron-grid/canvas';
import type { Grid, GridOptions } from '@acheron-grid/canvas';
import { LocalDataSource } from '@acheron-grid/core';
import type { Column, CellSelection, SelectionRange } from '@acheron-grid/core';
```

The createGrid methods and behavior remain the same; see the [Canvas guide](../canvas/README.md). Core does not re-export Canvas because that would invert the dependency direction. Existing core/headless consumers continue to work.

## Capabilities and domain events

Core and Canvas options accept `permissions` (grid scope), column `permissions`, and a pure `resolveCellPermission(cell)` callback for application row/cell rules. Policies are partial booleans: `editable`, `selectable`, `copyable`, `pasteable`, `writable`, `formatting`. Any explicit false veto survives later scopes. By default selection/copy/write are allowed, while edit/paste follow column `editable`. Policies may enable edit/paste on a column with no editable opt-in; `writable: false` always denies both.

`getCellPermission(rowIndex, columnIndex)` returns a frozen resolved snapshot or throws for invalid coordinates. Setter/parser availability is separate: `canEdit` checks those too, and `canPaste` checks only the starting cell and setter availability. Paste checks every destination before any parser or setter. Copy denies the entire TSV if any cell is not copyable. Selection checks only the active endpoint, so a rectangular range may cover non-selectable interior cells. Denied pointer/keyboard targets keep the previous selection without searching for another cell.

Value API updates and value undo/redo check current writable permissions, without applying UI editable or pasteable rules. Denied batches never write or move history. `canUndo/canRedo` indicate stack availability; replay may still fail permission/conflict checks. Grid/column policies are construction snapshots; callback rules are resolved on every operation/query. Existing selection is not automatically removed on a policy change. Canvas callers can call `render()` after changing callback policy; an open editor rechecks permissions at commit. These capabilities do not replace server authorization.

Pass synchronous `onEvent(event)` to either factory. The discriminated `GridEvent` union contains:

- `cell:change`: source `api/edit/paste/undo/redo`, one batch of `{ rowIndex, rowId, columnKey, previous, value }` changes.
- `selection:change`: active `selection` and normalized `range`, plus frozen `ranges` in insertion order. Clear emits null active selection/range and an empty ranges array.
- `column:resize` / `row:resize`: `index`, `previous`, `size`; explicit resize participates in shared history.
- `lock:change`: explicit `target` and `locked` state; locks stay outside history.
- `freeze:change`: previous/current row and column prefix counts; freeze changes participate in history.
- `format:change`: source `api/undo/redo` and sparse target/patch changes; formatting shares value history.

State and history commit before renderer invalidation, then the domain event. Failures before commit and no-ops emit nothing. Both notification hooks are attempted even if one throws; the first error propagates after both, without rolling back committed state. Event envelopes/payload metadata are frozen; values remain shallow caller-owned references. Parser, resolver, setter and notification hooks cannot issue nested engine mutations; queries of committed state are allowed. Destroy silently releases hooks/history. Canvas legacy selection callbacks remain separate and are not duplicated by onEvent.

## Frozen panes and viewport geometry

Set construction options `frozenRows` / `frozenColumns` to safe integers from zero to the row/column count (default zero). They freeze leading data rows/left columns; the header is separate. Readonly getters expose current counts; `engine.setFrozen(rows, columns)` changes them atomically after validating both arguments. Resize still updates sparse sizes. Prefixes larger than the viewport are clipped, with no scrolling area on that dimension; they are not reduced automatically.

`engine.getViewport({ width, height, scrollLeft, scrollTop })` takes body client dimensions excluding header/scrollbars. All inputs must be finite and nonnegative; offsets clamp to dataset bounds. The readonly `ViewportLayout` contains normalized offsets, clipped frozen extents and at most four nonempty `regions`. Each region has `clip`, end-exclusive `rows`/`columns`, and `offsetX/offsetY` translations for axis positions.

`view.hitTest(x, y)` maps body-local coordinates to `{ row, col }` or null for blank/outside/nonfinite coordinates. `view.cellRect(row, col)` returns the full body-local rectangle and its pane clip, throwing for invalid dataset coordinates. Coordinates remain dataset indices; these helpers do not apply permissions. Frozen geometry never reads source values and bounds region work by visible indices, even if a million rows are frozen. Query a fresh viewport after scroll, viewport resize or axis mutations; it holds axis references and is not a durable layout snapshot. Canvas uses these same mappings for painting, pointer input, editor placement and menu targets.

## Multiple-range clipboard

`copySelection()` packs ranges in reading order: ranges sharing their top row and height concatenate horizontally; others stack vertically and pad to the widest range. `paste(text)` broadcasts the TSV matrix at each target range start. `copySelectionBlocks()` / `pasteSelectionBlocks(text)` preserve relative offsets and gaps for one target, or pair equal counts of source/target ranges. Conflicting overlap, malformed payloads, bounds, limits, permissions and parsing failures are rejected before one atomic write/history command. Editing and history operate on the active cell/data, preserving the range list. Clear/destroy discard all ranges. Selectable permission still checks only target endpoints.

## Runtime frozen changes

Runtime frozen changes preserve values, selection/ranges and data undo history. No-op/invalid changes emit nothing; changes emit `freeze:change` with `previousRows`, `previousColumns`, `rows` and `columns`, after layout invalidation. Canvas setters throw while editing or destroyed. The cell menu freezes prefixes through the clicked row/column, both through the cell, or unfreezes rows/columns/table. Menu freeze actions are disabled if the requested prefix would consume the viewport; the API still allows all-frozen layouts. These actions do not lock editing.

## Cell, row, column and table locks

`setLocked(target, locked)` adds/removes a value lock. Targets are `{ scope: 'table' }`, `{ scope: 'row', rowIndex }`, `{ scope: 'column', columnIndex }`, or `{ scope: 'cell', rowIndex, columnIndex }`, with zero-based coordinates. `isLocked(target)` reports the explicit lock at that scope. Any applicable lock vetoes writable/editable/pasteable; selection/copy retain their permissions. Unlocking a cell does not clear a row/column/table lock; unlocking the table removes only its table flag.

Locks use sparse sets and preserve selection and data undo history, but are themselves outside history and disappear on destroy/remount. API/edit/paste/undo/redo all enforce current locks. `lock:change` carries a frozen `target` and `locked`, after layout invalidation; no-op/invalid requests emit nothing. Canvas setters throw while editing or destroyed. Right-click provides Lock/Unlock actions and indicates when the cell is read-only.

Host policies retain their false veto after unlock. Set `allowLockChanges: false` at construction to disable management in the API/menu; `canManageLocks()` reports availability. Use grid/column/resolver permissions for mandatory application restrictions. These local controls do not replace server authorization. Use the formatting capability for color restrictions; persisted/user-specific locks are not provided here.

## Sparse cell formatting

`engine.format(targets, patch)` atomically applies colors to cell/row/column/table targets (the lock target shapes) or `{ scope: 'range', range: { startRow, endRow, startColumn, endColumn } }`. Range ends are inclusive. `patch` accepts `background` / `textColor` as hex colors (#RGB/#RGBA/#RRGGBB/#RRGGBBAA) or null. A property set to null resets that color to the theme within the target; a null patch removes that target's explicit overlay and may reveal earlier formatting. `getFormat(row, col)` returns a frozen effective snapshot; `canFormat(targets)` checks current permission availability.

The last applied property wins across intersecting targets. Updating only background preserves the previous priority of text color. Multiple targets are one atomic command. Colors are sparse overlays, not per-cell arrays; static permissions need only column checks, while a dynamic resolver must validate every targeted cell. Each formatted cell lookup scans O(formatted areas), intended for modest local formatting sets. Formatting reads/writes no source values and does not reorder rows.

`formatting` defaults to true and has the same false veto as other capabilities, independently of writable and value locks. Formatting and value edits share the 100-command undo/redo history, with current formatting permissions checked on style replay. `format:change` carries source api/undo/redo and frozen changes containing target/previous/value patches, after layout invalidation. Styles are local and cleared on destroy/remount; persistence, arbitrary CSS, fonts, borders and conditional formatting are not implemented.

## Explicit rectangular selection

`engine.selectRange({startRow, endRow, startColumn, endColumn}, mode?)` accepts `'replace'` (default), `'add'` or `'extend'`. Replace discards existing ranges; add retains the previous active range; extend updates the active rectangle while keeping retained ranges. The 128-range cap applies atomically to add. It updates selection atomically and emits at most one selection event. Bounds are inclusive safe integer coordinates. Start and end cells must both be selectable; interior checks remain operation-specific. The active cell is the start corner, anchor the end corner. This is sparse and reads no source values, even for a whole million-row column. Invalid input or denied endpoints preserve prior selection. Plain navigation returns to its normal single-cell behavior.

## Local row views

`new LocalDataView(source, {sort: {columnKey, direction: 'asc' | 'desc'}, filters: [{columnKey, query, operator: 'contains' | 'equals' | 'not-empty' | 'empty'}]})` builds an immutable index projection for synchronous local data. All filters combine with AND; contains/equals compare case-insensitive text, and empty means null/undefined/empty string. Empty text criteria do not restrict rows. Numeric pairs sort numerically; other values use a case-insensitive numeric-aware Intl.Collator. Nullish values sort last in either direction; equal values preserve source order.

LocalDataView remains an immutable projection that delegates reads/writes to source indices. For a live view, pass the original source to createGridEngine and use engine.setView(criteria), or initial options.view. The view getter is immutable; rowCount is the visible count, sourceRowCount is the original count, and getRowId(displayIndex) returns stable identity. Edits, paste, undo and redo automatically reapply criteria. Selection, locks, colors and heights remain in source coordinates and follow their records, including hidden rows. Public cell/selection/format/lock/resize APIs use display coordinates; permission resolvers and non-selection domain events use source coordinates. Selection events use display coordinates. Clear criteria before structural commands or structural undo/redo. Freeze remains a positional prefix, clamped for the visible view. A hidden selected record reappears when criteria are cleared; visible fragments are selected without including hidden intervening rows.

This is O(source rows) index memory/filter work and O(matching rows log matching rows) sorting; it does not preserve virtualization while evaluating the full source. Only use it for local datasets sized for that cost. Source row order/count/identities must remain stable; column keys must be supplied by the application. No remote paging, async criteria or multi-column sort is implemented.

## License

Licensed under [MIT](LICENSE). Copyright (c) 2026 Hao Duong. No npm release is available. Lucide assets belong to the separate Canvas package and are not dependencies of this headless core.


## Structural commands and layout history

insertRows(beforeIndex, rows) accepts {id, values} snapshots. deleteRows(indices), moveRows(indices, beforeIndex), insertColumns(beforeIndex, columns), deleteColumns(indices) and moveColumns(indices, beforeIndex) use zero-based coordinates. Move boundaries refer to the current order before removing selected items; relative order is stable, including noncontiguous selections.

Row operations require optional synchronous DataSource.getRow() and atomic spliceRows(splices). Sequential splices must succeed entirely or leave the source unchanged. getRow includes fields outside visible columns. Column insertion/deletion requires addColumns(keys, defaults?), which atomically permits missing fields and initializes own Column.defaultValue values. Existing hidden fields take precedence. Deleted column definitions retain source fields. LocalDataSource applies registered defaults to subsequent inserted/restored rows; insertion/default initialization share one history command. LocalDataSource implements these contracts. LocalDataView intentionally does not: clear sorting/filtering before changing source structure.

The columns and rowCount getters reflect current structure. Sizes, locks and format areas follow surviving row IDs/column keys. Rectangles split into multiple rectangles when necessary instead of selecting or formatting intervening items. Changes exceeding 128 selection ranges fail before writing. Selection on deleted items moves to a remaining selected rectangle or clears. Table/column formatting covers inserted rows; previous row/cell/range formatting excludes them. Frozen prefixes remain positional counts, clamped after deletion.

canChangeStructure(request) is an optional host veto for API, undo and redo. Requests expose axis/kind/count/indices/beforeIndex, the exact target order (-1 means a new item), and target column definitions. Table locks deny structural changes; row deletion additionally requires writable permission and applicable locks. Column deletion checks column locks and host policy; it removes definitions while retaining data fields. Insertion/reorder do not derive permission from editable. Locks remain outside history: current surviving locks are mapped during replay, and restoring deleted items restores their former locks.

Changes emit structure:change with source api/undo/redo, rowCount and columnKeys after a structure invalidation containing old-to-new axis maps. Structural operations, value edits, formatting, explicit resize and freeze share the 100-command stack. Resize/freeze replay events retain their original shapes. measureRowHeight is a renderer operation outside history; isRowHeightManual distinguishes explicit row sizes. Initial columnWidths configures key-based sizes without commands.

Structural history retains row identity arrays, coordinate maps, sparse metadata snapshots and affected shallow rows. This uses O(rows + columns + metadata) memory per command, plus range/format fragmentation. Local sequential splices copy row arrays per contiguous block. This is intended for local datasets, not remote transactions or an unbounded million-row structural history. External identity/count changes or changed rows a replay would replace cause an error and leave history available for retry. Nested values remain caller-owned.


Live local views use O(rows) index memory/filter work and O(matches log matches) sorting per value command. Sparse geometry is rebuilt from resized rows. Large source selections can split into many visible fragments when sorted/filtered. External source writes require setView(engine.view) to refresh membership/order; remote paging and async filtering are not implemented.
## Merged cells and manual row groups

```ts
engine.mergeCells({ startRow: 1, endRow: 2, startColumn: 0, endColumn: 1 });
engine.unmergeCells({ startRow: 1, endRow: 2, startColumn: 0, endColumn: 1 });
const groupId = engine.groupRows(3, 8);
engine.setGroupCollapsed(groupId, true);
engine.setGroupCollapsed(groupId, false);
engine.ungroupRows(groupId);
```

Merged cells use the top-left value; other source values remain intact. `getMerge(row, col)` returns a span in visible coordinates. Numeric viewport geometry and hit testing resolve the anchor, including when it is scrolled outside the viewport. Selection expands to include complete intersecting spans. Copy exports the anchor and blank covered cells. Paste rejects nonempty writes to covered cells; blank clipboard placeholders preserve hidden values. Explicit `updateCells` still addresses source values through visible coordinates.

Manual row groups can be nested or disjoint. Their first row remains visible when collapsed. `getRowGroups()` and `getMergedCells()` return immutable **source-coordinate** metadata; `getRowSourceIndex(visibleRow)` maps visible rows. Editing, selection, locks and row sizes retain record identity across collapse/expand. Grouping, ungrouping, collapse/expand and merge/unmerge share undo/redo. Use `allowMerging`, `allowRowGrouping` or `canChangeLayout(request)` to control capabilities. Merge/unmerge require writable cells; row grouping respects table/row locks and the host veto.

Current limits: 1,024 spans/groups each; merge permission checks cover at most 100,000 cells. Sorting/filtering requires removing merges and groups. Collapsing rows that contain merged cells, or crossing a frozen boundary, is rejected. Expand all groups before structural changes. Moving an intact span/group preserves it; operations splitting it are rejected, and deletion of a member dissolves its metadata (undo restores it). Collapse rebuilds an O(row-count) local projection; metadata stays sparse and rendering remains viewport based.

## Portable layout and view configuration

```ts
import { createGridEngine, restoreGridConfiguration } from '@acheron-grid/core';

const saved = JSON.stringify(engine.exportConfiguration());
const restoredOptions = restoreGridConfiguration(
  JSON.parse(saved), applicationColumns, dataSource.getRowCount(),
);
const restoredEngine = createGridEngine({
  ...restoredOptions, dataSource, permissions: applicationPermissions,
});
```

`exportConfiguration()` returns a detached version-1 JSON-compatible snapshot of column keys/order/current widths, source frozen-row count, frozen-column count and local sort/filters. `restoreGridConfiguration(input, columns, rowCount)` validates unknown input and returns initialization options. It requires exactly the current column keys once each; unknown versions, missing/duplicate/unknown keys, invalid sizes, counts or view definitions throw before grid construction. Existing application column definitions (including parsers and permission policies) are reused; configuration cannot supply executable definitions.

Storage is host-owned; the core does not access localStorage or a server. Filter queries may contain sensitive text, so choose storage and access controls accordingly. Apply returned options to a new engine; this API does not change a mounted grid or create undo history. Configuration excludes cell data, row order/heights, default row height, merges/groups, formatting, locks, selection and history. Frozen rows are a count, not stable row identities; restore against the intended source order. Keep rowHeight and other host options explicit. Structural column additions/deletions require the host's matching schema when restoring.

## Cut and single-cell paste

A one-cell clipboard value fills every cell in the selected target range(s), including its structured cell formatting. Permissions, parsers and formatting permissions are validated before the combined write. Larger clipboard rectangles retain their existing anchor/paired-range behavior; matrix tiling is not implemented.

```ts
engine.select(0, 0);
const cut = engine.cutSelectionBlocks(); // Stages a move; source is unchanged.
engine.select(1, 1);
engine.pasteCutSelectionBlocks(cut);    // Destination write and source clear: one undo.
// engine.cancelCut();                 // Cancel without changing source data.
```

Cut is a staged move within the same engine. Source cells must be copyable and writable; paste validates destination permissions/parsers and unchanged source row IDs, column order and values before writing. A failed validation leaves both sides unchanged. Source content is cleared to `null`; source cell formatting remains, while copied formatting is applied at the destination. Overlapping source/destination cells preserve the pasted result. Undo/redo replays the combined change once. Cut from merged cells is rejected; unmerge first. Use the payload from the staged cut with `pasteCutSelectionBlocks`; ordinary `paste`/`pasteSelectionBlocks` remain copy operations. The host owns clipboard transfer and must not treat arbitrary external clipboard data as a staged move. Cross-engine/browser/application moves and matrix tiling are not supported.

Successful paste selects the full destination rectangle, including all pasted rows and columns. Failed paste leaves selection unchanged.
