⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.246+05671ced9What's newOn an older Zig?Blog

Undo

Updated

People expect one Undo to remove one action.

One action is one stroke, one fill. Not one dab, and not one pointer sample.

So the history saves something once per action, just before the action changes a layer. The question is what to save.

Saving the whole picture is expensive

The simplest answer is a copy of every layer.

At 640 by 400 with two layers, one copy is 2 × 640 × 400 × 4 = 2,048,000 bytes. Eight undo steps would take 16,384,000 bytes, about 15.6 MiB.

That is a lot for a short line in one corner. Most of each copy is pixels the action never touched.

Saving what changed

Every action here already knows which rectangle it touches.

A stroke knows it from the stroke buffer’s dirty rectangle. A fill knows the bounds of the region it found.

So the history copies that rectangle, from that one layer, just before the action changes it.

A stroke 100 pixels wide and 60 high saves 100 × 60 × 4 = 24,000 bytes, about 1% of a full copy.

examples/paint/src/engine/history.zigEntry
pub const Entry = struct {
    layer: u32,
    rect: Rect,
    /// `rect.area()` pixels, row by row.
    pixels: []Rgba,

    fn bytes(e: Entry) usize {
        return e.pixels.len * @sizeOf(Rgba);
    }
};

Undo and redo are one swap

To undo, put the saved pixels back.

But redo needs the pixels the action made, and putting the old ones back would overwrite them.

So undo does not copy. It swaps: the saved pixels go into the layer, and the layer’s current pixels go into the entry.

Now the entry holds the newer pixels. Redo is the same swap again.

examples/paint/src/engine/history.zigswap
    fn swap(e: Entry, layer: []Rgba, width: u32) void {
        var i: usize = 0;
        var y = e.rect.y0;
        while (y < e.rect.y1) : (y += 1) {
            const row = @as(usize, y) * width;
            for (layer[row + e.rect.x0 .. row + e.rect.x1], e.pixels[i..][0..e.rect.width()]) |*a, *b| {
                std.mem.swap(Rgba, a, b);
            }
            i += e.rect.width();
        }
    }

One function does both jobs, so undo and redo cannot drift apart.

Two stacks, one array

The usual design keeps two stacks.

Undo pops an entry off the past stack and pushes it onto the future stack. Redo does the opposite.

Here the two stacks are one array and an index called cursor. Entries before the cursor can be undone. Entries from the cursor on can be redone.

entries   [ A ][ B ][ C ][ D ]
                         ^
                       cursor

Here A, B and C can be undone and D can be redone.

examples/paint/src/engine/history.zigHistory fields
pub const History = struct {
    entries: std.ArrayList(Entry) = .empty,
    cursor: usize = 0,
    budget: usize,
    used: usize = 0,

    // ...
};

Undo moves the cursor back one and swaps. Redo swaps and moves it forward:

examples/paint/src/engine/history.zigundo
    /// Undo the newest action. Returns the entry so the caller knows which
    /// layer and rectangle to redraw, or null when there is nothing to undo.
    pub fn undo(h: *History, layers: []const []Rgba, width: u32) ?Entry {
        if (!h.canUndo()) return null;
        h.cursor -= 1;
        const e = h.entries.items[h.cursor];
        swap(e, layers[e.layer], width);
        return e;
    }

Moving an entry from one stack to the other would need room in the other stack, and getting room can fail. Moving an index cannot fail. So undo and redo never allocate, and never fail.

A new action drops the future

Draw A, then B. Undo B. Then draw C.

Can Redo bring B back now?

It must not. B was drawn on top of A, before C existed. Putting it back over C would make a picture that never existed.

So recording a new action first deletes everything from the cursor on. That is the first thing record does.

A memory budget

The history has a limit in bytes. When a new entry would go over it, the oldest entries are dropped until it fits.

Dropping the oldest entry means the earliest actions can no longer be undone. That is the price of a limit.

One entry can also be bigger than the whole budget, for example a fill of the whole page with a small budget. Then the history clears itself completely.

Keeping the older entries would be worse. Undo would skip over the action that could not be saved. The older entries would then put back pixels from before that action, in some places and not others, and the result would be a picture that never existed.

