/** * Bounded undo/redo for the canvas (T-167/T-170). A generic ring over * immutable snapshots — the canvas ops above already return fresh objects, * so the history stores references (no deep copies needed). * * Bounded: at most `capacity` past states. Oldest states are evicted when * the bound is exceeded (the stack then cannot undo further than that — the * renderer should surface "history truncated"). Deterministic; no I/O. */ export class OpStack { private past: T[] = []; private future: T[] = []; private current: T; readonly capacity: number; constructor(initial: T, capacity = 100) { if (capacity < 1) throw new Error("capacity must be ≥ 1"); this.current = initial; this.capacity = capacity; } /** Push a new state as the result of an operation. Clears redo. */ push(next: T): void { if (Object.is(next, this.current)) return; // no-op (e.g. same reference) this.past.push(this.current); if (this.past.length > this.capacity) this.past.shift(); // evict oldest this.current = next; this.future = []; this.pushed++; } /** Replace the present state WITHOUT an undo step (seed after a load, or * after a restore — the previous past/future is discarded). */ reset(next: T): void { this.past = []; this.future = []; this.current = next; this.pushed = 0; } get present(): T { return this.current; } canUndo(): boolean { return this.past.length > 0; } canRedo(): boolean { return this.future.length > 0; } /** Number of states available to undo (≤ capacity). */ get depth(): number { return this.past.length; } /** True when we have evicted past the bound (renderer shows "truncated"). */ get truncated(): boolean { // we can only know truncation happened if we pushed past capacity; track // via a monotonic counter of total pushes. return this.pushed > this.capacity; } undo(): T { if (!this.canUndo()) return this.current; this.future.push(this.current); this.current = this.past.pop()!; return this.current; } redo(): T { if (!this.canRedo()) return this.current; this.past.push(this.current); this.current = this.future.pop()!; return this.current; } private pushed = 0; }