Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3

Words
4422
Reading
20 min
Listen
Play
17h

Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3

zig.png

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 Canvas of palette indices, the Palette, set/get, and blitTo;
  • The interactive layer from Part 2 (episode 176): the Editor, the Tool union, screenToCell, and the pointer-event handle loop;
  • Comfort with std.ArrayList and allocators (episode 7), error unions and defer (episode 4), slices (episode 5), and testing (episode 12);
  • The ambition to learn Zig programming.

Difficulty

  • Advanced

Curriculum (of the Learn Zig Series):

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! ;-)

scipio@scipio

Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3 | Ecency