When memory runs out

Here is the whole of record:

examples/paint/src/engine/history.zigrecord
    /// Copy `rect` of `layer` before an action changes it. Returns false when
    /// the copy could not be kept, either because it alone is larger than the
    /// budget or because memory ran out. The action should go ahead anyway.
    pub fn record(
        h: *History,
        gpa: std.mem.Allocator,
        layer: u32,
        pixels: []const Rgba,
        width: u32,
        rect: Rect,
    ) bool {
        if (rect.isEmpty()) return true;

        // A new action starts a new branch. What could have been redone no
        // longer follows from the picture, so it goes.
        h.dropFrom(gpa, h.cursor);

        const need = rect.area() * @sizeOf(Rgba);
        if (need > h.budget) {
            // Even an empty history could not hold this one. Keeping the old
            // entries would let undo skip over this action and restore a
            // picture that never existed, so they go too.
            h.dropFrom(gpa, 0);
            return false;
        }
        while (h.used + need > h.budget) h.dropOldest(gpa);

        // Make room in the list first, so a failure after the copy has been
        // made cannot leave a copy that nothing owns.
        while (true) {
            if (h.entries.ensureUnusedCapacity(gpa, 1)) |_| break else |_| {}
            if (h.entries.items.len == 0) return false;
            h.dropOldest(gpa);
        }
        const copy = while (true) {
            if (gpa.alloc(Rgba, rect.area())) |buf| break buf else |_| {}
            if (h.entries.items.len == 0) return false;
            h.dropOldest(gpa);
        };

        var i: usize = 0;
        var y = rect.y0;
        while (y < rect.y1) : (y += 1) {
            const row = @as(usize, y) * width;
            @memcpy(copy[i..][0..rect.width()], pixels[row + rect.x0 .. row + rect.x1]);
            i += rect.width();
        }
        h.entries.appendAssumeCapacity(.{ .layer = layer, .rect = rect, .pixels = copy });
        h.cursor = h.entries.items.len;
        h.used += need;
        return true;
    }

It returns bool, not an error.

In Zig, running out of memory is an ordinary error value, error.OutOfMemory. The program decides what to do with it.

The decision here is that the stroke matters more than its undo. If the copy cannot be made, the stroke still goes onto the layer. Only the undo step is lost, and record returns false. The studio then shows a line under the canvas saying the last action cannot be undone.

Before giving up, record drops the oldest entries one at a time and tries again. Old undo steps are worth less than the new one.

The order of the two allocations matters too. Room in the list is made first, and the copy second. If the copy were made first and the list then failed to grow, the copy would have no owner and would leak.

Checks

Undo, redo, and back again:

examples/paint/src/engine/history.zigundo restores, redo repeats, and both can go back and forth
test "undo restores, redo repeats, and both can go back and forth" {
    const gpa = testing.allocator;
    var layer: [8 * 8]Rgba = @splat(.transparent);
    const layers = [_][]Rgba{&layer};
    var h: History = .init(1 << 20);
    defer h.deinit(gpa);

    const rect: Rect = .{ .x0 = 2, .y0 = 2, .x1 = 5, .y1 = 4 };
    try testing.expect(h.record(gpa, 0, &layer, 8, rect));
    fillRect(&layer, 8, rect, 9);
    const drawn = layer;

    _ = h.undo(&layers, 8).?;
    const empty: [8 * 8]Rgba = @splat(.transparent);
    try testing.expectEqual(empty, layer);
    _ = h.redo(&layers, 8).?;
    try testing.expectEqual(drawn, layer);
    _ = h.undo(&layers, 8).?;
    _ = h.redo(&layers, 8).?;
    try testing.expectEqual(drawn, layer);

    try testing.expect(h.redo(&layers, 8) == null);
}

The A, B, C example:

examples/paint/src/engine/history.zigdrawing after an undo drops the redo branch
test "drawing after an undo drops the redo branch" {
    const gpa = testing.allocator;
    var layer: [8 * 8]Rgba = @splat(.transparent);
    const layers = [_][]Rgba{&layer};
    var h: History = .init(1 << 20);
    defer h.deinit(gpa);

    const a: Rect = .{ .x0 = 0, .y0 = 0, .x1 = 2, .y1 = 2 };
    const b: Rect = .{ .x0 = 4, .y0 = 4, .x1 = 6, .y1 = 6 };
    _ = h.record(gpa, 0, &layer, 8, a);
    fillRect(&layer, 8, a, 1);
    _ = h.record(gpa, 0, &layer, 8, b);
    fillRect(&layer, 8, b, 2);

    _ = h.undo(&layers, 8); // B is undone
    _ = h.record(gpa, 0, &layer, 8, b); // C, drawn where B was
    fillRect(&layer, 8, b, 3);

    try testing.expect(!h.canRedo()); // B cannot come back
    try testing.expectEqual(@as(usize, 2), h.entries.items.len);
}

The budget, with room for exactly three entries and five actions:

examples/paint/src/engine/history.zigthe budget drops the oldest entries
test "the budget drops the oldest entries" {
    const gpa = testing.allocator;
    var layer: [8 * 8]Rgba = @splat(.transparent);
    const layers = [_][]Rgba{&layer};
    // Room for exactly three 2x2 copies.
    var h: History = .init(3 * 4 * @sizeOf(Rgba));
    defer h.deinit(gpa);

    for (0..5) |i| {
        const x: u32 = @intCast(i);
        const r: Rect = .{ .x0 = x, .y0 = 0, .x1 = x + 2, .y1 = 2 };
        try testing.expect(h.record(gpa, 0, &layer, 8, r));
        fillRect(&layer, 8, r, @intCast(i + 1));
        try testing.expect(h.used <= h.budget);
    }
    try testing.expectEqual(@as(usize, 3), h.entries.items.len);
    var undone: usize = 0;
    while (h.undo(&layers, 8) != null) undone += 1;
    try testing.expectEqual(@as(usize, 3), undone);
}

And running out of memory. Zig’s testing allocator can fail on purpose. This test runs the same four actions again and again, failing a different allocation each time, until every allocation has failed once:

examples/paint/src/engine/history.zigrunning out of memory loses undo steps, never pixels, and leaks nothing
test "running out of memory loses undo steps, never pixels, and leaks nothing" {
    // Run the same four actions once per allocation they make, with that one
    // allocation failing. `std.testing.checkAllAllocationFailures` cannot do
    // this job: it expects the failure to come back as an error, and here the
    // whole point is that it does not.
    var fail_index: usize = 0;
    while (true) : (fail_index += 1) {
        var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = fail_index });
        const gpa = failing.allocator();

        var layer: [8 * 8]Rgba = @splat(.transparent);
        const layers = [_][]Rgba{&layer};
        var h: History = .init(1 << 20);
        var kept: usize = 0;
        for (0..4) |i| {
            const x: u32 = @intCast(i * 2);
            const r: Rect = .{ .x0 = x, .y0 = 0, .x1 = x + 2, .y1 = 2 };
            if (h.record(gpa, 0, &layer, 8, r)) kept += 1;
            fillRect(&layer, 8, r, 200); // the action happens either way
        }
        // Every undo the history still holds must restore real pixels.
        while (h.undo(&layers, 8)) |e| {
            for (e.rect.y0..e.rect.y1) |y| for (e.rect.x0..e.rect.x1) |x| {
                try testing.expectEqual(Rgba.transparent, layer[y * 8 + x]);
            };
        }
        h.deinit(gpa);
        try testing.expectEqual(failing.allocated_bytes, failing.freed_bytes);
        if (!failing.has_induced_failure) {
            try testing.expectEqual(@as(usize, 4), kept);
            break; // every allocation has now been made to fail once
        }
    }
}

Each run checks three things: every action still changed the layer, every undo that was kept restores the right pixels, and every byte allocated was freed.

Explain before reading the answer

The budget is 16 MiB. You fill the whole 640 by 400 top layer, then draw 20 small strokes.

How many undo steps are there?

All 21. The fill saved one full layer, 1,024,000 bytes, and the strokes saved only their rectangles. Together they are far under 16 MiB, so nothing was dropped.