~14 min · undo, memory, swift, enums, copy-on-write
Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Fourteen Kinds of Change
Every edit you can take back in Ariandel becomes one value of a single type, HistoryStep, in the engine's History.swift. The edit happens first; the step records what already happened; undo applies it backwards and redo forwards. There is no command object with an execute() method and no closure that captures the canvas. A step is data: a before, an after, and just enough identity (a layer id, a stack index) to find where it applies.
The type has fourteen cases (first code block). Pixels: tiles(layerID:before:after:). The stack: insertLayer, removeLayer, moveLayer, and layerOrder for a group that moves as a block. Per-layer state: layerProperties, layerText and mask. The document: background, selection, canvasSize, groups and savedSelections. And one case that holds the others: group([HistoryStep]).
That last case holds values of its own type, and the enum is declared indirect, which boxes every case's payload on the heap. Strictly, this case would compile without it: an array keeps its elements in heap storage already, so a [HistoryStep] is a fixed-size value. Only a case holding a HistoryStep directly, with no array between, would make Swift demand the keyword. What the group case really buys is that one gesture undoes as one step. Merge Down records a group: the lower layer's new tiles, the upper layer's removal, and, when they apply, the lower layer's text and mask going away. A crop is a group too: a canvasSize step plus a tiles step for each layer. Undo walks a group backwards; redo walks it forwards.
The enum did not start this big. The engine commit that created it, on the evening the repository was born, had seven cases. Every later feature that could be undone added its own case instead of a side channel, which is how undo stays tile-exact (the architecture's fourth invariant) while the app grows around it.
What a Step Is Charged
The history is bounded by memory, not only by count, so every step knows its own cost: byteCount, computed once when the step is recorded. One rule sits under every case: charge the step for what only it keeps alive.
Tiles: the before-state, 32 KiB for each tile that existed, with a 64-byte floor. While the step sits on the undo stack, the after-state is free: with copy-on-write tiles (Track 3) it is either the live tile itself or the next step's before-state, and somebody else already pays for it. Undo opens a gap. The layer goes back to holding the before-state, and the undone step's after-state can be held by nothing but its redo entry, which counts only before-states. A stroke on fresh paper has a before-state of nothing but nils and costs 64 bytes. Undo it, and that 64-byte entry keeps every 32 KiB tile the stroke created alive. The budget undercounts redo until a new step clears it or redo hands the tiles back to the layer. The doc comment on byteCount (first code block) tells only the undo-stack half.
Insert and remove layer: the whole layer's tiles. A removed layer has no other holder while it is out of the stack, and an inserted one has none the moment it is undone.
Selection: both sides, plus 64. This case misses the rule in the opposite direction from the redo gap. A SelectionMask is a dense byte per pixel, but it keeps them in a copy-on-write array, so the step's after-mask is the same storage the live selection holds, and later the next selection step's before. Those bytes can be charged twice, which errs toward evicting early.
Mask: 64 bytes, plus the mask's paint when the mask itself was added or removed.
Saved selections: only the masks that leave the list.
The rest: 64 bytes each. Group: the sum of its parts.
The first version charged a tile step for its before and its after, and on that evening it was honest: history held its own copies of both. Commit 6479f1f made tiles copy-on-write and changed the charge in the same diff. A cost model has to move when the sharing moves, or the budget quietly lies.
Two Bounds and a Trim
The defaults are History(byteBudget: 768 * 1024 * 1024, maximumSteps: 250): 768 MiB and 250 steps. Recording a step clears redo and calls trim(). Trim drops the oldest undo steps until there are at most 250, keeps dropping the oldest while the total is over budget, and only then starts on redo, the farthest step first.
Make 768 MiB concrete. It is 24,576 tile before-states. A fully painted 4096×4096 layer is 4,096 tiles, 128 MiB, so the budget holds six "clear this whole layer" steps, or a few hundred ordinary strokes that each repaint a few dozen tiles. Deleting a big painted layer is among the most expensive single steps in the app: that step owns the entire layer until it falls off the end.
Shedding Under Pressure
iOS may warn an app when memory runs low. The warning is best effort: the system can still end the app without one, so a hook like this is a chance to give memory back, not a guarantee of a last word. Ariandel's answer is narrow on purpose (third code block). Studio observes didReceiveMemoryWarningNotification on the main queue; the open canvas calls history.shed(keeping: 0.25); and if the undo stack got shorter, a short toast says so. Only the undo stack: handleMemoryWarning compares undoStack.count before and after, and trim reaches redo only once the undo stack is empty. So the toast stays silent in exactly one case, and it is a real one. Undo everything, get a memory warning, and shed drops redo steps without a word. shed takes a quarter of the history's current bytes and hands it to trim as a one-time budget. Its doc comment ends with the sentence that matters: Layers are never touched. Layers are the painting. Undo is a convenience built on top of it, and only the convenience is negotiable.
This hook arrived late. The architecture promised "memory pressure trims history first" from the first evening; the code did not exist until the 6479f1f review read the document against the code and noticed. The engine test that guards it now builds a 2 MiB history, paints twelve overlapping strokes, sheds to a quarter, and checks two things: the history holds at most 512 KiB, and the layer still has its tiles.
The second code block is a sketch you can compile in a minute. With an 8 MiB budget it prints a fresh stroke costing 64 bytes, thirteen records trimmed to seven steps (7,680 KiB, oldest kept "Stroke 7"), then a shed to a quarter leaving two steps (1,280 KiB).
Count what only you hold. A memory budget over shared data is honest only when each holder is charged for the bytes that would be freed if it went away. Shared bytes charged twice make the budget evict too early; owned bytes treated as shared make it evict too late. The redo gap is the second kind.
Code
The fourteen cases and their charges (AriandelEngine/History.swift, verbatim)·swift
/// One undoable change to an artwork.
public indirect enum HistoryStep {
/// Pixels of one layer changed (a stroke, a clear, a fill, a merge's target).
case tiles(layerID: UUID, before: TileSnapshot, after: TileSnapshot)
/// A layer was inserted at `index`.
case insertLayer(Layer, index: Int)
/// A layer was removed from `index`.
case removeLayer(Layer, index: Int)
/// A layer moved between stack positions.
case moveLayer(from: Int, to: Int)
/// The stack reordered at once (a group moved as a block), by layer id, bottom to top.
case layerOrder(before: [UUID], after: [UUID])
/// Properties of one layer changed (name, visibility, opacity, blend, lock).
case layerProperties(layerID: UUID, before: LayerProperties, after: LayerProperties)
/// Background color changed (nil is transparent).
case background(before: RGBAColor?, after: RGBAColor?)
/// The selected area changed (nil selects everything).
case selection(before: SelectionMask?, after: SelectionMask?)
/// The canvas changed size (crop, resize, quarter turns); its layers'
/// pixels move in `.tiles` steps of the same group.
case canvasSize(before: CanvasSize, after: CanvasSize)
/// The layer groups changed (made, renamed, shown or hidden, removed).
case groups(before: [LayerGroup], after: [LayerGroup])
/// A text layer's text changed, or the layer stopped being text (nil).
case layerText(layerID: UUID, before: LayerText?, after: LayerText?)
/// A layer's mask was added, removed, inverted, enabled or disabled; its
/// paint changes in `.tiles` steps keyed by the mask's own id.
case mask(layerID: UUID, before: LayerMask?, after: LayerMask?)
/// The saved selections changed (saved, deleted, moved with the canvas).
case savedSelections(before: [SavedSelection], after: [SavedSelection])
/// Several steps that undo and redo as one (merge down, paste as layer).
case group([HistoryStep])
/// Bytes this step keeps alive, for the history budget.
///
/// Tiles are shared (see `TileBuffer`): a step's after-state is the live
/// tile or the next step's before-state, so only the before-state is
/// memory this step alone holds, and only it is counted.
var byteCount: Int {
switch self {
case let .tiles(_, before, _):
max(64, before.values.compactMap { $0 }.count * Tile.byteCount)
case let .insertLayer(layer, _), let .removeLayer(layer, _):
max(64, layer.tiles.byteCount)
case .moveLayer, .layerOrder, .layerProperties, .background, .canvasSize, .groups, .layerText:
64
case let .selection(before, after):
(before?.byteCount ?? 0) + (after?.byteCount ?? 0) + 64
case let .mask(_, before, after):
// A mask added or removed is held here while it is out of the layer.
64 + (before?.layer === after?.layer ? 0 : max(before?.layer.tiles.byteCount ?? 0,
after?.layer.tiles.byteCount ?? 0))
case let .savedSelections(before, after):
// Masks only this step keeps: deleted, or replaced by moved ones.
64 + before.filter { !after.contains($0) }.reduce(0) { $0 + $1.mask.byteCount }
case let .group(steps):
steps.reduce(0) { $0 + $1.byteCount }
}
}
A minimal sketch: a byte-budgeted undo stack with shed(keeping:) (compiled and run with swiftc in Swift 6 mode)·swift
// A minimal sketch of a byte-budgeted undo stack, shaped like Ariandel's History.
// Build and run: swiftc -swift-version 6 history.swift -o history && ./history
final class Tile { // stands in for a 32 KiB fix15 tile
static let byteCount = 64 * 64 * 4 * 2
}
indirect enum Step {
case tiles(before: [Int: Tile?], after: [Int: Tile?])
case rename(before: String, after: String)
case group([Step])
// Only the before-state is counted. On the undo stack the after-state is the
// live tile or the next step's before-state. Once undone, a redo entry can be
// its only holder, and that goes uncounted, here as in Ariandel.
var byteCount: Int {
switch self {
case let .tiles(before, _): max(64, before.values.compactMap { $0 }.count * Tile.byteCount)
case .rename: 64
case let .group(steps): steps.reduce(0) { $0 + $1.byteCount }
}
}
}
final class History {
private(set) var undo: [(label: String, step: Step, bytes: Int)] = []
private(set) var redo: [(label: String, step: Step, bytes: Int)] = []
let byteBudget: Int
let maximumSteps: Int
init(byteBudget: Int, maximumSteps: Int) {
self.byteBudget = byteBudget
self.maximumSteps = maximumSteps
}
var byteCount: Int { (undo + redo).reduce(0) { $0 + $1.bytes } }
func record(_ step: Step, label: String) {
undo.append((label, step, step.byteCount))
redo.removeAll()
trim()
}
// The memory-warning hook: keep a fraction of what is held now. Layers are not in here.
func shed(keeping fraction: Double) {
trim(toBytes: Int(Double(byteCount) * max(0, min(1, fraction))))
}
// Oldest undo steps go first, then the farthest redo, until both bounds hold.
func trim(toBytes limit: Int? = nil) {
let budget = limit ?? byteBudget
while undo.count > maximumSteps { undo.removeFirst() }
var total = byteCount
while total > budget, !undo.isEmpty { total -= undo.removeFirst().bytes }
while total > budget, !redo.isEmpty { total -= redo.removeFirst().bytes }
}
}
func kib(_ bytes: Int) -> String { "\(bytes / 1024) KiB" }
// A small budget so the numbers are readable: 8 MiB, 250 steps.
let history = History(byteBudget: 8 << 20, maximumSteps: 250)
// A first stroke on empty tiles: no before-state at all, so it costs the 64-byte floor.
history.record(.tiles(before: [0: nil, 1: nil], after: [0: Tile(), 1: Tile()]), label: "Stroke 1")
print("fresh stroke costs", history.undo[0].bytes, "bytes")
// Strokes over painted tiles: each keeps 40 tiles it replaced (1.25 MiB).
for n in 2...12 {
var before: [Int: Tile?] = [:], after: [Int: Tile?] = [:]
for key in 0..<40 { before[key] = Tile(); after[key] = Tile() }
history.record(.tiles(before: before, after: after), label: "Stroke \(n)")
}
history.record(.group([.rename(before: "Layer 1", after: "Ink"), .rename(before: "Layer 2", after: "Wash")]),
label: "Rename both")
print("after 13 records:", history.undo.count, "steps,", kib(history.byteCount),
"oldest kept:", history.undo.first!.label)
history.shed(keeping: 0.25)
print("after shed(0.25):", history.undo.count, "steps,", kib(history.byteCount),
"oldest kept:", history.undo.first!.label)
// "This tile did not exist" must survive in a before-state. Subscript assignment
// of nil removes the key; updateValue keeps it (the engine's first-day bug).
var snapshot: [Int: Tile?] = [:]
snapshot[7] = nil
print("subscript nil keeps key 7:", snapshot.keys.contains(7))
snapshot.updateValue(nil, forKey: 7)
print("updateValue(nil) keeps key 7:", snapshot.keys.contains(7))
The memory-warning path (AriandelMobile, App/Studio.swift and Canvas/CanvasModel.swift, verbatim)·swift
// App/Studio.swift
// Memory pressure trims history first, oldest steps first, and never
// touches layers (architecture: Failure, Recovery, and Safety).
memoryWarnings = NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.canvas?.handleMemoryWarning() }
}
// Canvas/CanvasModel.swift
/// The system is short of memory: the oldest undo steps go first, the
/// layers never.
func handleMemoryWarning() {
let before = artwork.history.undoStack.count
artwork.history.shed(keeping: 0.25)
if artwork.history.undoStack.count < before {
flash("Memory is low: the oldest undo steps were let go")
}
revision += 1
}
Compile and run the sketch, then extend it. Add a case removeLayer(tiles: [Tile]) charged for every tile it holds. Set the budget to 768 MiB, record 300 strokes that each keep 40 tiles, then record the removal of a fully painted 4096×4096 layer (4,096 tiles). Before you run it, write down how many steps you expect to survive and which bound will do the trimming. Then run it and explain any difference.
Hint
Do the arithmetic first: one stroke is 40 × 32 KiB = 1.25 MiB, so 300 strokes would be 375 MiB, but the step bound of 250 trims before the byte bound ever matters. The removal adds 128 MiB on top. Check which loop in trim fires first, and remember the step bound only looks at the undo stack.
Progress
Progress is local-only — sign in to sync across devices.