Learn Zig Series (#175) - Mini Project: Pixel Art Editor - Part 1
Learn Zig Series (#175) - Mini Project: Pixel Art Editor - Part 1
What will I learn?
- Why a pixel art editor stores palette indices in stead of colors -- the classic indexed-color model that every sprite and tile sheet has used since the 8-bit era, and why it makes recolouring, animation and file size all easier at once;
- How to build the document model from scratch: a
Canvasof index bytes, aPaletteof RGBA colours, and aDocumentthat owns both -- the data core every tool in the next parts will edit; - How Zig's typed errors turn every out-of-bounds pixel access into a value the caller must handle, so a stray click at
(999, 999)is never a silent corruption; - How to write the bucket fill (flood fill) with an explicit stack in stead of recursion, so filling a huge region can never blow the call stack -- a real bug in more than one shipped editor;
- How to composite the indexed canvas onto the RGBA framebuffer from episode 156, scaled up so a 32x32 sprite is actually visible, with index 0 left transparent;
- How to test all of it as pure functions of memory -- no window, no mouse, no GPU -- and where C, Rust and Go land on the very same design.
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 framebuffer and
Rgbaidea from episode 156, plus the colour work in episodes 165-166; - Comfort with allocators (episode 7), slices (episode 5), structs and tagged unions (episode 6), error unions (episode 4) 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 (this post)
Learn Zig Series (#175) - Mini Project: Pixel Art Editor - Part 1
For the last twenty-odd episodes we have been building the pieces of a graphics and audio toolkit -- a framebuffer, line and circle rasterizers, colour spaces, alpha blending, a PNG decoder, then a whole synthesizer and a MIDI parser. Every one of those was a component. Today we start pointing all of it at one hands-on build that will run across a few episodes: a pixel art editor. Not a toy -- a real little program with a canvas you can paint, a palette you can grow, a bucket fill, and a way to see the result on screen. This first part is the foundation the rest stands on: the document model. Get the data core right and the tools almost write themselves; get it wrong and every feature after it fights you. So we build it carefully, we test it with nobody watching the screen, and we let Zig's type system catch the bugs that pixel editors are famous for. Here we go!
But first, the three loose ends from the MIDI episode.
Solutions to Episode 174 Exercises
Exercise 1 -- a tiny sequencer. The job is to take a list of (delta_ticks, Message) pairs and a division, and render them into a PCM buffer by dispatching each message into the mixer at the right sample offset. The maths is the same clock we derived last episode: division gives ticks per quarter note, tempo_us gives microseconds per quarter note, so one tick is tempo_us / division microseconds, and multiplying by the sample rate turns a tick into a sample count. We accumulate absolute ticks, precompute the sample at which the next event is due, and fire it the instant the render cursor reaches it:
const std = @import("std");
pub const Sequence = struct {
events: []const Event,
division: u16, // ticks per quarter note
pub const Event = struct { delta: u32, msg: Message };
/// Render the sequence into `out`, dispatching each message into the mixer
/// at its absolute sample offset. `tempo_us` is microseconds per quarter.
pub fn render(self: Sequence, mixer: *Mixer(16), out: []f32, sample_rate: u32, tempo_us: u32) void {
const samples_per_tick =
(@as(f64, @floatFromInt(tempo_us)) * @as(f64, @floatFromInt(sample_rate)))
/ (@as(f64, @floatFromInt(self.division)) * 1_000_000.0);
var next_ev: usize = 0;
var abs_tick: u64 = 0;
var due: usize = if (self.events.len > 0)
@intFromFloat(@as(f64, @floatFromInt(self.events[0].delta)) * samples_per_tick)
else
std.math.maxInt(usize);
for (out, 0..) |*s, i| {
while (next_ev < self.events.len and i >= due) {
dispatch(mixer, self.events[next_ev].msg, sample_rate);
abs_tick += self.events[next_ev].delta;
next_ev += 1;
if (next_ev < self.events.len) {
const t = abs_tick + self.events[next_ev].delta;
due = @intFromFloat(@as(f64, @floatFromInt(t)) * samples_per_tick);
}
}
s.* = mixer.next();
}
}
};
The key insight: a sequencer is a scheduler, nothing more. It never touches samples itself -- it only decides when to press and release, and hands that decision to the mixer we already trust. The whole thing is a single pass over the output buffer with no allocation.
Exercise 2 -- write a real .mid file. The one thing that trips people up is that an MTrk chunk begins with a big-endian u32 length you cannot know until you have written the whole track. The clean trick is to reserve four bytes, remember the position, write the body, then back-patch the length once you know it:
const std = @import("std");
/// Write a format-0 SMF into `out` and return the number of bytes used.
pub fn writeSmf(out: []u8, notes: []const Note, division: u16) usize {
var w: usize = 0;
@memcpy(out[w .. w + 4], "MThd"); w += 4;
writeBe32(out[w..], 6); w += 4; // header is always 6 bytes
writeBe16(out[w..], 0); w += 2; // format 0
writeBe16(out[w..], 1); w += 2; // one track
writeBe16(out[w..], division); w += 2; // ticks per quarter note
@memcpy(out[w .. w + 4], "MTrk"); w += 4;
const len_pos = w; w += 4; // placeholder, patched at the end
const body_start = w;
var vlq: [4]u8 = undefined;
var buf3: [3]u8 = undefined;
w += writeDelta(out[w..], 0, &vlq); // tempo meta: 500000 us = 120 BPM
@memcpy(out[w .. w + 6], &[_]u8{ 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20 }); w += 6;
for (notes) |n| {
w += writeDelta(out[w..], n.on_delta, &vlq);
const on = encode(.{ .note_on = .{ .channel = 0, .note = n.note, .velocity = 100 } }, &buf3);
@memcpy(out[w .. w + on.len], on); w += on.len;
w += writeDelta(out[w..], n.dur, &vlq);
const off = encode(.{ .note_off = .{ .channel = 0, .note = n.note, .velocity = 0 } }, &buf3);
@memcpy(out[w .. w + off.len], off); w += off.len;
}
w += writeDelta(out[w..], 0, &vlq); // end-of-track meta: FF 2F 00
@memcpy(out[w .. w + 3], &[_]u8{ 0xFF, 0x2F, 0x00 }); w += 3;
writeBe32(out[len_pos..], @intCast(w - body_start)); // now we know the length
return w;
}
pub const Note = struct { note: u7, on_delta: u32, dur: u32 };
fn writeDelta(dst: []u8, value: u32, scratch: *[4]u8) usize {
const s = writeVarLen(value, scratch);
@memcpy(dst[0..s.len], s);
return s.len;
}
fn writeBe16(dst: []u8, v: u16) void { dst[0] = @intCast(v >> 8); dst[1] = @intCast(v & 0xFF); }
fn writeBe32(dst: []u8, v: u32) void {
dst[0] = @intCast(v >> 24); dst[1] = @intCast((v >> 16) & 0xFF);
dst[2] = @intCast((v >> 8) & 0xFF); dst[3] = @intCast(v & 0xFF);
}
Drop those bytes to a file and any media player or DAW will play your tune. The back-patch pattern is worth internalising -- you meet it in every container format where a length precedes a payload of unknown size, from MTrk to RIFF to ZIP.
Exercise 3 -- the messages we skipped. Two more channel-voice messages and one system message. Polyphonic aftertouch (0xA) carries a note and a pressure, channel aftertouch (0xD) carries just a pressure, and a SysEx block (0xF0 .. 0xF7) is a variable-length blob we must be able to skip without choking on the events after it:
const std = @import("std");
// Two new variants on the Message union:
// poly_aftertouch: struct { channel: u4, note: u7, pressure: u7 },
// channel_aftertouch: struct { channel: u4, pressure: u7 },
// ...and two new arms in Parser.next's switch(kind):
// 0xA => return .{ .msg = .{ .poly_aftertouch = .{
// .channel = channel, .note = try D.read(bytes, i), .pressure = try D.read(bytes, i + 1),
// } }, .len = i + 2 },
// 0xD => return .{ .msg = .{ .channel_aftertouch = .{
// .channel = channel, .pressure = try D.read(bytes, i),
// } }, .len = i + 1 },
/// Skip a system-exclusive block: 0xF0 ... 0xF7. Returns bytes consumed
/// (including both boundary bytes) so the caller can advance past it.
pub fn skipSysex(bytes: []const u8) !usize {
if (bytes.len == 0 or bytes[0] != 0xF0) return error.NotSysex;
var i: usize = 1;
while (i < bytes.len) : (i += 1) {
if (bytes[i] == 0xF7) return i + 1; // inclusive of the terminator
}
return error.UnterminatedSysex;
}
test "sysex is skipped without derailing the events after it" {
const stream = [_]u8{ 0xF0, 0x7E, 0x00, 0x09, 0x01, 0xF7, 0x90, 60, 100 };
const used = try skipSysex(&stream);
try std.testing.expectEqual(@as(usize, 6), used);
var p = Parser{};
const note = try p.next(stream[used..]); // the note-on right after the blob
try std.testing.expectEqual(@as(u7, 60), note.msg.note_on.note);
}
The insight both aftertouch types and SysEx share: an unknown or variable-length chunk must be skippable, not fatal. A parser that dies on the first message it does not recognise is useless against real files -- robustness is knowing how many bytes to step over, not understanding every byte. Right, three loose ends tied. Now let us build an editor.
What we are building
A pixel art editor is, at heart, a very small program with a very specific data model. The screen shows a grid of big fat pixels; the user picks a colour and paints. The naive way to store that is one RGBA colour per pixel -- and for a photo editor that is exactly right. But pixel art is different, and the difference drives the whole design: pixel art uses a small, fixed palette. A sprite is not "16 million possible colours", it is "these 16 colours, arranged in this pattern".
So we store indices, not colours. Each pixel is a single byte: an index into a palette of RGBA entries. This is the indexed-colour model that ran every console and computer game from the NES to the early PlayStation, and it buys us three things for free. Recolouring a whole sprite is editing one palette entry, not walking every pixel. Animation and palette cycling (the trick that made waterfalls and fire flicker in old games) is just rotating palette entries. And the file is a quarter the size, because one byte beats four. We will lean on all three as the project grows.
The Canvas: a grid of palette indices
The Canvas owns a flat, row-major buffer of index bytes. Flat, not a slice-of-slices, for the same reason the framebuffer was flat back in episode 156: one allocation, cache-friendly, and the address of any pixel is a single multiply-add. Index 0 is reserved, by convention, as transparent -- the empty background of the sprite:
const std = @import("std");
/// A pixel art canvas: one palette index per pixel, row-major. Index 0 is
/// transparent by convention (the empty background).
pub const Canvas = struct {
width: u16,
height: u16,
pixels: []u8,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) !Canvas {
const pixels = try allocator.alloc(u8, @as(usize, width) * height);
@memset(pixels, 0); // start fully transparent
return .{ .width = width, .height = height, .pixels = pixels, .allocator = allocator };
}
pub fn deinit(self: *Canvas) void {
self.allocator.free(self.pixels);
self.* = undefined;
}
inline fn index(self: Canvas, x: u16, y: u16) usize {
return @as(usize, y) * self.width + x;
}
};
Notice the @as(usize, width) * height in init. A 1024x1024 canvas is a million pixels, which overflows a u16 and even brushes a u32 if you are careless -- widening to usize before the multiply is the kind of thing Zig makes you think about explicitly, and it is exactly the overflow that has crashed image code in every language that let it slide. The index helper is inline because it is called in the innermost loop of every operation, and a function call there would be a waste.
Reading and writing pixels, safely
Now the two operations everything else is built on: set one pixel, read one pixel. Both must bounds-check, because the coordinates come from outside -- a mouse position, a fill that walked to the edge, a tool that did its own maths. And in Zig, "the caller passed a bad coordinate" is not a reason to crash or to silently do nothing; it is a typed error the caller has to acknowledge:
pub const CanvasError = error{OutOfBounds};
/// Paint a single pixel. Out-of-range coordinates are a typed error, never a
/// silent no-op and never a buffer overrun.
pub fn set(self: *Canvas, x: u16, y: u16, color: u8) CanvasError!void {
if (x >= self.width or y >= self.height) return error.OutOfBounds;
self.pixels[self.index(x, y)] = color;
}
/// Read a single pixel's palette index.
pub fn get(self: Canvas, x: u16, y: u16) CanvasError!u8 {
if (x >= self.width or y >= self.height) return error.OutOfBounds;
return self.pixels[self.index(x, y)];
}
/// Fill the whole canvas with one index (the fastest possible operation).
pub fn clear(self: *Canvas, color: u8) void {
@memset(self.pixels, color);
}
Why an error and not a clamp or a silent skip? Because the tool knows what it wants and the canvas does not. A brush might want to clamp to the edge; a fill might want to stop; a paste might want to reject the whole operation. Pushing the policy up to the caller, via a value they must handle, keeps the canvas honest and dumb -- which is exactly what a data core should be. clear skips the check entirely because @memset touches the whole buffer and cannot go out of range; that is a real, measurable difference on a canvas you clear every frame.
The Palette
The palette is a fixed array of RGBA entries -- 256 of them, because an index is a byte. We keep a running len so "add a colour" appends, and we reserve slot 0 for transparency. Rgba is a packed struct so four of them line up as four bytes in memory, ready to hand straight to a framebuffer or a PNG:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 };
pub const PaletteError = error{PaletteFull};
/// Up to 256 RGBA colours. Index 0 is transparent; real colours start at 1.
pub const Palette = struct {
colors: [256]Rgba = [_]Rgba{.{ .r = 0, .g = 0, .b = 0, .a = 0 }} ** 256,
len: u16 = 1, // slot 0 reserved (transparent)
/// Append a colour, returning its index. Fails only when all 256 are used.
pub fn add(self: *Palette, color: Rgba) PaletteError!u8 {
if (self.len >= 256) return error.PaletteFull;
const idx: u8 = @intCast(self.len);
self.colors[idx] = color;
self.len += 1;
return idx;
}
pub fn lookup(self: Palette, i: u8) Rgba {
return self.colors[i];
}
};
There is a quiet lesson in len: u16 next to colors: [256]. If len were a u8 it could hold 0..255, and the moment we filled all 256 slots it would wrap to 0 -- the array is full at exactly the value a u8 cannot represent. A u16 counter guarding a 256-entry array is deliberate: the count needs one more value than the largest index. Small, but it is the sort of off-by-one that becomes a corruption bug three months later.
The bucket fill
Every pixel editor has a bucket tool, and every first attempt writes it as recursion: fill this pixel, then recurse into the four neighbours. That works on a 16x16 sprite and then blows the call stack the first time someone fills a 512x512 background, because the recursion depth is the size of the region. So we do it properly, with an explicit stack on the heap -- the same shape, but the depth lives in a growable array we control in stead of the call stack we do not:
/// Bucket fill from (x, y): replace the 4-connected region of the pixel's
/// current index with `new_color`. Explicit stack, so a huge fill can never
/// overflow the call stack.
pub fn floodFill(self: *Canvas, allocator: std.mem.Allocator, x: u16, y: u16, new_color: u8) !void {
const target = try self.get(x, y);
if (target == new_color) return; // already this colour -- also stops an infinite loop
var stack = std.ArrayList([2]u16).init(allocator);
defer stack.deinit();
try stack.append(.{ x, y });
while (stack.pop()) |p| {
const px = p[0];
const py = p[1];
if ((self.get(px, py) catch continue) != target) continue;
self.pixels[self.index(px, py)] = new_color;
if (px > 0) try stack.append(.{ px - 1, py });
if (px + 1 < self.width) try stack.append(.{ px + 1, py });
if (py > 0) try stack.append(.{ px, py - 1 });
if (py + 1 < self.height) try stack.append(.{ px, py + 1 });
}
}
Two details carry the whole thing. The if (target == new_color) return guard is not an optimisation -- it is a correctness requirement. Without it, filling a red region with red would repaint red pixels with red forever, because they never stop matching the target. And the neighbour pushes bounds-check before pushing, so px - 1 on the left edge (px == 0) is simply never enqueued -- there is no way to underflow a u16 here, because we guard px > 0 first. This is the bug-for-bug shape of a real editor's fill, minus the fancy scanline optimisation we might add in a later part.
Seeing it: compositing onto the framebuffer
Indices are invisible. To actually look at the sprite we resolve each index through the palette and paint it onto the RGBA framebuffer from episode 156 -- scaled up by a zoom, because a 32x32 sprite is a postage stamp at 1:1 and we want each logical pixel to be a fat, editable block. Index 0 stays transparent, so whatever we drew behind (a checkerboard, say) shows through:
/// Composite the indexed canvas onto an RGBA framebuffer, magnifying each
/// logical pixel into a zoom x zoom block. Index 0 (transparent) is skipped.
pub fn blitTo(self: Canvas, palette: Palette, fb: []Rgba, fb_width: u16, zoom: u16) void {
var y: u16 = 0;
while (y < self.height) : (y += 1) {
var x: u16 = 0;
while (x < self.width) : (x += 1) {
const idx = self.pixels[self.index(x, y)];
if (idx == 0) continue; // transparent, leave the background alone
const color = palette.lookup(idx);
var dy: u16 = 0;
while (dy < zoom) : (dy += 1) {
var dx: u16 = 0;
while (dx < zoom) : (dx += 1) {
const fx = x * zoom + dx;
const fy = y * zoom + dy;
fb[@as(usize, fy) * fb_width + fx] = color;
}
}
}
}
}
This is the seam between the model (indices) and the view (pixels on a screen), and keeping it a single function matters. Everything to the left of blitTo thinks in palette indices; everything to the right thinks in RGBA. The editor's tools never touch colours, and the renderer never touches indices. When we add real alpha blending here later (episode 166 is waiting), only this one function changes.
Tying it together: the Document
A Document is what a "file" becomes once it is open: a canvas plus the palette that gives its indices meaning. Neither is useful without the other -- an index of 5 is meaningless without the palette that says slot 5 is #ff00aa. Bundling them is the unit the rest of the editor (and, in a later part, save and load) will operate on:
/// An open pixel art file: a canvas of indices plus the palette they refer to.
pub const Document = struct {
canvas: Canvas,
palette: Palette,
pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) !Document {
return .{
.canvas = try Canvas.init(allocator, width, height),
.palette = .{},
};
}
pub fn deinit(self: *Document) void {
self.canvas.deinit();
}
};
Only the canvas owns heap memory, so only the canvas needs a deinit -- the palette is a fixed inline array that lives and dies with the struct. That is a nice property of the indexed design: the colours are cheap and copyable, and all the real memory management is in one place.
Testing with nobody watching
Here is the payoff of a pure data model: we can test the entire editor core with no window, no mouse, no GPU -- just assertions about bytes in memory. Set a pixel and read it back. Confirm an out-of-range write is the typed error and not a corruption. Fill a region and prove the fill respected the boundary and left the rest alone:
const std = @import("std");
test "set and get a pixel, and out-of-bounds is a typed error" {
var canvas = try Canvas.init(std.testing.allocator, 8, 8);
defer canvas.deinit();
try canvas.set(3, 4, 5);
try std.testing.expectEqual(@as(u8, 5), try canvas.get(3, 4));
try std.testing.expectError(error.OutOfBounds, canvas.set(8, 0, 1));
try std.testing.expectError(error.OutOfBounds, canvas.get(0, 8));
}
test "flood fill recolours only the connected region" {
var canvas = try Canvas.init(std.testing.allocator, 4, 4);
defer canvas.deinit();
// a 2x2 block of index 1 in the top-left corner; everything else stays 0
try canvas.set(0, 0, 1); try canvas.set(1, 0, 1);
try canvas.set(0, 1, 1); try canvas.set(1, 1, 1);
try canvas.floodFill(std.testing.allocator, 0, 0, 7);
try std.testing.expectEqual(@as(u8, 7), try canvas.get(1, 1)); // filled
try std.testing.expectEqual(@as(u8, 0), try canvas.get(3, 3)); // untouched
}
test "palette add returns growing indices and rejects overflow only when full" {
var pal = Palette{};
const a = try pal.add(.{ .r = 255, .g = 0, .b = 0, .a = 255 });
const b = try pal.add(.{ .r = 0, .g = 255, .b = 0, .a = 255 });
try std.testing.expectEqual(@as(u8, 1), a); // slot 0 is reserved
try std.testing.expectEqual(@as(u8, 2), b);
try std.testing.expectEqual(@as(u8, 255), pal.colors[a].r);
}
These are the bugs real editors ship: a fill that leaks past its region (a missing target check), a click just off the canvas that scribbles into the next allocation (a missing bounds check), a palette that wraps when it fills up. All three are caught here, on the CPU, in microseconds, with nothing plugged in. Because the model is a pure function of memory, the tests are trivial to write -- and that is the real reason to keep the view out of the model.
Where Zig quietly pays off, and the same job elsewhere
A pixel editor is exactly the kind of program where the boring bugs hurt: an index computed wrong writes into memory that belongs to something else, and the crash shows up somewhere unrelated an hour later. Zig's choices bite down on precisely those. The usize widening before the index multiply makes the overflow a thing you see. The typed OutOfBounds error means a bad coordinate is a value in the type signature, not an assumption. The u16 palette counter next to the 256-slot array makes the full-palette off-by-one impossible. And because the whole model is allocation-explicit, the one place that owns memory (the canvas) is the one place with a deinit -- no hidden ownership, no leaks the test allocator will not catch.
In C, the canvas is a uint8_t* and a width, and nothing checks the index -- you write pixels[y * w + x] and pray the caller validated x and y, forever. In Rust, the design is very close to ours: a Vec<u8>, a slice, Result for the fallible ops -- what it lacks is Zig's ease of dropping the bounds check on the hot clear path without reaching for unsafe. In Go, you get a bounds-checked slice for free (a real safety win over C), but the check is always on, even in the blit inner loop where you have already proven the coordinate is in range, and the garbage collector owns the buffer's lifetime in stead of you. Zig lands where this whole systems series keeps landing: the directness of C, the safety of Rust at the edges, and you decide, per operation, where the check earns its cost.
That is the foundation. We have a document -- a canvas of indices, a palette of colours, safe pixel access, a bucket fill, and a way to see the whole thing magnified on a framebuffer -- and every bit of it is tested without a single pixel ever reaching a screen. It is small, it is honest, and it is the bedrock the tools stand on. Next part, we start making it do things: the interactive side, where coordinates come from a real input loop and the canvas starts to feel like an editor in stead of a data structure. Plenty still to build, and it gets more fun from here.
Bedankt voor het lezen, en tot de volgende keer! ;-)