- Zig 98.9%
- Python 0.6%
- Shell 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .tools | ||
| docs | ||
| example | ||
| fonts | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| README.md | ||
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:
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 = ®ular,
.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(¶ms);
_ = 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 '-', '?', '!').
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.
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 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-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
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:
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