Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2

Words
4266
Reading
19 min
Listen
Play
1d

Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2

zig.png

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 Canvas of indices, the Palette, set/get, floodFill and blitTo;
  • 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):

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

scipio@scipio

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