A fast Zig library for building editors for screenplays, novels, and similar structured documents, as well as flat code and text documents.
  • Zig 98.9%
  • Python 0.6%
  • Shell 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 07eb4198f4 undo: report WHERE a restore wrote, so callers can patch instead of rebuild
An editor holding a flat mirror of the document — a text buffer feeding a
parser — was told only that "something changed", so every undo cost a
whole-document rebuild. The same edit typed forwards does not: the typing
path knows which block it touched. `RestoreSpan` is that knowledge on the
way back out.

Reported only for a SINGLE-entry restore. A group's entries may write
anywhere, and naming one block while others moved unseen would be worse
than saying nothing, so a multi-entry group reports null and the caller
takes its slow path — still correct, just not fast.

Measured in quote, 40k-line file, undoing one character: 42.8ms -> 30.4ms,
about a third. What remains is tree-sitter's incremental reparse, which is
identical whether the edit lands on the first block or the last, and is
therefore not something a span can help with.

Also: flat-mode anchor resolution returns the index instead of walking the
top-level array to recompute it. `topLevelDfsIndex` already documented that
"in flat mode the document index space already IS the top-level array" and
then summed strides anyway, and undo duplicated the loop rather than
calling it. Honest note: this one did not move the measurement — the walk
was not hot. It stands as a dedup, not as a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 08:02:45 +02:00
.tools gap_buffer: contentSlice — the zero-copy read the README already promised 2026-08-01 09:29:22 +02:00
docs editing: styled + structured paste primitives (insertStyledText, insertDocument) 2026-07-06 22:09:00 +02:00
example Migrate to Zig 0.16 and re-enable example app 2026-04-30 08:16:34 +02:00
fonts Add font selection, OTF/monospace tests, and proportional wrapping in example app 2026-01-30 22:58:30 +01:00
scripts setup-tools: detect checkouts with rev-parse, not [ -d .git ] 2026-07-25 23:33:04 +02:00
src undo: report WHERE a restore wrote, so callers can patch instead of rebuild 2026-08-13 08:02:45 +02:00
tests editing: styled + structured paste primitives (insertStyledText, insertDocument) 2026-07-06 22:09:00 +02:00
.gitignore block: continuation advance is max(indent, indicator), not sum 2026-05-16 13:31:12 +02:00
build.zig Add document-model fuzzer; fix 6 undo/tree bugs it found 2026-05-21 20:12:50 +02:00
build.zig.zon deps: pin demo dvui to a commit — branch-ref pins go hash-stale 2026-07-06 22:24:59 +02:00
CLAUDE.md docs: repoint links to git.urverk.org 2026-07-25 11:41:32 +02:00
context.md marks: generalise LinkRuns into per-block, app-defined marks 2026-08-08 03:02:12 +02:00
DESIGN.md marks: a point inside a deleted span goes with the text 2026-08-08 10:42:38 +02:00
LICENSE license: use legal name in the copyright line 2026-07-25 12:00:56 +02:00
README.md marks: generalise LinkRuns into per-block, app-defined marks 2026-08-08 03:02:12 +02:00

StoryKit

A Zig library for building text editors for screenplays, novels, and similar structured documents.

UI-agnostic — provides the document model, text layout, and editing logic. Your app handles rendering.

