1
0
Fork
You've already forked storykit
0
A fast Zig library for building editors for screenplays, novels, and similar structured documents, as well as flat code and text documents.
  • C 81.2%
  • Zig 11.2%
  • C++ 3.9%
  • Objective-C 2.3%
  • Makefile 0.4%
  • Other 0.7%
Mikael Säker fbb6bcb2cf deps: pin demo dvui to a commit — branch-ref pins go hash-stale
zig16-dev moved under the recorded hash, so every consumer's cold fetch of storykit died on 'hash mismatch' (the dep is lazy but the build runner still fetches+validates it). Pinned to the branch's current commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026年07月06日 22:24:59 +02:00
.claude/worktrees Add findNextMatch / findPrevMatch substring helpers 2026年05月09日 10:48:50 +02:00
.tools Add findNextMatch / findPrevMatch substring helpers 2026年05月09日 10:48:50 +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 scripts: pre-push hook running the bench regression guard 2026年07月06日 22:15:08 +02:00
src bench: styled-paste benchmarks + regression guard 2026年07月06日 22:09:09 +02:00
tests editing: styled + structured paste primitives (insertStyledText, insertDocument) 2026年07月06日 22:09:00 +02:00
zig-out/bin Add Kakoune-style word-object selection (kakouneSelectForward/BackwardWord) 2026年05月09日 19:12:33 +02:00
zig-pkg Add findNextMatch / findPrevMatch substring helpers 2026年05月09日 10:48:50 +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 Add font-metric text wrapping and full library source 2026年01月29日 16:13:14 +01:00
context.md bench: styled-paste benchmarks + regression guard 2026年07月06日 22:09:09 +02:00
DESIGN.md scripts: pre-push hook running the bench regression guard 2026年07月06日 22:15:08 +02:00
LICENSE Initial commit 2026年01月27日 12:10:33 +01:00
README.md bench: styled-paste benchmarks + regression guard 2026年07月06日 22:09:09 +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:

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 CharStyle

Layout 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_col

For 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=&regular,.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(&params);_=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 '-', '?', '!').

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 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=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 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):

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 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-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// parent

Structural 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 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:

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 blocks

Stable 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 parent

Tree 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