Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2
Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2
What will I learn?
- Why the interactive side of an editor is a different kind of problem than the data model -- input is sparse, messy, and arrives in the wrong coordinate space, and the whole design of Part 2 exists to tame that;
- How to model a tool as data with a tagged union, so pencil, eraser, bucket, eyedropper and line are all handled through one honest dispatch in stead of a tangle of booleans;
- How to build the Editor struct that owns the interaction state -- the document, the active tool, the primary colour, the zoom and pan -- and why keeping that in one place is what makes the tools testable;
- How to map a screen coordinate to a canvas cell through zoom and pan, and why that mapping returns an optional so a click in the margin is a
null, never a wrong pixel; - Why a single click is not enough and you must connect the dots with a Bresenham line (from episode 157) so a fast drag does not leave a dotted trail;
- How to test the whole interaction with no window -- feed a scripted sequence of pointer events and assert the bytes -- and where C, Rust and Go land on the same tool model.
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 indices, thePalette,set/get,floodFillandblitTo; - The Bresenham line walker from episode 157, and the framebuffer idea from episode 156;
- Comfort with tagged unions and structs (episode 6), optionals and error unions (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 (this post)
Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2
Last part we built the quiet half of a pixel art editor: the document model. A Canvas of palette indices, a Palette of RGBA colours, safe set and get, a bucket fill that cannot blow the stack, and a blitTo that magnifies the whole thing onto a framebuffer so you can actually see it. Every line of it was tested with nobody watching -- pure functions of memory, no window, no mouse. That was deliberate. A data core you can test in microseconds is the thing worth having.
Today we build the loud half: the interactive side. This is where the editor stops being a data structure and starts feeling like a tool. You pick a colour, you drag the mouse, and pixels appear. Sounds simple, and the naive version is -- until you notice three things that every real editor has to deal with, and every first attempt gets wrong. Input arrives in the wrong coordinate space (screen pixels, not canvas cells). Input is sparse (the mouse fires maybe sixty events a second, and a fast drag jumps ten cells between two of them). And a tool is not a function you call once -- it is a mode that reacts to a stream of events over time. Get those three right and the editor writes itself. Here we go!
The shape of the problem
Before any code, let us be honest about what we are building, because the design falls straight out of it. A user does three things with a pointer: presses a button down, drags while it is held, and lifts it back up. In between, they have chosen a tool (pencil, eraser, bucket, and so on) and a colour. That is the entire vocabulary. Everything the editor does is a reaction to that little grammar of events.
The subtle part is that the events do not speak the canvas' language. They arrive as (screen_x, screen_y) in framebuffer pixels, and our canvas thinks in (cell_x, cell_y) -- and there is a zoom and a pan sitting between the two. So the very first job of the interactive layer is translation: turn a raw screen coordinate into a canvas cell, or decide it lands outside the canvas entirely. Once we have that, a tool is just a small reaction to "the user did X at cell (a, b)".
I want to keep this layer as pure as the model was. No window library, no operating system event queue -- those are the last mile, and we can plug them in at the end. In stead, we define our own event type, and the editor is a function from an event to a mutation of the document. That keeps the whole thing testable, exactly like Part 1.
Modelling a tool as data
The wrong way to track "which tool is active" is a pile of booleans: is_pencil, is_eraser, is_bucket. That state can represent nonsense (two tools active at once) and every new tool multiplies the mess. The right way, the Zig way we have leaned on since episode 6, is a tagged union (or here, since most tools carry no data, a plain enum with the option to grow). One value, one active tool, and the compiler forces us to handle every case:
const std = @import("std");
/// Which editing tool is active. Most tools are stateless picks; a couple
/// carry a little data, which is why a tagged union earns its keep as this
/// grows -- today the payloads are trivial, tomorrow the line tool will
/// remember its anchor.
pub const Tool = union(enum) {
/// Paint the primary colour under the cursor.
pencil,
/// Paint index 0 (transparent) under the cursor.
eraser,
/// Flood fill the region under the cursor with the primary colour.
bucket,
/// Read the index under the cursor into the primary colour.
eyedropper,
/// Straight line from a pressed anchor to the current cursor.
line: struct { anchor: ?Cell = null },
};
/// A canvas coordinate, already translated out of screen space.
pub const Cell = struct { x: u16, y: u16 };
Why a union(enum) and not just an enum? Because the moment a tool needs to remember something between events -- the line tool needs the cell where you first pressed down -- an enum cannot hold it, and you are back to a side-band line_anchor field that can drift out of sync with the active tool. Bundling the anchor into the line variant means it exists exactly when the line tool is active and is gone the moment you switch away. The state that belongs to a tool lives inside the tool. That is the same discipline that made the tagged-union state machine in episode 33 so pleasant.
The Editor: one place that owns the interaction
Now the object that holds it all together. The Editor owns a pointer to the Document from Part 1 (it does not own the document's memory -- the caller does), plus the interaction state: the active tool, the primary palette index the user is painting with, and the viewport (zoom and pan). Keeping every scrap of "what is the user doing right now" in one struct is what makes the tools testable -- there is no hidden global, no window handle smuggled in:
/// Owns the transient interaction state on top of a Part 1 Document.
/// The Document's memory belongs to whoever created it; the Editor only
/// borrows a pointer.
pub const Editor = struct {
doc: *Document,
tool: Tool = .pencil,
primary: u8 = 1, // palette index to paint with (1 = first real colour)
zoom: u16 = 8,
pan_x: i32 = 0, // screen offset of the canvas' top-left corner
pan_y: i32 = 0,
painting: bool = false, // is a stroke in progress?
last: ?Cell = null, // previous cell of the current stroke, for line-joining
pub fn init(doc: *Document) Editor {
return .{ .doc = doc };
}
pub fn selectTool(self: *Editor, tool: Tool) void {
self.tool = tool;
self.painting = false;
self.last = null;
}
};
Notice last: ?Cell. That single optional is the seed of the whole "connect the dots" problem we are about to solve -- it remembers where the previous drag sample landed, so the next one can draw a line back to it in stead of a lonely dot. And painting: bool is the difference between "the mouse moved" and "the mouse moved while a button is held" -- a pixel editor must ignore the former, or your sprite gets painted every time the cursor merely passes over the canvas.
From screen pixels to canvas cells
Here is the translation layer, and it is the most important function in the episode even though it does almost nothing. It takes a raw screen coordinate, subtracts the pan, divides by the zoom, and hands back a Cell -- or a null if the point lands outside the canvas. That optional is the whole safety story: a click in the toolbar, the margin, or off the window edge is null, and a tool that gets null simply does nothing. There is no "clamp to the nearest edge" surprise, and no way to paint a pixel that is not there:
/// Translate a screen coordinate into a canvas cell, honouring pan and zoom.
/// Returns null when the point falls outside the canvas -- the caller then
/// simply does nothing, which is exactly right for a click in the margin.
pub fn screenToCell(self: Editor, sx: i32, sy: i32) ?Cell {
const rel_x = sx - self.pan_x;
const rel_y = sy - self.pan_y;
if (rel_x < 0 or rel_y < 0) return null; // left/above the canvas
const cx = @divFloor(rel_x, @as(i32, self.zoom));
const cy = @divFloor(rel_y, @as(i32, self.zoom));
if (cx >= self.doc.canvas.width or cy >= self.doc.canvas.height) return null;
return .{ .x = @intCast(cx), .y = @intCast(cy) };
}
The signed i32 maths matters here and is easy to get wrong. Screen coordinates can be negative (the pointer above or to the left of the canvas' top-left corner), and if you did this in unsigned arithmetic the subtraction sx - pan_x would wrap around to a gigantic positive number and happily land you back inside the canvas -- painting a pixel from a click that was nowhere near it. Doing the translation in signed space and only narrowing to u16 after we have proven the value is in range is the sober version. This is the coordinate-space discipline we met with the 2D transforms in episode 160, boiled down to its simplest useful case.
Pointer events, and the grammar of a stroke
Now the event type and the dispatch. We define our own PointerEvent -- three kinds, matching the grammar from the top of the episode -- and one handle method that reacts. This is the seam where a real window library would eventually feed us its events; today we feed them ourselves from tests, which is the point:
pub const PointerEvent = union(enum) {
down: Point, // button pressed
drag: Point, // moved while held
up, // button released
};
pub const Point = struct { x: i32, y: i32 };
/// The heart of the interactive layer: react to one pointer event.
pub fn handle(self: *Editor, ev: PointerEvent) !void {
switch (ev) {
.down => |p| {
self.painting = true;
self.last = null;
if (self.screenToCell(p.x, p.y)) |cell| try self.apply(cell);
},
.drag => |p| {
if (!self.painting) return; // moving without a held button: ignore
if (self.screenToCell(p.x, p.y)) |cell| try self.apply(cell);
},
.up => {
self.painting = false;
self.last = null;
},
}
}
Read the .drag arm twice. The if (!self.painting) return is the guard I mentioned -- a drag event with no button held is just the cursor wandering, and a pixel editor must let it wander without leaving paint. And resetting self.last = null on both down and up means each stroke starts fresh: the line-joining we are about to add never accidentically connects the end of one stroke to the start of the next, which would draw a stray diagonal across your sprite every time you lifted and re-pressed.
Connecting the dots
Now the bug that separates a real editor from a weekend prototype. If apply just painted the single cell under the cursor, a slow drag would look fine and a fast drag would leave a dotted line -- because the mouse only reported, say, cells (2,2) and (9,7), skipping everything in between. The fix is to draw a Bresenham line from the previous sample to the current one, using the exact integer line walker from episode 157. Every stroke becomes continuous, no matter how fast the hand moves:
/// Apply the active tool at `cell`. Pencil/eraser join to the previous
/// sample with a Bresenham line so a fast drag is continuous, not dotted.
fn apply(self: *Editor, cell: Cell) !void {
switch (self.tool) {
.pencil => try self.stroke(cell, self.primary),
.eraser => try self.stroke(cell, 0), // index 0 is transparent
.bucket => try self.doc.canvas.floodFill(
self.doc.allocatorRef(),
cell.x,
cell.y,
self.primary,
),
.eyedropper => self.primary = try self.doc.canvas.get(cell.x, cell.y),
.line => {}, // handled as a live-preview tool in Part 3
}
self.last = cell;
}
/// Paint `color` at `cell`, and along the line from the previous sample if
/// there was one. Out-of-range plots are silently dropped, never fatal.
fn stroke(self: *Editor, cell: Cell, color: u8) !void {
if (self.last) |prev| {
self.plotLine(prev, cell, color);
} else {
self.doc.canvas.set(cell.x, cell.y, color) catch {};
}
}
/// Integer Bresenham from a to b, painting each cell. Reuses the walker
/// from episode 157, adapted to write palette indices.
fn plotLine(self: *Editor, a: Cell, b: Cell, color: u8) void {
var x0: i32 = a.x;
var y0: i32 = a.y;
const x1: i32 = b.x;
const y1: i32 = b.y;
const dx = @abs(x1 - x0);
const dy = -@abs(y1 - y0);
const sx: i32 = if (x0 < x1) 1 else -1;
const sy: i32 = if (y0 < y1) 1 else -1;
var err = dx + dy;
while (true) {
self.doc.canvas.set(@intCast(x0), @intCast(y0), color) catch {};
if (x0 == x1 and y0 == y1) break;
const e2 = 2 * err;
if (e2 >= dy) {
err += dy;
x0 += sx;
}
if (e2 <= dx) {
err += dx;
y0 += sy;
}
}
}
Three design decisions are quietly doing the heavy lifting. First, stroke bounds every plot through canvas.set(...) catch {} -- and here, unlike Part 1's tools which cared about OutOfBounds, we deliberately swallow it. A Bresenham walk between two in-bounds cells never leaves the canvas, but writing the drop as a catch {} is honest about the fact that the walker works in signed space; nothing is silently corrupted because both endpoints came out of screenToCell, which already proved they are inside. Second, the eraser is not a special mechanism at all -- it is the pencil painting index 0, the transparent slot we reserved in Part 1. One code path, two tools. Third, the eyedropper reads in stead of writing, mutating self.primary so your next stroke uses the colour you just picked up -- the classic alt-click of every paint program, and it falls out of the same dispatch for free.
A small note on borrowing the allocator
You may have spotted self.doc.allocatorRef() in the bucket arm. The flood fill from Part 1 needs an allocator for its explicit stack, and rather than make the Editor carry one, we let the Document expose the allocator its canvas already holds. It is a one-liner on the Part 1 struct, and it keeps the ownership story straight -- the memory that fills belongs to the same allocator that made the canvas:
// Added to Document from Part 1:
pub fn allocatorRef(self: *Document) std.mem.Allocator {
return self.canvas.allocator;
}
Small thing, but it is the sort of plumbing that keeps a growing program from sprouting a second, redundant allocator field that later disagrees with the first. One canvas, one allocator, one place it lives.
Testing the interaction with no window
Here is where the "define our own event type" discipline pays off completely. Because a stroke is just a sequence of PointerEvent values fed to handle, we can script a drag and assert the result -- no window, no mouse, no sixty-hertz event loop. We test that a press-and-drag paints a continuous line, that moving without pressing paints nothing, and that the eyedropper picks up what the pencil put down:
const std = @import("std");
test "a fast drag paints a continuous line, not dots" {
var doc = try Document.init(std.testing.allocator, 16, 16);
defer doc.deinit();
var ed = Editor.init(&doc);
ed.primary = 3;
// zoom 1, no pan, so screen coords == cell coords for a clean test
ed.zoom = 1;
try ed.handle(.{ .down = .{ .x = 2, .y = 2 } });
try ed.handle(.{ .drag = .{ .x = 9, .y = 2 } }); // jump 7 cells at once
try ed.handle(.up);
// every cell from x=2..9 on row 2 must be painted, none skipped
var x: u16 = 2;
while (x <= 9) : (x += 1) {
try std.testing.expectEqual(@as(u8, 3), try doc.canvas.get(x, 2));
}
}
test "drag without a prior down paints nothing" {
var doc = try Document.init(std.testing.allocator, 8, 8);
defer doc.deinit();
var ed = Editor.init(&doc);
ed.zoom = 1;
try ed.handle(.{ .drag = .{ .x = 4, .y = 4 } }); // no button held
try std.testing.expectEqual(@as(u8, 0), try doc.canvas.get(4, 4));
}
test "eyedropper reads back what the pencil painted" {
var doc = try Document.init(std.testing.allocator, 8, 8);
defer doc.deinit();
var ed = Editor.init(&doc);
ed.zoom = 1;
ed.primary = 5;
try ed.handle(.{ .down = .{ .x = 1, .y = 1 } }); // paint index 5
try ed.handle(.up);
ed.primary = 0;
ed.selectTool(.eyedropper);
try ed.handle(.{ .down = .{ .x = 1, .y = 1 } });
try std.testing.expectEqual(@as(u8, 5), ed.primary); // picked it back up
}
These are the bugs interactive editors actually ship, caught on the CPU in microseconds: the dotted fast-drag (a missing line join), the phantom paint from a hovering cursor (a missing painting guard), the eyedropper that reads the wrong cell (a coordinate-mapping slip). Not one of them needed a screen. And because handle is a pure reaction to a value, the tests read like a little script of what the user did -- which is the clearest documentation of the tool's behaviour you could ask for. This is exactly the payoff we set up in Part 1 by keeping the view out of the model.
Wiring it to a real window (the last mile)
I promised no window library, and I meant it -- but it is worth seeing how short the last mile is, so the design does not feel academic. A real event loop (SDL, GLFW, a raw X11 or Win32 handler, whatever you like) does nothing more than translate its own events into our PointerEvent and call handle, then blitTo from Part 1 to show the result:
// Pseudocode of the last mile -- your window library fills in the blanks.
fn frame(ed: *Editor, backend: anytype, fb: []Rgba, fb_width: u16) !void {
while (backend.nextEvent()) |raw| {
const ev: PointerEvent = switch (raw.kind) {
.mouse_down => .{ .down = .{ .x = raw.x, .y = raw.y } },
.mouse_move => .{ .drag = .{ .x = raw.x, .y = raw.y } },
.mouse_up => .up,
else => continue,
};
try ed.handle(ev);
}
@memset(fb, .{ .r = 32, .g = 32, .b = 32, .a = 255 }); // clear to grey
ed.doc.canvas.blitTo(ed.doc.palette, fb, fb_width, ed.zoom);
backend.present(fb);
}
That is the entire integration: translate, handle, blitTo, present. Every line of logic worth testing sits behind that boundary in code we already proved correct with no backend at all. Swap SDL for GLFW and this one function changes; the editor does not even notice. That separation -- a thin, dumb shell around a tested core -- is the same functional-core / imperative-shell shape that has served us all series long.
Where Zig quietly pays off, and the same job elsewhere
An interactive tool is a magnet for the boring bugs, and Zig's choices bite down on precisely them. The optional return from screenToCell turns "click outside the canvas" into a null the compiler makes you unwrap before you can touch a cell -- there is no path where an off-canvas click paints a pixel, because there is no Cell to paint with. The tagged-union Tool means the switch in apply will not compile if you add a tool and forget to handle it; the compiler is your checklist. And the signed-then-narrow coordinate maths keeps the pan subtraction from wrapping into a false hit, an overflow that is completely invisible in a language that does unsigned arithmetic quietly.
In C, the pointer handler is a switch on an event enum and the tool is usually an int, with nothing stopping a bad coordinate from indexing the pixel buffer directly -- the off-canvas click writes into whatever is next in memory, and you find out an hour later. In Rust, the design is almost identical to ours: an Option<Cell>, an enum Tool with exhaustive match, checked arithmetic in debug builds -- the shape we reach for in Zig is the shape Rust pushes you toward too, which is a good sign we are on the right road. In Go, you would model the tool as an interface and get a bounds-checked slice for free, but you would carry the garbage collector for a program that never needed it, and the interface dispatch costs a pointer indirection on every plot where our switch compiles to a jump. Zig lands where this whole project keeps landing: the directness of C, the exhaustiveness and edge-safety of Rust, and you decide where the checks live.
That is the interactive layer. We can pick a tool, translate a messy screen click into an honest canvas cell or a clean null, paint a continuous stroke that no fast drag can break, erase, flood fill and eyedrop -- and the whole thing is tested by scripting pointer events with not a single window in sight. The editor finally does things. What it cannot yet do is remember: there is no undo, and closing the program throws your sprite away. That is the missing half, and it is where we head next -- turning this live editor into something that can take back a mistake and write your work to disk so it survives the session. The fun part, honestly, and the piece that makes it a tool you would actually keep.
Bedankt en tot de volgende keer! ;-)