Features

  • Block-based document model — flat (scripts) or hierarchical (novels with chapters)
  • Nested blocks — blocks can have children (nested lists, blockquotes containing paragraphs), with parent pointers and depth-first iteration
  • Gap buffer text storage — O(1) insert/delete at cursor
  • Character styling — bold, italic, underline, strikethrough, uppercase, highlight, superscript/subscript (run-length encoded, 16-bit packed)
  • Text wrapping — monospace (character count) or measured (pixel widths via app callback), with prose and code (Helix-style) wrap modes
  • Word navigation — Unicode-aware word boundary detection with customizable extra word characters
  • Editor-primitive helpers — Kakoune-style word objects, ASCII sentence boundaries, single-byte char find, substring search, char-category scans (text_layout)
  • Cursor & selection — full navigation, multi-block selection, per-block layout params, sticky-x across lines, grid-aware movement
  • Selection history< / > snapshot stack for stepping back/forward through past cursor + selection states (SelectionHistory)
  • Change detectionedit_version counter for O(1) dirty checking; content-hash dirty tracking (markClean/isDirty) that correctly detects when edits cancel out
  • Stable cursor identity — ID-based block addressing that survives structural mutations
  • Undo/redo — abstract handler interface with built-in snapshot implementation, coalescing, operation grouping, and recursive child snapshots
  • Tree diffing — O(n) ID-based diff between document trees (insert, delete, modify, reparent, reorder)
  • Cached metrics — subtree block count, character count, and height aggregated per block
  • Font metrics — built-in TTF/OTF parser for glyph advance widths and GPOS kerning
  • Annotations — key-value metadata on blocks and sections (notes, synopses, comments)
  • App-defined types — register your own block types (Dialogue, Action, Heading) and section types (Chapter, Act, Scene)

Quick Start

Add StoryKit as a dependency in your build.zig.zon, then in build.zig:

const storykit_dep = b.dependency("storykit", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("storykit", storykit_dep.module("storykit"));

Usage

Create a document

const storykit = @import("storykit");

var doc = storykit.Document.initFlat(allocator);
defer doc.deinit();

// Register app-defined block types
const action_type = try doc.registerBlockType(.{ .name = "Action" });
const dialogue_type = try doc.registerBlockType(.{ .name = "Dialogue" });

// Add blocks
_ = try doc.appendFlatBlockWithContent(action_type, "INT. COFFEE SHOP - DAY");
_ = try doc.appendFlatBlockWithContent(dialogue_type, "Hello, world.");

Edit text

All editing functions take a document and cursor, return an updated cursor, and record undo automatically.

var cursor = storykit.Cursor.at(0, 0, 0); // section 0, block 0, offset 0

// Insert text
cursor = try storykit.insertText(&doc, cursor, "The quick brown fox");

// Backspace
if (try storykit.deleteBackward(&doc, cursor)) |c| cursor = c;

// Split block (Enter key)
cursor = try storykit.splitBlock(&doc, cursor);

// Delete selection
const sel = storykit.Selection.fromRange(
    storykit.Cursor.at(0, 0, 5),
    storykit.Cursor.at(0, 0, 10),
);
if (try storykit.deleteSelection(&doc, sel)) |result| cursor = result.cursor;

Paste

// Plain multi-line paste (splits on '\n'), one undo unit:
cursor = try storykit.insertLines(&doc, cursor, "line one\nline two\nline three");

// Inline styled paste into the current block (no new blocks) — carries its own
// style + link runs, offset-relative to the inserted text; redo-lossless:
cursor = try storykit.insertStyledText(&doc, cursor, "bold bit", &styles, &links);

// Structured paste of already-built styled blocks (rich clipboard), one undo unit.
// Each block keeps its type + StyleRuns + Marks + annotations + children.
const frag = [_]*storykit.Block{ block_a, block_b };
cursor = try storykit.insertDocument(&doc, cursor, &frag, .block_distinct);

Rich editors should paste through insertStyledText / insertDocument rather than inserting plain text and re-applying styles afterward — the latter loses styling on redo (the re-styling isn't in the same undo unit). InsertMode picks the boundary behaviour: .inline_fuse (fragment ends fuse into the surrounding block, for flowing text) or .block_distinct (each fragment is its own block, adopting an empty target — for whole elements).

Style text

// Toggle bold on a range
const bold_sel = storykit.Selection.fromRange(
    storykit.Cursor.at(0, 0, 0),
    storykit.Cursor.at(0, 0, 5),
);
try storykit.applyStyleToSelection(&doc, bold_sel, .{ .bold = true });

// Query style at a position
const block = doc.getFlatBlock(0).?;
const style = block.getStyleAt(3); // returns CharStyle

Layout and wrapping

Two modes, same result type:

// Monospace — pure character-count wrapping (screenplays)
const layout = try block.getLayout(.{ .sizing = .{ .monospace = 72 } });

// Measured — pixel-width wrapping (proportional fonts)
const layout = try block.getLayout(.{ .sizing = .{ .measured = .{
    .max_width = 600,
    .measure_fn = myMeasureFn,
    .measure_ctx = @ptrCast(&my_ctx),
} } });

// Code-aware wrapping — break at punctuation, push long tokens to right margin
const layout = try block.getLayout(.{
    .sizing = .{ .monospace = 80 },
    .wrap_mode = .code, // default: .prose
});

Both produce a LayoutResult with wrapped lines:

const wrap = layout.wrap;
for (0..wrap.line_count) |i| {
    const line_text = wrap.lines[i];          // text slice for this line
    const line_start = wrap.line_starts[i];   // byte offset in block
    // render line_text at appropriate y position
}

// Find which line a cursor is on
const pos = wrap.findCursorLine(cursor.offset);
// pos.line, pos.col, pos.byte_col

For measured mode, pixel-coordinate helpers use the layout's char_widths:

// Click hit-testing: pixel x -> byte offset
const offset = storykit.byteOffsetForX(wrap, line_idx, click_x, layout.char_widths.?);

// Cursor rendering: byte offset -> pixel x
const cursor_x = storykit.xForByteOffset(wrap, line_idx, byte_offset, layout.char_widths.?);

Layout is cached per block and invalidates automatically on any edit.

MeasureFn callback

For measured mode, you provide a function that fills per-character advance widths. This lets the library use your renderer's exact measurements:

fn myMeasureFn(ctx: *anyopaque, text: []const u8, styles: *const storykit.StyleRuns, out: []f32) void {
    var byte_pos: usize = 0;
    var i: usize = 0;
    while (byte_pos < text.len and i < out.len) {
        const char_style = styles.getStyleAt(byte_pos);
        const font = pickFont(char_style.bold, char_style.italic);
        const cp_len = storykit.text_layout.utf8CharLen(text[byte_pos]);
        const end = @min(byte_pos + cp_len, text.len);
        out[i] = measureGlyph(font, text[byte_pos..end]);
        byte_pos = end;
        i += 1;
    }
}

A built-in adapter is available if you use StoryKit's FontMetrics directly:

var regular = try storykit.FontMetrics.fromFont(allocator, regular_ttf, 16.0);
var bold = try storykit.FontMetrics.fromFont(allocator, bold_ttf, 16.0);
var italic = try storykit.FontMetrics.fromFont(allocator, italic_ttf, 16.0);
var bold_italic = try storykit.FontMetrics.fromFont(allocator, bold_italic_ttf, 16.0);

const fonts = storykit.StyleFonts{
    .regular = &regular,
    .bold = &bold,
    .italic = &italic,
    .bold_italic = &bold_italic,
};

const layout = try block.getLayout(.{ .sizing = .{ .measured = .{
    .max_width = 600,
    .measure_fn = storykit.fontMetricsMeasure,
    .measure_ctx = @ptrCast(&fonts),
} } });

Cursor navigation

var cursor = storykit.Cursor.at(0, 0, 0);
const params = storykit.LayoutParams{ .sizing = .{ .monospace = 72 } };

// Line movement (wrapping-aware, crosses block boundaries)
// Uses LayoutParamsFn so each block can have its own layout
const params_fn = storykit.Cursor.LayoutParamsFn.uniform(&params);
_ = try cursor.moveUp(&doc, params_fn);
_ = try cursor.moveDown(&doc, params_fn);

// Character movement (crosses block boundaries)
_ = cursor.moveLeft(&doc);
_ = cursor.moveRight(&doc);

// Line boundaries
try cursor.moveToLineStart(&doc, params);
try cursor.moveToLineEnd(&doc, params);

// Block boundaries
cursor.moveToBlockStart();
try cursor.moveToBlockEnd(&doc);

// Document boundaries
cursor.moveToDocumentStart();
try cursor.moveToDocumentEnd(&doc);

Word navigation

Unicode-aware word boundary detection, suitable for Ctrl+Left/Right and double-click word selection:

const content = block.contentSlice();
const tl = storykit.text_layout;

// Find word boundaries (Option/Ctrl + arrow keys)
const prev = storykit.findPrevWordBoundary(content, cursor.offset);
const next = storykit.findNextWordBoundary(content, cursor.offset);

// Select word at position (double-click)
const word = storykit.findWordAt(content, cursor.offset);
// word.start, word.end — byte offsets

// With extra characters treated as word chars (e.g., hyphens in identifiers)
const extra = [_]u21{'-'};
const word2 = storykit.findWordAtExtra(content, cursor.offset, &extra);

Codepoint and character-category primitives

Lower-level building blocks used to compose Kakoune-style word selection, paredit movement, and other custom navigation. All work on raw byte offsets and accept extra word-character codepoints.

const tl = storykit.text_layout;

// Decode the codepoint at a byte offset.
const cp = tl.decodeAt(content, pos);  // .{ .cp: u21, .len: u3 }
const len = tl.codepointLen(content, pos);

// Step backward to the start of the previous codepoint.
const prev = tl.prevCpStart(content, pos);

// Classify the codepoint at a byte offset (.word | .punctuation | .whitespace).
// Past the end of content is .whitespace.
const cat = tl.charCategoryAt(content, pos, &extras);

// Walk forward / backward over a run of one category.
const after = tl.skipCategoryForward(content, start, .word, &extras);
const before = tl.skipCategoryBackward(content, start, .word, &extras);

Kakoune-style word selection

Per-content word-object selection following Kakoune semantics: word/punctuation/whitespace each form their own selectable class; a word object is "run of one class + trailing whitespace" (except when the object itself is whitespace). Anchor extends back to include the cursor when cursor and cursor+1 are the same class, so repeated w walks word-by-word predictably.

Returns byte offsets within the content slice; the caller maps to Cursors. null returns mean "advance to next/prev block and recurse with kakouneSelectWordFromLineStart."

const tl = storykit.text_layout;

// Forward `w` — null when at end-of-content.
if (tl.kakouneSelectForwardWord(content, cursor_offset, &extras)) |sel| {
    // sel.anchor, sel.focus — byte offsets
}

// Backward `b` — null at start-of-content.
if (tl.kakouneSelectBackwardWord(content, cursor_offset, &extras)) |sel| { ... }

// First object on a fresh line (called after crossing into next/prev block).
if (tl.kakouneSelectWordFromLineStart(content, &extras)) |sel| { ... }

extras is a list of codepoints to promote to .word category (e.g. Scheme uses '-', '?', '!').

Per-content scan used to compose document-wide / search. Editors loop their own blocks (block shape varies — flat vs sectioned) and call these for the inner match. Case-insensitive matching is ASCII-fold only — fine for code editors and most prose; multi-byte case folding is out of scope.

const tl = storykit.text_layout;

// Find the next occurrence at or after `from`.
if (tl.findNextMatch(content, query, from, .{ .case_insensitive = true })) |off| {
    // off is the byte offset of the match
}

// Find the previous occurrence strictly before `before`.
if (tl.findPrevMatch(content, query, before, .{})) |off| { ... }

MatchOpts is a struct so future flags (regex, word-boundary, etc.) can grow without breaking callers.

Selection history (< / >)

Stack of (cursor, selection) snapshots with undo+redo semantics. Independent from content undo/redo. Used by editors that bind < / > to step back/forward through past selection states.

var hist = storykit.SelectionHistory.init(64); // 0 = unbounded
defer hist.deinit(allocator);

// Before an action that changes cursor/selection, push the previous state.
try hist.push(allocator, .{ .cursor = view.cursor, .selection = view.selection });

// Step back: returns the previous snapshot, parks current on the redo stack.
if (try hist.stepBack(allocator, .{ .cursor = view.cursor, .selection = view.selection })) |snap| {
    view.cursor = snap.cursor;
    view.selection = snap.selection;
}

// Step forward: mirror.
if (try hist.stepForward(allocator, current)) |snap| { ... }

push clears the redo stack (new path through history). cap = 0 is unbounded; with a positive cap, the oldest entry is dropped on overflow.

Click-granularity object selection

For GUI editors that bind single/double/triple click to char / word / sentence selection (and "drag locks to granularity" — once double- clicked, dragging extends word-by-word). storykit owns the per-position object lookup; the editor owns the click-counter state machine and drag dispatch.

const tl = storykit.text_layout;

// Single click — char (1 codepoint at pos).
// Double click — whole word object containing pos.
// Triple click — whole sentence object containing pos.
if (tl.objectAt(content, pos, .word, &extras)) |obj| {
    // obj.anchor / obj.focus — byte offsets, focus inclusive
}

// Or call the unit-specific helpers directly:
if (tl.wordObjectAt(content, pos, &extras)) |obj| { ... }
if (tl.sentenceObjectAt(content, pos)) |obj| { ... }

null returns mean no object exists at that position (e.g. word click on whitespace, sentence click in empty content). Editors typically fall back to char-granularity in that case.

Sentence boundaries

For prose editors that bind s / S to sentence selection or ) / ( to sentence motion. ASCII sentence-end punctuation (. ! ?) followed by whitespace; the boundary is the start of the next sentence's first non-whitespace character.

const tl = storykit.text_layout;

if (tl.isAtSentenceStart(content, pos)) { ... }

const next = tl.findNextSentenceBoundary(content, pos);
const prev = tl.findPrevSentenceBoundary(content, pos);

// Used internally; exposed for completeness.
if (tl.isSentenceEnd('.')) { ... }

Find char (f / F)

Per-content single-byte scan for vim/Kakoune-style f and F key handlers. The pending-state struct (which character is the target, count, extend-vs-fresh-select) lives in each editor since it's tightly wired to input dispatch; storykit just owns the scan.

const tl = storykit.text_layout;

// Forward: first occurrence at or after `from`.
if (tl.findCharForward(content, '.', from)) |off| { ... }

// Backward: last occurrence strictly before `before`.
if (tl.findCharBackward(content, '.', before)) |off| { ... }

Undo/redo

Editing functions record undo automatically when a handler is attached:

var undo_mgr = storykit.UndoManager.init(allocator);
defer undo_mgr.deinit();
doc.undo_handler = undo_mgr.handler();

// Edits are now recorded
cursor = try storykit.insertText(&doc, cursor, "Hello");
cursor = try storykit.insertText(&doc, cursor, " world");

// Undo/redo
if (try doc.undo()) |c| cursor = c;
if (try doc.redo()) |c| cursor = c;

Group multiple operations into a single undo step:

doc.beginGroup();
cursor = try storykit.splitBlock(&doc, cursor);
try storykit.setBlockType(&doc, 0, 1, heading_type);
doc.endGroup();
// Single doc.undo() reverts both operations

Consecutive single-character inserts and backspaces coalesce automatically (typing "hello" is one undo step, not five).

Dirty tracking

Content-hash based save detection — correctly identifies when edits return content to its saved state:

doc.markClean(); // snapshot current state as "saved"

cursor = try storykit.insertText(&doc, cursor, "ab");
try std.testing.expect(doc.isDirty()); // content changed

// Undo both characters — content matches saved state again
if (try doc.undo()) |c| cursor = c;
try std.testing.expect(!doc.isDirty()); // back to saved content

Uses edit_version internally to avoid rehashing when nothing has changed.

The UndoHandler is an interface — apps can implement it for custom undo behavior (collaborative editing, persistence, app-level undo integration):

const MyHandler = struct {
    fn handler(self: *@This()) storykit.UndoHandler {
        return .{ .ptr = @ptrCast(self), .vtable = &vtable };
    }
    const vtable = storykit.UndoHandler.VTable{
        .willModify = myWillModify,
        .didModify = myDidModify,
        .performUndo = myUndo,
        .performRedo = myRedo,
        .canUndo = myCanUndo,
        .canRedo = myCanRedo,
        .beginGroup = myBeginGroup,
        .endGroup = myEndGroup,
    };
};

Annotations

Key-value metadata on blocks, with undo support:

// Set (records undo)
try storykit.setBlockAnnotation(&doc, 0, block_idx, "note", "Needs more tension");

// Query
const block = doc.getFlatBlock(block_idx).?;
if (block.annotations.get("note")) |note| {
    // use note
}

// Remove (pass null)
try storykit.setBlockAnnotation(&doc, 0, block_idx, "note", null);

Hierarchical documents

For novels, textbooks, or anything with chapters:

var doc = storykit.Document.init(allocator);
defer doc.deinit();

_ = try doc.registerSectionType(.{ .name = "Chapter" });
const sec = try doc.insertSectionWithTitle(0, 0, "Chapter One");
_ = try sec.appendBlockWithContent(0, "It was a dark and stormy night.");

Branching on mode: flat docs are internally a Document with one anonymous section, so doc.sectionCount() returns 1 (not 0). Apps deciding whether to render section chrome should branch on doc.mode (an explicit .flat / .hierarchical enum), not on sectionCount().

Nested blocks

Blocks can have children for representing nested structures (lists with sublists, blockquotes with paragraphs):

// Create a list item with nested sub-items
const parent = try sec.appendBlockWithContent(list_type, "Parent item");

const child = try allocator.create(storykit.Block);
child.* = try storykit.Block.initWithContent(allocator, list_type, "Nested item");
try parent.addChild(child);

// Query nesting
parent.childCount()  // 1
parent.isContainer() // true
child.depth()        // 1
child.parent         // parent

Structural editing operations:

// Indent: move block into previous sibling's children
try storykit.indentBlock(&doc, section_idx, block_idx);

// Dedent: move child out to parent's level
try storykit.dedentBlock(&doc, section_idx, parent_block_idx, child_idx);

// Wrap range in a container block
try storykit.wrapInContainer(&doc, section_idx, start_idx, count, container_type);

// Unwrap: move container's children out, remove container
try storykit.unwrapContainer(&doc, section_idx, block_idx);

Flat docs and indent: nested children created by indentBlock in a flat document are reachable by cursor navigation — Cursor.block_idx is DFS-deep in both modes, so prev/next walks descend into the indented subtree. (Earlier versions kept flat-mode navigation top-level-only, which silently hid indented blocks from the cursor.)

Block iteration

Depth-first traversal over blocks including nested children:

const sec = doc.getSection(0).?;
var it = sec.blockIterator();
while (it.next()) |blk| {
    // Visits parent, then children left-to-right, then next sibling
    // For flat documents, identical to iterating sec.blocks.items
}

// Deep find by ID (searches into children)
if (sec.findBlockDeep(block_id)) |blk| { ... }

// Total count including nested children
const total = sec.totalBlockCountDeep();

Grid-aware cursor movement

Blocks whose parent has a cols annotation navigate visually by row/column instead of DFS order — useful for tables, dual-column layouts, or side-by-side editing:

// Create a 2-column grid
const grid = try sec.appendBlockWithContent(container_type, "");
try grid.annotations.put("cols", "2");

// Add 4 cells (2 rows × 2 columns): A B C D
for (0..4) |_| {
    const cell = try allocator.create(storykit.Block);
    cell.* = try storykit.Block.initWithContent(allocator, cell_type, "");
    try grid.addChild(cell);
}

// moveDown from cell A → cell C (same column, next row)
// moveUp from cell D → cell B (same column, prev row)
// moveDown from last row → exits grid to next block
// moveUp from first row → exits grid to previous block
// moveToNextBlock/moveToPrevBlock skip grid parent blocks

Stable cursor identity

BlockAddress identifies a block by UUID, surviving structural mutations:

// Create from current position
var addr = storykit.BlockAddress.fromPosition(&doc, section_idx, block_idx).?;

// After inserts/deletes shift indices, resolve still finds it
// O(1) if block hasn't moved, O(n) fallback via ID search
if (addr.resolve(&doc)) |loc| {
    // loc.section_idx, loc.block_idx — updated position
}

// StableCursor = BlockAddress + offset + sticky_x
var stable = storykit.StableCursor.fromCursor(cursor, &doc).?;
// ... structural mutations happen ...
if (stable.toCursor(&doc)) |c| cursor = c;

Cached metrics

Aggregate metrics cached per block subtree:

const m = block.getMetrics();
m.block_count  // blocks in subtree (including self)
m.char_count   // characters in subtree
m.height_px    // layout height (null until measured)

// Set measured height (app-provided after layout)
block.setHeightPx(24.0);

// Metrics invalidate automatically on edits and bubble up to parent

Tree diffing

O(n) ID-based diff between document trees:

const ops = try storykit.diffSections(&old_section, &new_section, allocator);
defer allocator.free(ops);

for (ops) |op| {
    switch (op) {
        .insert => |d| { ... },   // block exists only in new
        .delete => |d| { ... },   // block exists only in old
        .modify => |d| { ... },   // content changed
        .reparent => |d| { ... }, // moved to different parent
        .reorder => |d| { ... },  // sibling position changed
    }
}

// Content hashing for fast comparison
const hash = storykit.contentHash(&block);

Building

zig build test    # run all library and integration tests
zig build run     # run the example app (requires SDL3)
zig build bench   # run performance benchmarks (always ReleaseFast)
zig build fuzz    # run the seeded document-model fuzzer (16 seeds × 8000 ops)

The fuzzer drives Document + Cursor + Selection + UndoManager through random edits, asserting structural invariants and a deep undo round-trip every 250 ops. It's intentionally separate from zig build test so the unit suite stays green while you hunt regressions. Add -Dfuzz-verbose to trace every op.

Requires Zig 0.16.0 or later.

Module Structure

src/
├── storykit.zig         — public API
├── gap_buffer.zig       — GapBuffer text storage
├── style_runs.zig       — CharStyle, StyleRun, StyleRuns
├── annotations.zig      — Annotations (key-value metadata)
├── block.zig            — Block, BlockMetrics, LayoutParams, MeasureFn, children/parent
├── block_iterator.zig   — BlockIterator (depth-first traversal)
├── section.zig          — Section (hierarchical grouping, deep find/count)
├── document.zig         — Document, BlockTypeDef, SectionTypeDef
├── cursor.zig           — Cursor, Selection (with direction), BlockAddress, StableCursor
├── editing.zig          — editing operations (insert, delete, split, indent, dedent, wrap)
├── selection_history.zig — SelectionHistory, SelectionSnapshot (sel-undo/redo)
├── diff.zig             — tree diffing (DiffOp, diffSections, diffDocuments)
├── text_layout.zig      — wrapText, coord helpers, char-category scans, Kakoune word
│                          selection, sentence boundaries, char-find, substring search
├── font_metrics.zig     — FontMetrics (TTF/OTF glyph width + kerning parser)
├── kern.zig             — GPOS kerning table parser (PairPos Format 1/2)
├── unicode.zig          — Unicode case mapping, classification (alphabetic, whitespace, decimal)
├── unicode_tables.zig   — Generated Unicode property tables
├── undo.zig             — UndoHandler, UndoManager, BlockSnapshot (recursive children)
└── uuid.zig             — UUID v4 generator (thread-local Xoshiro256)

License

MIT