# Design ## Context `redraw()` in `wlgol.c` currently does everything: accumulates time and runs simulation ticks, handles editing, clears the whole framebuffer, draws grid lines, scans all cells, and commits. It is invoked from `wl_callack_done` whenever the (single) buffer is released, i.e. at display refresh rate. The simulation ticks at ~20 tps and starts paused, so most frames are identical repaints. The app renders into one persistent SHM buffer and waits for its release before reattaching. ## Goals / Non-Goals **Goals:** - Near-zero CPU when paused and idle; render cost proportional to changed cells while running. - Keep the visible output identical to the current renderer. - Keep the blocking `wl_display_dispatch` event-loop model. **Non-Goals:** - Optimizing `fill_rect` itself (SIMD-style row fills); a full repaint is now rare. - Double buffering, EGL/Vulkan, subsurfaces. - Changing simulation semantics, editing rules, or key bindings. ## Decisions ### 1. Simulation timing moves out of the renderer into the event loop Tick accumulation (`sim_accumulator`, `simulation_tick()`) runs once per loop iteration after dispatch, not inside `redraw()`. Rationale: if rendering is skipped, ticks must still occur or the simulation freezes. The event loop stays blocking on `wl_display_dispatch`; each frame callback (or input event) wakes it. ### 2. Idle = empty commit When nothing is dirty, the loop attaches nothing and commits only a freshly requested `wl_surface_frame` callback. This keeps frame callbacks flowing (the loop stays event-driven) without giving the compositor new content. Alternative considered: a timer fd / sleep-based loop — rejected, it changes the event-loop model for no benefit since frame callbacks already provide the heartbeat. ### 3. Dirty sources, one flag each A single `screen_dirty` flag plus a `full_redraw` flag, set by: - simulation tick applied → `screen_dirty` - grid modification (paint drag, click placement) → `screen_dirty` - pointer motion/crossing while `active_tool >= 0` → `screen_dirty` - tool select / rotate / grid-line toggle → `screen_dirty` + `full_redraw` - `need_resize` → `screen_dirty` + `full_redraw` Rationale: content has exactly five sources of truth (ticks, edits, preview, toggle, resize); covering all of them is what makes "skip the frame" safe. Forgetting the preview path is the classic bug here — without it the cursor preview would smear. ### 4. Diff against a rendered-state snapshot, not dirty-tracking in `grid_set` Add `grid_rendered` (a third grid buffer alongside `grid`/`grid_back`). After each render, copy the current grid into it. At render time, diff row-by-row with `memcmp` (9216 bytes total — negligible) and repaint only differing cells. Alternatives: - Marking dirty cells inside `grid_set` — rejected: every call site becomes a correctness dependency, and `simulation_tick` swaps whole buffers anyway, so per-set tracking would need extra bookkeeping after the swap. - Diffing `grid` vs `grid_back` (previous generation) — rejected: `grid_back` is clobbered by the next tick's computation and misses edits made between ticks. The snapshot approach is independent of *why* the grid changed, so ticks, painting, and pattern placement all work through one mechanism. ### 5. Per-cell repaint includes shared edges Repainting cell (c, r) draws: background fill for the cell area, the cell body if alive, and the grid-line fragments on all four of its edges (x = c·cell_px and (c+1)·cell_px columns, y = r·cell_px and (r+1)·cell_px rows, clipped to the cell). When a neighbor didn't change, its edge gets redrawn identically — harmless. When it did change, it repaints itself. This avoids the "line between two cells vanishes" bug that cell-local-only edges would cause. ### 6. Preview = extra dirty cells, drawn last The preview is overlay state, not grid state, so `grid_rendered` cannot track it. At render time, the dirty cell set is: `diff(grid, grid_rendered)` ∪ `preview_cells(last frame)` ∪ `preview_cells(this frame)`. Store the last preview's cells (and whether it was shown) between frames. Preview cells are repainted (so the overlay disappears correctly) and the new overlay is drawn after all cell work, preserving the current draw order. ### 7. Keep the single buffer; gate real frames on release Incremental rendering relies on the framebuffer surviving between frames — the existing single SHM buffer already provides that. When dirty, render only if the buffer is `released`; otherwise leave `screen_dirty` set and render on the next frame callback after release (same gating as today, just conditional). Alternative: double buffering + copying the front buffer — rejected as a bigger change for no additional benefit at this scale. ### 8. Damage = bounding box of repainted cells `wl_surface_damage` accepts one rectangle per commit; use the bounding box of all cells repainted this frame (whole canvas for full redraws, empty for idle commits). Per-cell damage rects would need `wl_surface_damage_buffer` per rect — not worth the complexity for a SHM client where the compositor-side gain is small. ## Risks / Trade-offs - [Empty-commit frame callbacks may throttle or behave inconsistently across compositors] → The heartbeat is only used for timing ticks and deferred renders; if a compositor stops calling back, the next input event still wakes the loop. Behavior on the reference compositor (existing setup) is verified manually. - [Preview drag while painting causes near-continuous rendering] → Accepted: that is genuinely changing content every frame, and per-frame cost is now proportional to the moved cells, not the canvas. - [FPS counter now counts rendered frames, not display refreshes] → Expected side effect of skipping idle frames; the log line semantics shift with this change. - [Diff covers whole grid on dense animations] → `full_redraw` style fallback is not needed; even a full-diff repaint via per-cell work stays correct, just slower. Revisit only if it shows in practice. ## Migration Plan Two independently verifiable stages, both internal to `wlgol.c` (plus a `grid_rendered` snapshot helper): 1. Event-driven rendering: move tick timing out of `redraw()`, add dirty flags and the empty-commit idle path. Verify: paused idle CPU ≈ 0%, running still animates, editing/preview still update. 2. Incremental repaint: add `grid_rendered`, diff, per-cell repaint, bounded damage. Verify: no visual regression vs stage 1 (grid lines intact at shared edges, preview clears correctly). Rollback is a plain `git revert` — no data or protocol changes. ## Open Questions None.