Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3
Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3
What will I learn?
- Why undo is not an afterthought but a data-structure decision, and how the command pattern turns "take that back" into a reversible value in stead of a full-canvas snapshot;
- How to model an edit as the exact set of cells it touched, so a whole pencil stroke costs a handful of bytes to undo, not a copy of the entire image;
- How to coalesce a drag -- which fires dozens of writes -- into a single undo entry, so one Ctrl+Z undoes one stroke, the way a human expects;
- How to build a two-stack history (undo and redo) and why a fresh edit has to throw the redo stack away;
- How to design a small, honest binary file format with a magic number and a version byte, and write a save/load pair that survives a round trip byte-for-byte;
- How to test the whole thing -- undo, redo, and disk persistence -- with no window and (almost) no disk, and where C, Rust and Go land on the same two problems.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written against Zig 0.16;
- The document model from Part 1 (episode 175): the
Canvasof palette indices, thePalette,set/get, andblitTo; - The interactive layer from Part 2 (episode 176): the
Editor, theToolunion,screenToCell, and the pointer-eventhandleloop; - Comfort with
std.ArrayListand allocators (episode 7), error unions anddefer(episode 4), slices (episode 5), and testing (episode 12); - The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing
- Learn Zig Series (#167) - PNG Decoder in Zig
- Learn Zig Series (#168) - JPEG Decoder Basics
- Learn Zig Series (#169) - Audio Fundamentals: PCM and Buffers
- Learn Zig Series (#170) - Audio Output via C Interop
- Learn Zig Series (#171) - Synthesis: Oscillators
- Learn Zig Series (#172) - Synthesis: Envelopes and Filters
- Learn Zig Series (#173) - Audio Mixing
- Learn Zig Series (#174) - MIDI Parsing and Generation
- Learn Zig Series (#175) - Mini Project: Pixel Art Editor - Part 1
- Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2
- Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3 (this post)
Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3
Two parts in, and the editor already does real work. Part 1 (episode 175) built the document model -- a Canvas of palette indices, a Palette of RGBA colours, set/get, a flood fill, and a blitTo that magnifies the whole thing onto a framebuffer. Part 2 (episode 176) built the interactive layer on top: a Tool union, a screenToCell translation that turns a messy click into an honest canvas cell (or a clean null), and a handle loop that paints continuous strokes no fast drag can break. You can pick a colour, drag the mouse, erase, fill and eyedrop. It feels like a tool.
But it has the memory of a goldfish. Make one wrong stroke and it is there forever -- there is no undo. Close the program and your sprite evaporates -- there is no save. Those two gaps are what separate a fun demo from something you would actually open twice. Today we close both, and we close the project. Undo/redo first, then persistence to disk, then the whole three-part editor gets a final honest test with no window and (almost) no filesystem. Here we go!
Why undo is a data-structure decision, not a feature
The lazy way to do undo is to snapshot the entire canvas before every change and keep a list of snapshots. It works, and for a tiny 16x16 sprite you would never notice the cost. But think about what it actually stores: a 256-cell copy for every single dot you paint. Do a hundred strokes on a 512x512 canvas and you are hoarding a hundred quarter-megabyte images just so the user might press Ctrl+Z. That is not undo, that is a memory leak with good intentions.
The honest observation is that a stroke changes almost nothing. A pencil drag touches maybe forty cells out of a quarter million. So in stead of remembering the whole picture, we remember only what changed -- for each touched cell, its coordinate, the index that was there before, and the index we put there after. That is the command pattern: an edit is a small, self-describing value that knows how to undo and redo itself. Undo restores the before values, redo re-applies the after values, and the storage cost is proportional to the work done, not to the size of the canvas.
const std = @import("std");
/// One cell touched by an edit: where it was, and its index before and after.
/// Four bytes of "before", four of "after" -- and a stroke that paints forty
/// cells costs forty of these, not a copy of the whole canvas.
pub const CellChange = struct {
x: u16,
y: u16,
before: u8,
after: u8,
};
Note what the record does not contain: it does not know which tool made it. A pencil stroke, an eraser stroke, and a bucket fill all produce the exact same shape of data -- a list of "this cell went from A to B". That uniformity is the quiet superpower of the command pattern. Undo does not need a switch over tool types; it only needs to walk a list of cell changes backwards. One mechanism, every tool.
An edit: coalescing a whole stroke into one action
Here is the subtlety that trips up every first attempt. When you drag the pencil, Part 2's handle fires apply on every intermediate cell -- that is exactly the Bresenham line-joining we built so a fast drag stays continuous. If each of those set calls became its own undo entry, a single stroke would need forty presses of Ctrl+Z to disappear. Nobody wants that. A human thinks of one drag as one action, so undo has to think that way too.
The fix is coalescing: while a stroke is in progress we accumulate every cell change into one pending Edit, and only when the button lifts do we commit that whole Edit to the history as a single undoable unit. An Edit is just a growable list of CellChange, owning its own memory (episode 7):
/// A single undoable action -- typically one whole stroke, gesture or fill.
/// Owns the list of every cell it changed, in the order they were changed.
pub const Edit = struct {
changes: std.ArrayList(CellChange),
pub fn init() Edit {
return .{ .changes = .empty };
}
pub fn deinit(self: *Edit, gpa: std.mem.Allocator) void {
self.changes.deinit(gpa);
}
/// Record one cell's transition. Called many times during a stroke.
pub fn record(self: *Edit, gpa: std.mem.Allocator, c: CellChange) !void {
// A no-op paint (same index) is not worth remembering.
if (c.before == c.after) return;
try self.changes.append(gpa, c);
}
/// True when the stroke touched nothing worth undoing (all no-ops, or a
/// click that landed off-canvas). We drop empty edits so undo never
/// swallows a press that did nothing visible.
pub fn isEmpty(self: Edit) bool {
return self.changes.items.len == 0;
}
};
The if (c.before == c.after) return guard is small but earns its place. Drag the pencil back and forth over a cell you already painted and you will "change" it to the colour it already is -- a genuine no-op. Recording those would make undo feel spongy: press Ctrl+Z and nothing visible happens, because the "edit" you are undoing changed nothing. Filtering them at the source keeps every undo meaningful -- one press, one visible reversal.
The history: two stacks and the redo rule
Undo and redo are the textbook use of two stacks. The undo stack holds committed edits, newest on top. To undo, you pop the top edit, restore its before values, and push it onto the redo stack. To redo, you pop from the redo stack, re-apply its after values, and push it back onto the undo stack. The two stacks are mirror images and the whole thing is about fifteen lines of real logic.
There is exactly one rule that first-timers forget, and it produces a genuinely confusing bug: a fresh edit clears the redo stack. If you undo three strokes and then paint something new, the three "redoable" strokes are no longer reachable -- you have branched off a different history, and offering to "redo" the old branch would paint back strokes that no longer belong to the drawing. Every serious editor throws the redo stack away the moment you make a new edit, and so do we.
/// Owns the undo and redo stacks plus the in-progress (pending) edit.
/// The Canvas it edits is borrowed -- History does not own pixel memory.
pub const History = struct {
gpa: std.mem.Allocator,
undo_stack: std.ArrayList(Edit),
redo_stack: std.ArrayList(Edit),
pending: ?Edit = null,
pub fn init(gpa: std.mem.Allocator) History {
return .{
.gpa = gpa,
.undo_stack = .empty,
.redo_stack = .empty,
};
}
pub fn deinit(self: *History) void {
for (self.undo_stack.items) |*e| e.deinit(self.gpa);
for (self.redo_stack.items) |*e| e.deinit(self.gpa);
if (self.pending) |*e| e.deinit(self.gpa);
self.undo_stack.deinit(self.gpa);
self.redo_stack.deinit(self.gpa);
}
/// Start accumulating a new stroke. Called on pointer-down.
pub fn begin(self: *History) void {
std.debug.assert(self.pending == null);
self.pending = Edit.init();
}
/// Add one cell transition to the stroke in progress.
pub fn record(self: *History, c: CellChange) !void {
if (self.pending) |*e| try e.record(self.gpa, c);
}
};
Notice pending: ?Edit. That optional is the coalescing machinery: it is null between strokes, becomes an empty Edit on pointer-down, fills up during the drag, and gets committed (or discarded, if empty) on pointer-up. Keeping it inside the history -- rather than as a loose field on the editor -- means the whole "one stroke is one action" story lives in one place, the same discipline that kept the tool state honest in Part 2.
Committing, undoing, redoing
Now the three operations that do the work. commit closes the pending edit: an empty one (a click that painted nothing) is simply dropped, and a real one is pushed to the undo stack -- and pushing a new edit is exactly the moment we clear redo. undo and redo walk an edit's changes and write the before or after index straight into the borrowed canvas.
/// Close the pending stroke. Empty strokes are discarded; real ones become
/// a new undo entry, which invalidates the redo stack (we branched history).
pub fn commit(self: *History) !void {
var edit = self.pending orelse return;
self.pending = null;
if (edit.isEmpty()) {
edit.deinit(self.gpa);
return;
}
// A new action makes any "redoable" future unreachable -- drop it.
for (self.redo_stack.items) |*e| e.deinit(self.gpa);
self.redo_stack.clearRetainingCapacity();
try self.undo_stack.append(self.gpa, edit);
}
/// Undo the most recent edit by restoring every cell's `before` index.
pub fn undo(self: *History, canvas: *Canvas) void {
var edit = self.undo_stack.pop() orelse return;
// Reverse order is not strictly required for independent cell writes,
// but it is the correct habit -- later changes are unwound first.
var i: usize = edit.changes.items.len;
while (i > 0) {
i -= 1;
const c = edit.changes.items[i];
canvas.set(c.x, c.y, c.before) catch {};
}
self.redo_stack.append(self.gpa, edit) catch {
// If we cannot park it for redo, we still undid correctly; just
// free it rather than leak. Redo of this one step is then lost.
edit.deinit(self.gpa);
};
}
/// Redo the most recently undone edit by re-applying every `after` index.
pub fn redo(self: *History, canvas: *Canvas) void {
var edit = self.redo_stack.pop() orelse return;
for (edit.changes.items) |c| {
canvas.set(c.x, c.y, c.after) catch {};
}
self.undo_stack.append(self.gpa, edit) catch {
edit.deinit(self.gpa);
};
}
Two things worth pausing on. First, undo walks the changes backwards. For a pixel editor where every cell is independent it does not strictly matter, but it is the right habit -- if edits ever overlapped a cell twice, unwinding them last-first is the only order that restores the true original. Building the correct instinct now costs nothing and saves a debugging afternoon later. Second, look at how set(...) catch {} swallows the error: every coordinate in an Edit came from screenToCell in Part 2, so it was in-bounds when we recorded it and it is in-bounds now. The catch {} is not sloppiness -- it is an honest acknowledgement that set returns an error union while we hold a proof, from construction, that the error cannot fire here.
Wiring history into the editor
The Editor from Part 2 needs three small changes and nothing more. It gains a history field; its write path routes through a recording helper so every paint captures its before/after; and its handle loop calls begin on down and commit on up. Here is the recording paint -- the single choke point every tool now flows through:
/// Paint `color` at a cell AND record the transition for undo. This replaces
/// the raw canvas.set calls the Part 2 tools used, so every write is undoable.
fn plot(self: *Editor, x: u16, y: u16, color: u8) void {
const before = self.doc.canvas.get(x, y) catch return; // off-canvas: skip
self.doc.canvas.set(x, y, color) catch return;
self.history.record(.{
.x = x,
.y = y,
.before = before,
.after = color,
}) catch {};
}
Because Part 2 already funnelled every tool through stroke and plotLine, we only have to change those to call plot in stead of canvas.set, and the bucket fill records its own before/after span. Undo support then reaches pencil, eraser, line and fill for free -- one choke point, total coverage. The pointer loop grows two calls:
pub fn handle(self: *Editor, ev: PointerEvent) !void {
switch (ev) {
.down => |p| {
self.history.begin(); // open a fresh undo entry
self.painting = true;
self.last = null;
if (self.screenToCell(p.x, p.y)) |cell| try self.apply(cell);
},
.drag => |p| {
if (!self.painting) return;
if (self.screenToCell(p.x, p.y)) |cell| try self.apply(cell);
},
.up => {
self.painting = false;
self.last = null;
try self.history.commit(); // seal the stroke as one action
},
}
}
That is the entire integration. begin on down, paint through plot during the gesture, commit on up. A real application would then bind Ctrl+Z to editor.history.undo(&editor.doc.canvas) and Ctrl+Y to redo, but the logic is done -- the keybinding is just the last mile, exactly like the window loop was in Part 2.
Persistence: designing an honest little file format
Undo protects you within a session. Persistence protects you between sessions, and it means designing a file format. We could reach for PNG (we wrote a decoder back in episode 167) but that throws away the two things this editor cares about: the palette and the indices. A pixel-art file wants to save exactly the document model -- so we invent a tiny native format, and we do it the way every durable format does.
Three rules make a binary format trustworthy, and we met all three when we built the key-value store's on-disk log (episodes 40-43). One, start with a magic number so a wrong file is rejected instantly in stead of being misread as pixels. Two, put a version byte right after it, so a future format can grow without silently corrupting old readers. Three, write the dimensions before the data so the reader knows how much to expect. Here is the header, laid out as plain little-endian bytes:
/// File layout (all integers little-endian):
/// [0..4) magic = "ZPIX"
/// [4] version = 1
/// [5] pal_len number of palette colours (0..=255)
/// [6..8) width u16
/// [8..10) height u16
/// [10..) palette pal_len * 4 bytes (r,g,b,a)
/// then indices width*height bytes, row-major
const MAGIC = [4]u8{ 'Z', 'P', 'I', 'X' };
const VERSION: u8 = 1;
pub const LoadError = error{
BadMagic,
UnsupportedVersion,
Truncated,
};
Nota bene: the header is deliberately fixed-size and boring. Every field has one meaning, the offsets are documented right there in the comment, and there is not a single variable-length surprise before the dimensions. Boring formats are the ones you can still read in five years, and the ones a hex editor can make sense of when something goes wrong.
Writing and reading it back
Saving is a straight walk down that layout. We build the bytes in an ArrayList and write them in one go (the file I/O patterns from episode 10), which keeps the on-disk shape identical to the spec above -- what you read is precisely what you write:
pub fn save(doc: *const Document, path: []const u8, gpa: std.mem.Allocator) !void {
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(gpa);
try buf.appendSlice(gpa, &MAGIC);
try buf.append(gpa, VERSION);
try buf.append(gpa, @intCast(doc.palette.colors.len));
var wh: [4]u8 = undefined;
std.mem.writeInt(u16, wh[0..2], doc.canvas.width, .little);
std.mem.writeInt(u16, wh[2..4], doc.canvas.height, .little);
try buf.appendSlice(gpa, &wh);
for (doc.palette.colors) |c| {
try buf.appendSlice(gpa, &[_]u8{ c.r, c.g, c.b, c.a });
}
try buf.appendSlice(gpa, doc.canvas.pixels); // the index plane
try std.fs.cwd().writeFile(.{ .sub_path = path, .data = buf.items });
}
Loading is where the paranoia lives, because a loader is a parser and every parser meets malformed input eventually (this is the same lesson the SQL parser drilled in episode 130). We check the magic, reject an unknown version, and -- critically -- validate that the file is long enough for the dimensions it claims before we trust a single byte of pixel data. A file that says "512x512" but only carries ten bytes must fail cleanly with Truncated, never index past the buffer:
pub fn load(path: []const u8, gpa: std.mem.Allocator) !Document {
const bytes = try std.fs.cwd().readFileAlloc(gpa, path, 64 * 1024 * 1024);
defer gpa.free(bytes);
if (bytes.len < 10) return LoadError.Truncated;
if (!std.mem.eql(u8, bytes[0..4], &MAGIC)) return LoadError.BadMagic;
if (bytes[4] != VERSION) return LoadError.UnsupportedVersion;
const pal_len = bytes[5];
const width = std.mem.readInt(u16, bytes[6..8], .little);
const height = std.mem.readInt(u16, bytes[8..10], .little);
const pal_bytes = @as(usize, pal_len) * 4;
const pix_bytes = @as(usize, width) * @as(usize, height);
const need = 10 + pal_bytes + pix_bytes;
if (bytes.len < need) return LoadError.Truncated; // claims more than it has
var doc = try Document.init(gpa, width, height);
errdefer doc.deinit();
var off: usize = 10;
var i: usize = 0;
while (i < pal_len) : (i += 1) {
doc.palette.colors[i] = .{
.r = bytes[off],
.g = bytes[off + 1],
.b = bytes[off + 2],
.a = bytes[off + 3],
};
off += 4;
}
@memcpy(doc.canvas.pixels, bytes[off .. off + pix_bytes]);
return doc;
}
The errdefer doc.deinit() deserves a nod. We allocate the document before the final @memcpy, so if anything between the allocation and the successful return were to fail, errdefer (episode 4) unwinds the half-built document rather than leaking it. On the happy path it never fires -- ownership of the finished document passes cleanly to the caller. That is Zig's "no hidden control flow" promise doing exactly what it says: the cleanup is visible, local, and tied to the error path only.
Testing the whole thing with no window and (almost) no disk
Here is the reward for keeping the model pure across all three parts. Undo, redo and the save/load round trip are all pure functions of memory and bytes, so we can test them at CPU speed. First, undo/redo -- paint a cell, undo it back to blank, redo it forward again, and assert the canvas at each step:
const std = @import("std");
test "undo and redo walk a single stroke back and forth" {
var doc = try Document.init(std.testing.allocator, 8, 8);
defer doc.deinit();
var ed = Editor.init(&doc, std.testing.allocator);
defer ed.history.deinit();
ed.zoom = 1;
ed.primary = 4;
try ed.handle(.{ .down = .{ .x = 3, .y = 3 } });
try ed.handle(.up);
try std.testing.expectEqual(@as(u8, 4), try doc.canvas.get(3, 3));
ed.history.undo(&doc.canvas);
try std.testing.expectEqual(@as(u8, 0), try doc.canvas.get(3, 3)); // blank again
ed.history.redo(&doc.canvas);
try std.testing.expectEqual(@as(u8, 4), try doc.canvas.get(3, 3)); // back
}
test "a new edit clears the redo stack" {
var doc = try Document.init(std.testing.allocator, 8, 8);
defer doc.deinit();
var ed = Editor.init(&doc, std.testing.allocator);
defer ed.history.deinit();
ed.zoom = 1;
ed.primary = 1;
try ed.handle(.{ .down = .{ .x = 0, .y = 0 } });
try ed.handle(.up);
ed.history.undo(&doc.canvas); // stroke is now redoable
ed.primary = 2; // a brand new stroke branches history...
try ed.handle(.{ .down = .{ .x = 7, .y = 7 } });
try ed.handle(.up);
ed.history.redo(&doc.canvas); // ...so this must be a no-op
try std.testing.expectEqual(@as(u8, 0), try doc.canvas.get(0, 0));
}
That second test is the one I care about most -- it pins down the redo-invalidation rule that is so easy to get wrong, and it reads like a plain description of the user's actions. For persistence we can round-trip entirely in a temporary directory, so the test leaves nothing behind on the real disk:
test "save then load reproduces the document byte for byte" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var doc = try Document.init(std.testing.allocator, 5, 3);
defer doc.deinit();
doc.palette.colors[1] = .{ .r = 200, .g = 30, .b = 30, .a = 255 };
try doc.canvas.set(0, 0, 1);
try doc.canvas.set(4, 2, 1);
const path = "sprite.zpix";
try tmp.dir.setAsCwd(); // save/load use cwd; point it at the temp dir
try save(&doc, path, std.testing.allocator);
var back = try load(path, std.testing.allocator);
defer back.deinit();
try std.testing.expectEqual(doc.canvas.width, back.canvas.width);
try std.testing.expectEqualSlices(u8, doc.canvas.pixels, back.canvas.pixels);
try std.testing.expectEqual(@as(u8, 200), back.palette.colors[1].r);
}
test "load rejects a truncated file" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.setAsCwd();
// valid header claiming 100x100 but with no pixel data following
var hdr = [_]u8{ 'Z', 'P', 'I', 'X', 1, 0, 100, 0, 100, 0 };
try tmp.dir.writeFile(.{ .sub_path = "bad.zpix", .data = &hdr });
try std.testing.expectError(LoadError.Truncated, load("bad.zpix", std.testing.allocator));
}
The truncated-file test is the sort of thing that never occurs to you until a real file gets cut off by a full disk or a crashed save -- and then you are very glad the loader refuses it with a named error in stead of reading garbage into your canvas or, worse, walking off the end of the buffer. Testing the failure path is every bit as important as testing the happy one.
Where Zig quietly pays off, and the same job elsewhere
These two features -- undo and persistence -- are magnets for exactly the bugs Zig is built to catch. The ?Edit pending field makes "commit with no stroke open" a null the compiler forces you to handle, so there is no path where you seal an edit that was never begun. The LoadError set means every caller of load must decide, in writing, what happens on a bad file -- there is no silently-ignored return code the way C's -1 gets forgotten. And errdefer guarantees the half-built document on a failed load is freed, with the cleanup sitting right next to the allocation where you can see it.
In C, undo tends to become a hand-rolled linked list of void* commands with a comment begging you not to forget to free them, and the file loader is the classic buffer-overrun waiting to happen -- read a length from the file, trust it, index past the end. The whole family of image-parser CVEs lives in that one missing bounds check we wrote as if (bytes.len < need) return Truncated. In Rust, the shape is almost identical to ours: a Vec<Edit>, a Result<Document, LoadError>, a slice access that panics rather than corrupts -- again, the design Zig nudges you toward is the design Rust enforces, which is a reassuring sign we are on the right track. In Go, you would get a bounds-checked slice and easy []byte handling for free, but you would carry the garbage collector for a program that manages a single flat pixel buffer perfectly well by hand, and you would lose the errdefer-style locality -- defer runs on every return, not just the error ones. Zig lands, as it has all series, on the directness of C with the edge-safety of Rust, and you still decide where every allocation lives.
The project, finished
Step back and look at what three episodes built. A document model you can test in microseconds. An interactive layer that turns messy pointer events into clean strokes. And now a history that makes every action reversible and a file format that makes your work outlive the session. Not one line of it needed a window to be proven correct -- the entire editor is a tested core with a thin, dumb shell waiting to be bolted on at the very edge. That functional-core / imperative-shell shape is the single most valuable habit this whole series has tried to teach, and a pixel editor -- input-heavy, stateful, easy to get subtly wrong -- is about the most honest place to prove it works.
Having said that, there is always more you could add: layers, selection rectangles, a colour picker UI, export to PNG using the encoder cousin of episode 167. But the bones are solid, and every one of those features would hang off the same clean model without disturbing it. That is what a good core buys you -- room to grow without fear.
That closes the Pixel Art Editor. Next time we start a fresh mini project and swap pixels for sound -- back to the audio world we built the foundations for around episodes 169 to 174, this time to make something that actually sings. Different domain, same discipline: a pure, tested core first, and the noisy real world only at the edges.
Thanks for building this one with me, and De groeten! ;-)