Files in the top-level directory from the latest check-in
- Qt
- SQLite
- test_resources
- README.md
SQLeet Architecture
A complete spreadsheet engine in pure SQLite, designed for Qt
QAbstractItemModel(or similar) integration.
File Structure
Total SQLite Backend Size: 3,264 lines (2,539 algorithmically pertinent non-comment lines).
Schema Complexity: 145 top-level SQLite statements:
- 33 Tables
- 42 Views
- 52 Triggers
- 18 Indexes
| File | Purpose | Lines |
|---|---|---|
| spreadsheet.sql | Core schema: tables, indexes, config | 445 |
| views.sql | Qt model views, conditional formats, filtering | 310 |
| triggers.sql | Display formatting, formula cascade, setData, undo | 467 |
| operations.sql | Row/col ops, copy/paste, clear, fill | 708 |
| textsheet.sql | Text sheets (tokens/paragraphs/wrap) + unified named ranges & cross-kind live embedding | 1,334 |
Load order (each file layers on the previous):
spreadsheet.sql → views.sql → triggers.sql → operations.sql → textsheet.sql
textsheet.sql is optional: omit it for a grid-only build. The Qt frontend loads
all five. Smoke tests: test.sql (grid) and textsheet_test.sql (mixed sheets),
each a self-checking PASS/FAIL suite — grep FAIL the output to gate health.
Qt Interface — What the Client Calls
The external code is paper-thin. The entire Qt model boils down to these queries:
data(index, role) → single cell
SELECT value FROM v_data
WHERE sheet_id=:s AND row=:r AND col=:c AND role=:role;
data() → viewport batch (preferred, one query for visible area)
SELECT row, col, display, edit_value, font, bg_color, fg_color, text_align, ...
FROM v_viewport
WHERE sheet_id=:s AND row BETWEEN :r1 AND :r2 AND col BETWEEN :c1 AND :c2;
setData(index, value, role)
INSERT INTO v_set_data(sheet_id, row, col, role, value) VALUES(:s,:r,:c,:role,:val);
setFormula()
INSERT INTO v_set_formula(sheet_id, row, col, formula, formula_sql) VALUES(:s,:r,:c,:f,:fsql);
rowCount() / columnCount()
SELECT row_count, col_count FROM v_model_info WHERE sheet_id=:s;
Structural Operations
INSERT INTO v_op(sheet_id, op, target, count) VALUES(:s, 'insert_row', 5, 1);
INSERT INTO v_op(sheet_id, op, target, count) VALUES(:s, 'delete_col', 3, 2);
INSERT INTO v_copy(sheet_id, start_row, start_col, end_row, end_col) VALUES(...);
INSERT INTO v_paste(sheet_id, target_row, target_col) VALUES(...);
INSERT INTO v_clear(sheet_id, start_row, start_col, end_row, end_col, mode) VALUES(...);
INSERT INTO v_new_workbook(name) VALUES('My Workbook');
Grid Named Ranges (named_ranges)
-- Define (or redefine) a named range; dependent formulas update live.
INSERT INTO v_define_name(sheet_id, name, start_row, start_col, end_row, end_col) VALUES(...);
INSERT INTO v_drop_name(sheet_id, name) VALUES(:s, 'Sales');
SELECT name, a1_notation FROM v_named_range WHERE sheet_ref=:s; -- list
Names work in formulas both as ranges (=SUM(Sales)) and as scalar values
(=B2*Rate). The formula bar shows the name; redefining the range updates
dependents, and insert/delete row/col shifts named ranges like everything else.
All logic is in SQLite; the Qt client just dispatches these INSERTs and offers
a Name Box + Names menu (Define from Selection, Name Manager).
Text Sheet Queries
The Qt side reads v_sheet_meta once per sheet to learn kind/wrap_width/show_grid,
then uses the matching views. Grid path is unchanged.
-- Sheet kind dispatch
SELECT kind, wrap_width, show_grid FROM v_sheet_meta WHERE sheet_id=:s;
-- Text viewport (parallel to v_viewport for grids)
SELECT row, col, display, edit_value, item_flags, x_offset, ...
FROM v_text_viewport
WHERE sheet_id=:s AND row BETWEEN :r1 AND :r2 AND col BETWEEN :c1 AND :c2;
-- Text single-cell read
SELECT value FROM v_text_data WHERE sheet_id=:s AND row=:r AND col=:c AND role=:role;
-- Unified read (dispatches on kind internally — works for either sheet type)
SELECT value FROM v_data_all WHERE sheet_id=:s AND row=:r AND col=:c AND role=:role;
-- Text setData (delete/replace/split/append dispatched by trigger on content)
INSERT INTO v_text_set(sheet_id, row, col, role, value) VALUES(:s,:r,:c,:role,:val);
-- Create a text sheet
INSERT INTO v_new_text_sheet(workbook_id, name, position, wrap_width) VALUES(1,'Notes',1,80);
-- Bulk-ingest text (tokenizer + wrap run entirely in SQLite)
INSERT INTO v_text_ingest(sheet_id, content) VALUES(:s, :text);
-- Cross-kind named range (obj_ranges — distinct from grid named_ranges; see below)
INSERT INTO v_define_object(workbook_id, scope_sheet_id, name, target_kind, ref_sheet_id,
start_row, start_col, end_row, end_col) VALUES(...); -- grid rect
INSERT INTO v_define_object(workbook_id, scope_sheet_id, name, target_kind, ref_sheet_id,
start_tok_id, end_tok_id) SELECT 1,2,'Title',1,2, MIN(tok_id), MIN(tok_id)+1
FROM text_tokens WHERE sheet_id=2; -- token span
-- Live embedding (host displays live value of source range, any-kind → any-kind)
INSERT INTO v_embed(host_kind, host_sheet_id, host_row, host_col, src_range_id) VALUES(...);
-- Invalidation/recalc queue (also driven automatically by triggers)
SELECT * FROM v_embed_queue;
flags(index) reads the item_flags column from v_text_viewport (35 = normal,
33 = non-editable ref token). The Qt frontend adds a sheet tab bar (mixed
kinds), a Sheet menu (New Grid/Text Sheet), and an Embed menu (Name
Selection → v_define_object, Embed Named Range Here → v_embed). A second thin
QAbstractTableModel (TextSheetModel) reads v_text_data/v_text_viewport and
writes v_text_set; the view swaps models on tab change. Everything else stays in
SQLite.
Text Sheets & Cross-Sheet Embedding (textsheet.sql)
A workbook can hold two kinds of sheet in one database: grids (kind=0) and
text sheets (kind=1). Every table still keys on sheet_id, so the Qt layer
keeps calling the same model views; they dispatch on kind internally.
Two-Level Canonical Model
A text sheet stores canonical paragraphs of tokens:
- Paragraphs — the logical row a newline creates. Each has a stable surrogate
para_idand a visiblepara_ord. Splitting identity from order makes anchors durable: a reorder or split never invalidates an existingpara_id. - Tokens — one space-delimited word/symbol per token, with a stable
tok_idand a paragraph-localtok_ord. Spaces are not stored; they are the implicit separator, encoded as the+1in the width model. Paragraph continuity is preserved by token membership (para_id), so wrapping may move a token to any display row without rewriting content.
Wrap Projection
Wrapping is a backend projection, not a rewrite. A materialized text_wrap
cache holds one row per visible token, addressed by paragraph-local display row.
The global Qt (row, col) is computed as paragraph.disp_row_start + local_row,
where disp_row_start is a maintained prefix sum of paragraph line counts. Storing
a paragraph-local row is the key trick: a single-paragraph edit rebuilds only its
wrap rows — no renumbering of the whole cache, no PK-collision gymnastics.
The wrap algorithm (v_rewrap_para) is a greedy line-break recursive CTE that
walks tokens in order carrying (local_row, disp_col, x_offset). A new line starts
when x_offset + width + next_width > wrap_width and the line already has a token
(so an over-wide token sits alone rather than looping). It writes text_wrap,
updates line_count, recomputes the sheet-wide disp_row_start prefix sum and the
sheet's reported extents.
Schema Objects (textsheet.sql)
| Object | Role |
|---|---|
sheets.kind |
0 = grid / 1 = text; one kind per sheet for life |
text_sheet_cfg |
Per-text-sheet wrap_width, show_grid, monospace, default_align |
text_paragraphs |
Canonical logical rows: stable para_id, visible para_ord, materialized line_count + disp_row_start prefix sum, paragraph style |
text_tokens |
Canonical content: stable tok_id, paragraph-local tok_ord, display text, ref_range_id (embedded ref), optional measured_width; generated width and kind |
token_roles |
Sparse per-token Qt roles (ToolTip/CheckState/UserRole+…) |
text_wrap |
Materialized wrap projection: (para_id, local_row, disp_col) → tok_id, x_offset, width; WITHOUT ROWID |
obj_ranges |
Unified cross-kind named ranges: grid rect / token span / paragraph span, with CHECKed anchor columns |
embeds |
Unified live-embedding graph: host (grid cell or text token) → source range, with cached resolved_value + is_dirty queue slot |
text_edit_scratch |
One-row scratch capturing a target's stable ids up front so multi-statement edit triggers are wrap-independent and collision-safe |
Generated columns on text_tokens earn their keep:
width INTEGER GENERATED ALWAYS AS (COALESCE(measured_width, length(text) + 1)) STORED,
kind TEXT GENERATED ALWAYS AS (CASE WHEN ref_range_id IS NOT NULL THEN 'ref' … END) STORED
width = displayed text width + 1 unit (the +1 is the implicit trailing
separator). Default is char-cell/monospace (length+1), fully SQL-native; if Qt
has real font metrics it pushes measured_width and the generated column prefers
it. STORED so it indexes and never recomputes on read.
Tokenizer
v_text_ingest runs a recursive-CTE scan that splits on space and newline
simultaneously, inlined on the recursive column (the recursive table may not be
referenced from a subquery). Newlines start paragraphs; space runs collapse to one
separator; punctuation rides with its word, so the token stream round-trips to the
original line via group_concat(text,' '). Example:
INSERT INTO v_text_ingest(sheet_id, content)
VALUES(2, 'The quick brown fox, jumped!' || char(10) || 'Second paragraph here.');
-- tokens: The | quick | brown | fox, | jumped! | Second | paragraph | here.
-- wrap@18: row0 "The quick brown" row1 "fox, jumped!"
-- row2 "Second paragraph" row3 "here."
Inline Live References
A token with ref_range_id set is an embedded ref. Its text is the cached
resolved value, so DisplayRole and width/wrap work uniformly. EditRole
renders the name of the source range (render-on-read), not the value — the same
render-on-read philosophy as v_formula_text for grid formulas. Editing the token
detaches it (drops the ref, keeps your literal). Plain tokens never touch the
resolver, so ordinary editing stays fast. The delegate marks ref tokens non-editable
(item_flags = 33).
Width Strategy
width = COALESCE(measured_width, length(text) + 1) — a STORED generated
column. Char-cell by default (SQL-native, deterministic); measured_width lets Qt
supply real font-metric advance (the one justified transient transport, since SQLite
cannot measure glyphs). Exposed to the delegate via SizeHintRole ("width;1") and
x_offset (UserRole 259) for sub-cell painting.
Two Independent Named-Range Systems
SQLeet has two separate named-range systems — do not conflate them:
| System | Table | Used by | Shift handling |
|---|---|---|---|
| Legacy grid ranges | named_ranges |
Grid cell formulas (=SUM(Sales), =B2*Rate) |
operations.sql shift triggers |
| Unified cross-kind ranges | obj_ranges |
Live embedding (embeds) across grid ↔ text |
textsheet.sql trg_objrange_* triggers |
Grid formulas reference named_ranges only; the embedding system references
obj_ranges only. textsheet.sql adds its own INSTEAD OF INSERT triggers on
v_op (trg_objrange_{insert,delete}_{row,col}) that shift grid obj_ranges and
grid-cell embed hosts on insert/delete row/col, using the exact shift/shrink math
operations.sql applies to named_ranges. Text ranges anchor to stable surrogate
ids (tok_id/para_id), so other edits never move them; endpoint deletion
re-anchors to the nearest surviving neighbour in document order (para_ord,
tok_ord).
Live Embedding
embeds is the cross-sheet reference graph: a host location displays the live
resolved value of a source obj_ranges span, any-kind → any-kind.
- Invalidation:
trg_tok_invalidate_embedsandtrg_cell_invalidate_embedsmark dependent embeds dirty when a source token or cell changes. - Resolution:
trg_embed_resolverecomputesv_range_valueand pushes the new value to the host, with value-change guards so the resolver writing a token back never loops. A text host receives rewrittentext(which reflows its paragraph); a grid host receivescomputed_value(which the existing display formatter picks up). - Cycles:
v_embed_cyclesdetects cross-kind cycles. The engine relies on the client to refuse a commit that surfaces a flagged cycle.
-- name a grid range and a text token span, then embed each into the other (live):
INSERT INTO v_define_object(workbook_id,scope_sheet_id,name,target_kind,ref_sheet_id,
start_row,start_col,end_row,end_col) VALUES(1,1,'Totals',0,1,0,1,1,1); -- grid B1:B2
INSERT INTO v_define_object(workbook_id,scope_sheet_id,name,target_kind,ref_sheet_id,
start_tok_id,end_tok_id) SELECT 1,2,'Title',1,2, MIN(tok_id), MIN(tok_id)+1
FROM text_tokens WHERE sheet_id=2;
INSERT INTO v_embed(host_kind,host_sheet_id,host_row,host_col,src_range_id)
SELECT 1,2,0,99,(SELECT id FROM obj_ranges WHERE name='Totals'); -- grid range → text token
INSERT INTO v_embed(host_kind,host_sheet_id,host_row,host_col,src_range_id)
SELECT 0,1,0,3,(SELECT id FROM obj_ranges WHERE name='Title'); -- text range → grid cell
SELECT * FROM v_embed_queue; -- the invalidation/recalc queue
Observed live propagation: changing grid B2 200→999 updated the embedded ref
token to 100 999; editing text token Quarterly→Annual updated grid D1 to
Annual report.
Textsheet Index Strategy
| Index | Why | Partial? |
|---|---|---|
text_wrap PK (para_id, local_row, disp_col) |
Clustered viewport range scan | — (WITHOUT ROWID) |
idx_wrap_tok |
Token → current visible position ("reveal token") | no |
idx_para_order (sheet_id, para_ord) |
Ordered paragraph access | unique |
idx_para_disp (sheet_id, disp_row_start) |
Map a global display row → owning paragraph | no |
idx_tok_order (para_id, tok_ord) |
Canonical order; wrap walk join | unique |
idx_tok_sheet (sheet_id, tok_id) |
Invalidation membership lookups | no |
idx_tok_ref |
Only the few embedded-ref tokens | partial WHERE ref_range_id IS NOT NULL |
idx_objrange_name |
Name resolution | no |
idx_embed_src |
Dependents of a source range | no |
idx_embed_dirty |
The recalc queue is the small dirty subset | partial WHERE is_dirty = 1 |
idx_embed_token |
Text-host embeds only | partial WHERE host_kind = 1 |
Every hot access path is either a clustered PK range scan (viewport) or an equality probe on a covering/partial index (invalidation, resolution, reverse lookup).
Textsheet Trigger Catalog
| Trigger | What it does |
|---|---|
trg_rewrap_para |
Greedy wrap + line_count + prefix sum + extents — the reflow primitive |
trg_tok_{ins,upd,del}_rewrap |
Any token content change reflows its paragraph (and the old one if a token moved) |
trg_tok_invalidate_embeds |
Marks embeds dirty when a source token changes |
trg_cell_invalidate_embeds |
Marks embeds dirty when a source grid cell changes |
trg_embed_resolve / trg_embed_resolve_insert |
Resolve + push new value to host; value-change guard prevents loops |
trg_reanchor_token_ranges |
On endpoint deletion, re-anchors to nearest surviving neighbour in (para_ord, tok_ord) |
trg_objrange_{insert,delete}_{row,col} |
Makes grid obj_ranges and grid-cell embed hosts follow structural edits |
trg_cfg_rewrap |
Wrap-width change reflows the whole sheet (projection only) |
trg_new_text_sheet |
Create text sheet via v_new_text_sheet |
trg_text_ingest |
Bulk tokenize + wrap via v_text_ingest |
trg_text_set_* |
setData dispatch: delete / single-word replace / multi-word split / append / style |
trg_text_{split,join}_para |
Paragraph split and join operations |
trg_text_rebuild |
Full idempotent rebuild from canonical tables |
trg_define_object |
Create/update a cross-kind named range via v_define_object |
trg_embed_into_{text,grid} |
Create an embedding into a text token or grid cell via v_embed |
Materialized vs. Computed Live
Materialized (random-read-hot): width, kind (generated columns), text_wrap,
line_count, disp_row_start, embeds.resolved_value.
Computed live (views): v_text_grid, v_text_data, v_range_value,
v_named_object, v_embed_queue, v_embed_cycles. Render-on-read keeps derived
text correct under structural edits, exactly like v_formula_text for grid formulas.
Known cost: trg_rewrap_para recomputes the whole-sheet prefix sum
(O(paragraphs)) on every paragraph edit — a deliberate correctness-first choice.
Optimization path: bulk-mode flag to skip the prefix pass during v_text_ingest,
then run v_text_rebuild once; and/or revert to incremental delta-bumps now that
creation paths set disp_row_start consistently.
Formula Engine — Two Tiers
# Cell recalc flowchart — top-down, explicit placement
# --- Center spine (top → bottom) ---
A: box "Cell Value Changes" fit at (0cm, 12cm)
B: diamond "Trigger fires?" fit at (0cm, 9.8cm)
C: box "Mark dependents dirty" fit at (0cm, 7.6cm)
D: diamond "Formula type?" fit at (0cm, 5.4cm)
arrow from A.s to B.n
arrow from B.s to C.n
arrow from C.s to D.n
# --- Tier branch (3-way split from D) ---
E: box "Tier 1a: Linear eval" "SUM(coeff x ref_value)" fit at (-5.5cm, 2.2cm)
F: box "Tier 1b: Range eval" "SUM/AVG/MIN/MAX/COUNT" fit at (0cm, 2.2cm)
G: box "Tier 2: Client loop" "SELECT * FROM v_recalc_queue" fit at (5.5cm, 2.2cm)
arrow from D.sw down 0.6cm then left until even with E then to E.n \
"formula_terms" small above
arrow from D.s down 0.6cm then to F.n \
"formula_ranges" small above
arrow from D.se down 0.6cm then right until even with G then to G.n \
"formula_tokens" small above
# --- Merge Tier 1a/1b; separate Tier 2 path ---
H: box "UPDATE computed_value," "is_dirty=0" fit at (0cm, -0.6cm)
I: box "Client executes" "pre-built SQL" fit at (5.5cm, -0.6cm)
arrow from E.s to H.n
arrow from F.s to H.n
arrow from G.s to I.n
# --- Recursive trigger feedback loop ---
arrow from H.w left 1.2cm then up 6.8cm then to C.w \
"recursive_triggers=ON" small above
→ /pikchrshowThe client parser is the one piece of non-trivial application logic. It uppercases the formula (so entry is case-insensitive) and classifies it:
- Tier 1b — a single range aggregate, e.g.
=SUM(A1:A10)→formula_ranges:[('SUM', A1:A10)] - Tier 1a — a strict linear combination of
coeff*refterms, e.g.=A1+2*B1-C3→formula_terms:[(A1, +1.0), (B1, +2.0), (C3, -1.0)] - Tier 2 — anything else (cell×cell products, division, parentheses, standalone constants):
=B2*C2,=A1/B1,=(A1+B1)*2
Tier 1 handles the common cases entirely inside triggers — zero client
involvement. A change marks dependents dirty (cell_deps) and recursive_triggers
chains the re-evaluation automatically.
Tier 2 is decomposed into an ordered token stream in formula_tokens
(cell references + literal chunks). The tokens are the single source of truth:
- computation —
formula_sql(a pre-compiled SQL expression) is generated from the tokens; the viewv_recalc_queueresolves topological order via recursive CTE, and the client loop is trivial (drainRecalcQueuejust executes each returnedupdate_sqluntil no dirty Tier 2 cells remain) - display — see render-on-read below
- structural edits — insert/delete shift each token's
ref_row/ref_col(just likeformula_terms), and regenerateformula_sql, so a Tier 2 formula survives row/column edits
Render-on-read formula text
The human-readable formula (what EditRole / the formula bar shows) is never
stored as frozen text — it is derived on read by v_formula_text from the
decomposition (formula_ranges / formula_terms / formula_tokens). Because
structural ops shift the decomposition, the displayed references stay correct
with no extra bookkeeping: inserting a row turns =SUM(D2:D4) into =SUM(D3:D5)
and =B2*C2 into =B3*C3 automatically.
The same render-on-read philosophy extends to text sheets: EditRole on a ref
token derives the source range name from obj_ranges, not the cached value.
Key SQLite Features Used
| Feature | Where | Why |
|---|---|---|
WITHOUT ROWID |
cells, cell_styles, cell_deps, formula_terms, formula_ranges, formula_tokens, text_wrap | Clustered B-tree on PK — viewport range scans are sequential |
| Partial indexes | idx_cells_dirty, idx_cells_formulas, idx_tok_ref, idx_embed_dirty, idx_embed_token |
Only index the small fraction of dirty/formula/ref/embed rows |
| Covering index | idx_cells_viewport |
Viewport queries never touch main table pages |
STORED generated columns |
text_tokens.width, text_tokens.kind |
Computed at write time, indexed, never re-evaluated on read |
INSTEAD OF triggers on views |
v_set_data, v_op, v_copy, v_paste, v_clear, v_text_set, v_embed, v_define_object | Command dispatch — all operations are INSERT INTO v_xxx(...) |
| Recursive triggers | Formula cascade, embed resolution | PRAGMA recursive_triggers=ON — Tier 1 formulas and embed pushes chain automatically |
| Recursive CTE | v_recalc_queue, v_circular_refs, v_rewrap_para (wrap walk), v_text_ingest (tokenizer) | Topological sort, cycle detection, greedy line-breaking, tokenization — all without procedural code |
Ordered group_concat |
v_formula_text, formula_sql regeneration |
Render-on-read formula text rebuilt from the decomposition in token order |
UPSERT |
v_set_data trigger | INSERT...ON CONFLICT DO UPDATE for cell creation/update |
| JSON functions | cell_styles.font, borders, conditional formats | Complex structured data without extra tables |
printf() |
Display formatting trigger | Number formatting ($, %, precision) |
strftime() |
Date display | Date formatting with configurable patterns |
generate_series() |
Fill operations | Range generation without external code |
| WAL mode | Global pragma | Concurrent readers during recalc |
Two-Pass Negate Trick
Row/column insertion on WITHOUT ROWID tables can't use ORDER BY in UPDATE (unsupported). SQLeet solves PK collision during shifts with a negate trick:
-- Pass 1: make all affected rows negative (guaranteed unique)
UPDATE cells SET row = -(row + 1) WHERE row >= :target;
-- Pass 2: restore with offset
UPDATE cells SET row = (-row - 1) + :count WHERE row < 0;
This technique is reused throughout textsheet.sql wherever token ordinals need
gap-safe shifting (e.g. multi-word setData that re-tokenizes in place).
Query Logging (--log)
Launch with --log (or --log=/path/to/log.db, default sqleet-log.db) to trace
every statement on the main connection into a second, ATTACHed database
(logdb.query_log). Each row is timestamped and serialized:
| ts | action | sql | result_rows | results | micros |
|---|---|---|---|---|---|
2026-…T00:39:02.085 |
setFormula(r=4, c=3) = '=SUM(D2:D4)' |
SELECT row, col, … FROM v_data … |
4 | [1\|3\|100\|100] ; [2\|3\|200\|200] ; … |
31 |
action is the app command that prompted the query — nested queries (recalc
drain, trigger-driven SQL) inherit the top-level action, so you can see exactly
what each statement was doing. Capture uses sqlite3_trace_v2; writes go through
a separate connection (a trace callback must not touch the connection it traces).
There's a headless --selftest that drives the real model over the real
connection (combine with --log to capture a full session).
Smoke Test Results
Run both suites and grep FAIL:
# Grid suite (test.sql)
sqlite3 :memory: ".read spreadsheet.sql" ".read views.sql" ".read triggers.sql" \
".read operations.sql" ".read test.sql"
# Mixed-sheet suite (textsheet_test.sql — 25 assertions)
sqlite3 :memory: ".read spreadsheet.sql" ".read views.sql" ".read triggers.sql" \
".read operations.sql" ".read textsheet.sql" ".read textsheet_test.sql"
The grid suite also passes unchanged with textsheet.sql loaded — the new v_op
triggers only touch the new tables.
Grid suite (test.sql) — all ✅
- ✅ Workbook/sheet creation via
v_new_workbook - ✅ Value entry with auto type detection (text/number/boolean/date)
- ✅ Display text formatting (numbers strip trailing zeros)
- ✅
data()for DisplayRole, BackgroundRole, ForegroundRole, FontRole, ToolTipRole - ✅ Linear formula evaluation (
=A2-B2→ 400) - ✅ Range aggregate formula (
=SUM(A2:A4)→ 4500) - ✅ Tier 2 general expression (
=B2*C2→ product) compute and cascade - ✅ Cascade recalculation (change A2 → C2 and SUM both update)
- ✅ Case-insensitive formula entry (
=sum(a1:a4)≡=SUM(A1:A4)) - ✅ Render-on-read formula text stays correct after insert (
=SUM(D2:D4)→=SUM(D3:D5)) - ✅ Viewport batch query returns all display data in one call
- ✅ Row insertion with correct shifting of all references (terms, ranges, tokens, deps)
- ✅ Copy/paste
- ✅ Clear (values mode)
- ✅ Circular reference detection
Mixed-sheet suite (textsheet_test.sql) — all ✅
- ✅ Sheet kinds: grid (
kind=0) and text (kind=1) coexist in one workbook - ✅ Text ingest with punctuation + newlines; tokenization round-trips losslessly
- ✅ Wrap at
wrap_width=18:rowCount/columnCountreflect wrapped extents - ✅ Role reads: DisplayRole, EditRole, TextAlignRole, BackgroundRole, ForegroundRole, SizeHintRole
- ✅
setDatasingle-word replace in place - ✅
setDataempty string → token deletion - ✅
setDatamulti-word → re-tokenizes in place (negate-trick gap) - ✅
setDatapast-end → appends new token - ✅ Paragraph split and join
- ✅ Reflow on
wrap_widthchange - ✅ Idempotent
v_text_rebuild - ✅ Grid named range (
named_ranges) and text token span (obj_ranges) defined independently - ✅ Bidirectional live embedding with propagation (grid B2 change updates text ref token; text edit updates grid cell)
- ✅ EditRole on a ref token renders the source name (render-on-read), not the cached value
- ✅ Ref-token detach on edit (drops ref, keeps literal)
- ✅ Grid
obj_rangesand grid-cell embed hosts track insert/delete row correctly - ✅ Token-range re-anchor on endpoint deletion (nearest surviving neighbour, not global first/last)
- ✅ Cross-kind cycle detection via
v_embed_cycles - ✅
PRAGMA foreign_key_check/integrity_checkclean; notext_edit_scratchleakage