- C 81.2%
- Zig 11.2%
- C++ 3.9%
- Objective-C 2.3%
- Makefile 0.4%
- Other 0.7%
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 detection —
edit_versioncounter 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:
conststorykit_dep=b.dependency("storykit",.{.target=target,.optimize=optimize,});exe.root_module.addImport("storykit",storykit_dep.module("storykit"));Usage
Create a document
conststorykit=@import("storykit");vardoc=storykit.Document.initFlat(allocator);deferdoc.deinit();// Register app-defined block typesconstaction_type=trydoc.registerBlockType(.{.name="Action"});constdialogue_type=trydoc.registerBlockType(.{.name="Dialogue"});// Add blocks_=trydoc.appendFlatBlockWithContent(action_type,"INT. COFFEE SHOP - DAY");_=trydoc.appendFlatBlockWithContent(dialogue_type,"Hello, world.");Edit text
All editing functions take a document and cursor, return an updated cursor, and record undo automatically.
varcursor=storykit.Cursor.at(0,0,0);// section 0, block 0, offset 0// Insert textcursor=trystorykit.insertText(&doc,cursor,"The quick brown fox");// Backspaceif(trystorykit.deleteBackward(&doc,cursor))|c|cursor=c;// Split block (Enter key)cursor=trystorykit.splitBlock(&doc,cursor);// Delete selectionconstsel=storykit.Selection.fromRange(storykit.Cursor.at(0,0,5),storykit.Cursor.at(0,0,10),);if(trystorykit.deleteSelection(&doc,sel))|result|cursor=result.cursor;Paste
// Plain multi-line paste (splits on '\n'), one undo unit:cursor=trystorykit.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=trystorykit.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 + LinkRuns + annotations + children.constfrag=[_]*storykit.Block{block_a,block_b};cursor=trystorykit.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 rangeconstbold_sel=storykit.Selection.fromRange(storykit.Cursor.at(0,0,0),storykit.Cursor.at(0,0,5),);trystorykit.applyStyleToSelection(&doc,bold_sel,.{.bold=true});// Query style at a positionconstblock=doc.getFlatBlock(0).?;conststyle=block.getStyleAt(3);// returns CharStyleLayout and wrapping
Two modes, same result type:
// Monospace — pure character-count wrapping (screenplays)constlayout=tryblock.getLayout(.{.sizing=.{.monospace=72}});// Measured — pixel-width wrapping (proportional fonts)constlayout=tryblock.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 marginconstlayout=tryblock.getLayout(.{.sizing=.{.monospace=80},.wrap_mode=.code,// default: .prose});Both produce a LayoutResult with wrapped lines:
constwrap=layout.wrap;for(0..wrap.line_count)|i|{constline_text=wrap.lines[i];// text slice for this lineconstline_start=wrap.line_starts[i];// byte offset in block// render line_text at appropriate y position}// Find which line a cursor is onconstpos=wrap.findCursorLine(cursor.offset);// pos.line, pos.col, pos.byte_colFor measured mode, pixel-coordinate helpers use the layout's char_widths:
// Click hit-testing: pixel x -> byte offsetconstoffset=storykit.byteOffsetForX(wrap,line_idx,click_x,layout.char_widths.?);// Cursor rendering: byte offset -> pixel xconstcursor_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:
fnmyMeasureFn(ctx:*anyopaque,text:[]constu8,styles:*conststorykit.StyleRuns,out:[]f32)void{varbyte_pos:usize=0;vari:usize=0;while(byte_pos<text.lenandi<out.len){constchar_style=styles.getStyleAt(byte_pos);constfont=pickFont(char_style.bold,char_style.italic);constcp_len=storykit.text_layout.utf8CharLen(text[byte_pos]);constend=@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:
varregular=trystorykit.FontMetrics.fromFont(allocator,regular_ttf,16.0);varbold=trystorykit.FontMetrics.fromFont(allocator,bold_ttf,16.0);varitalic=trystorykit.FontMetrics.fromFont(allocator,italic_ttf,16.0);varbold_italic=trystorykit.FontMetrics.fromFont(allocator,bold_italic_ttf,16.0);constfonts=storykit.StyleFonts{.regular=®ular,.bold=&bold,.italic=&italic,.bold_italic=&bold_italic,};constlayout=tryblock.getLayout(.{.sizing=.{.measured=.{.max_width=600,.measure_fn=storykit.fontMetricsMeasure,.measure_ctx=@ptrCast(&fonts),}}});Cursor navigation
varcursor=storykit.Cursor.at(0,0,0);constparams=storykit.LayoutParams{.sizing=.{.monospace=72}};// Line movement (wrapping-aware, crosses block boundaries)// Uses LayoutParamsFn so each block can have its own layoutconstparams_fn=storykit.Cursor.LayoutParamsFn.uniform(¶ms);_=trycursor.moveUp(&doc,params_fn);_=trycursor.moveDown(&doc,params_fn);// Character movement (crosses block boundaries)_=cursor.moveLeft(&doc);_=cursor.moveRight(&doc);// Line boundariestrycursor.moveToLineStart(&doc,params);trycursor.moveToLineEnd(&doc,params);// Block boundariescursor.moveToBlockStart();trycursor.moveToBlockEnd(&doc);// Document boundariescursor.moveToDocumentStart();trycursor.moveToDocumentEnd(&doc);Word navigation
Unicode-aware word boundary detection, suitable for Ctrl+Left/Right and double-click word selection:
constcontent=block.contentSlice();consttl=storykit.text_layout;// Find word boundaries (Option/Ctrl + arrow keys)constprev=storykit.findPrevWordBoundary(content,cursor.offset);constnext=storykit.findNextWordBoundary(content,cursor.offset);// Select word at position (double-click)constword=storykit.findWordAt(content,cursor.offset);// word.start, word.end — byte offsets// With extra characters treated as word chars (e.g., hyphens in identifiers)constextra=[_]u21{'-'};constword2=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.
consttl=storykit.text_layout;// Decode the codepoint at a byte offset.constcp=tl.decodeAt(content,pos);// .{ .cp: u21, .len: u3 }constlen=tl.codepointLen(content,pos);// Step backward to the start of the previous codepoint.constprev=tl.prevCpStart(content,pos);// Classify the codepoint at a byte offset (.word | .punctuation | .whitespace).// Past the end of content is .whitespace.constcat=tl.charCategoryAt(content,pos,&extras);// Walk forward / backward over a run of one category.constafter=tl.skipCategoryForward(content,start,.word,&extras);constbefore=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."
consttl=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 '-', '?', '!').
Substring search
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.
consttl=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.
varhist=storykit.SelectionHistory.init(64);// 0 = unboundeddeferhist.deinit(allocator);// Before an action that changes cursor/selection, push the previous state.tryhist.push(allocator,.{.cursor=view.cursor,.selection=view.selection});// Step back: returns the previous snapshot, parks current on the redo stack.if(tryhist.stepBack(allocator,.{.cursor=view.cursor,.selection=view.selection}))|snap|{view.cursor=snap.cursor;view.selection=snap.selection;}// Step forward: mirror.if(tryhist.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.
consttl=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.
consttl=storykit.text_layout;if(tl.isAtSentenceStart(content,pos)){...}constnext=tl.findNextSentenceBoundary(content,pos);constprev=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.
consttl=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:
varundo_mgr=storykit.UndoManager.init(allocator);deferundo_mgr.deinit();doc.undo_handler=undo_mgr.handler();// Edits are now recordedcursor=trystorykit.insertText(&doc,cursor,"Hello");cursor=trystorykit.insertText(&doc,cursor," world");// Undo/redoif(trydoc.undo())|c|cursor=c;if(trydoc.redo())|c|cursor=c;Group multiple operations into a single undo step:
doc.beginGroup();cursor=trystorykit.splitBlock(&doc,cursor);trystorykit.setBlockType(&doc,0,1,heading_type);doc.endGroup();// Single doc.undo() reverts both operationsConsecutive 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=trystorykit.insertText(&doc,cursor,"ab");trystd.testing.expect(doc.isDirty());// content changed// Undo both characters — content matches saved state againif(trydoc.undo())|c|cursor=c;trystd.testing.expect(!doc.isDirty());// back to saved contentUses 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):
constMyHandler=struct{fnhandler(self:*@This())storykit.UndoHandler{return.{.ptr=@ptrCast(self),.vtable=&vtable};}constvtable=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)trystorykit.setBlockAnnotation(&doc,0,block_idx,"note","Needs more tension");// Queryconstblock=doc.getFlatBlock(block_idx).?;if(block.annotations.get("note"))|note|{// use note}// Remove (pass null)trystorykit.setBlockAnnotation(&doc,0,block_idx,"note",null);Hierarchical documents
For novels, textbooks, or anything with chapters:
vardoc=storykit.Document.init(allocator);deferdoc.deinit();_=trydoc.registerSectionType(.{.name="Chapter"});constsec=trydoc.insertSectionWithTitle(0,0,"Chapter One");_=trysec.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 ondoc.mode(an explicit.flat/.hierarchicalenum), not onsectionCount().
Nested blocks
Blocks can have children for representing nested structures (lists with sublists, blockquotes with paragraphs):
// Create a list item with nested sub-itemsconstparent=trysec.appendBlockWithContent(list_type,"Parent item");constchild=tryallocator.create(storykit.Block);child.*=trystorykit.Block.initWithContent(allocator,list_type,"Nested item");tryparent.addChild(child);// Query nestingparent.childCount()// 1parent.isContainer()// truechild.depth()// 1child.parent// parentStructural editing operations:
// Indent: move block into previous sibling's childrentrystorykit.indentBlock(&doc,section_idx,block_idx);// Dedent: move child out to parent's leveltrystorykit.dedentBlock(&doc,section_idx,parent_block_idx,child_idx);// Wrap range in a container blocktrystorykit.wrapInContainer(&doc,section_idx,start_idx,count,container_type);// Unwrap: move container's children out, remove containertrystorykit.unwrapContainer(&doc,section_idx,block_idx);Flat docs and indent: nested children created by
indentBlockin a flat document are reachable by cursor navigation —Cursor.block_idxis 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:
constsec=doc.getSection(0).?;varit=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 childrenconsttotal=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 gridconstgrid=trysec.appendBlockWithContent(container_type,"");trygrid.annotations.put("cols","2");// Add 4 cells (2 rows ×ばつ 2 columns): A B C Dfor(0..4)|_|{constcell=tryallocator.create(storykit.Block);cell.*=trystorykit.Block.initWithContent(allocator,cell_type,"");trygrid.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 blocksStable cursor identity
BlockAddress identifies a block by UUID, surviving structural mutations:
// Create from current positionvaraddr=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 searchif(addr.resolve(&doc))|loc|{// loc.section_idx, loc.block_idx — updated position}// StableCursor = BlockAddress + offset + sticky_xvarstable=storykit.StableCursor.fromCursor(cursor,&doc).?;// ... structural mutations happen ...if(stable.toCursor(&doc))|c|cursor=c;Cached metrics
Aggregate metrics cached per block subtree:
constm=block.getMetrics();m.block_count// blocks in subtree (including self)m.char_count// characters in subtreem.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 parentTree diffing
O(n) ID-based diff between document trees:
constops=trystorykit.diffSections(&old_section,&new_section,allocator);deferallocator.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 comparisonconsthash=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