Render-path roadmap — what's left to do¶
Status snapshot of the render-correctness + slow-terminal-perf work
started from the structural-proposal review on b9f493f. This is a
working doc: items move out as they ship.
Already shipped (visible in main log)¶
- Runtime canvas-clear fix in
app.cpp(mirror of 489347b for therun<Program>/run(event_fn,render_fn)path that the original commit missed). - Per-row
last_content_colcache inCanvas— O(1) lookups replacing backward linear scans inserialize,compose_inline_frame, anddiff. render_tree_inline_autogrow— single paint pass via two-compute measure→resize→recompute pattern. Closes the OOB cache window. Seedocs/internals/inline-autogrow.mdfor the failure-mode record.Canvas::set_wide— atomic paired wide-char write; orphan trail/lead unrepresentable in the API.render::emit::erase_to_eol_safe— centralised the SGR-reset-before-EL invariant in one header.TextElement::formatreturnsWrappedLine{text, byte_offset}— drops the O(content × lines) substring scan in the painter's word-wrap+runs path.- A6:
ScrollbackMarker— typed token forInlineFrameStatescrollback commits, clamped to liveprev_rows. Forces callers to query current state instead of carrying a stale int. - B7:
StylePool::write_transition_sgr— differential SGR transitions, emits only the attribute toggles + fg/bg setters that changed betweenprev_idandnew_id. ~50%+ byte reduction per style change on style-heavy content. - B1, B8: bandwidth coalesce (
Writer::ns_per_byteEMA +compose_inline_frame(min_changed_rows)hold-and-accumulate) — removed. The hold swallowed keystrokes on the typing path: a 1-row composer change would meet the hold criterion and the first two keystrokes would only land when the third one forced a flush. Streaming pacing at ~20 Hz makes the saved syscalls negligible anyway. If a similar optimisation is ever needed it should live ABOVE compose (the caller decides not to render this tick) rather than inside it, where compose has no way to tell user input from animation. - B9: Animation unified onto a single clock — the
Sub::Everytimer path.request_animation_frame()records a per-render frame request (thread-local, likelive_scroll_states) that the run loop folds into the same wake schedule as timers;on_animation_frame()is sugar forevery(16ms). No separate deadline global, no parallel pump. Idle frames render zero bytes. - B10:
Canvas::clearbounded by previousmax_y_+1— ~10× less memory traffic per inline frame on a 500-row canvas with ~50 rows of content. The invariant ("cells beyondmax_y_are blank") holds inductively. Originally caused a ghost, since root-caused and fixed via 88516a6 + 69688c5. - A1:
prev_cellsshadow-of-wire —compose_inline_frame's per- row loop writes the just-emitted slice intoprev_cellsin place ([x_first_diff, x_last_diff+1]for common rows, full row for new rows) instead of bulk-memcpying[first_changed*W, content_rows*W]at end of frame. Sparse-streaming frames drop from "copy every row below first_changed" to "copy only changed segments per row". - A2:
CanvasStage{Drained, Painted}runtime enum — transitions flipped byset/fill/blit_packed_row/clear/clear_rows/resize. Documents the paint lifecycle in one named field; compile-time type-state would template every Canvas user, so the runtime form is the shippable cut. - B5:
intern_const(pool, style)— defaulted-lambda-tag template so each call site instantiates a unique specialisation with its own static thread_localcached_id, keyed onStylePool::pool_id()(process-wide monotone counter, bumped on construction and onclear()). Skips the hash + slot-probe ofpool.intern()on every paint after the first call. - Open ghost composer (b9f493f): fixed via
88516a6(cache_id derived from owner_less-keyed weak_ptr identity, closes the pointer-recycling cache aliasing class) and69688c5(Cmd::force_redraw()/Runtime::force_redraw()host-triggered coherence collapse, mirrorshandle_resize's Divergent transition for first-input-after-streaming).
Items deferred — open¶
A3 — typed widget strips¶
The biggest item left, and the only structural piece still on the table. Replaces the canvas-based painter with a strip-based composer.
What it changes. The renderer's intermediate representation today
is a 2D Canvas of packed cells. Widgets return Element trees, the
painter walks them and writes cells, the diff/serialize path reads
cells back to emit ANSI. A3 swaps the cell grid for Strip (one per
row, each a list of (text, style) runs already in wire-ready form).
The renderer never reads back — strips ARE what gets emitted.
The widget API (Element-returning functions) can stay; only the
internal pipeline changes. Cache layer goes from "cells per
(cache_id, width)" to "StripFrame per (cache_id, width)".
Wins. - Closes the cache-capture bug class entirely. No 2D buffer to read out of bounds, leak stale cells from, or alias via recycled pointers. (All three mitigated by other fixes — see "open ghost composer" above — but A3 closes the class structurally rather than case-by-case.) - Drops the "every cell starts blank each frame" load-bearing invariant. Strips are built fresh per frame; there's no shared surface to leak through. - Style integration with the diff falls out naturally — B7's differential SGR was bolted onto a cell-comparison diff; with strips, style is part of the run, transitions live where the data does. - Width-change invalidation is per-component instead of canvas-wide. - Per-row strip equality + concat is cache-friendlier than packed- uint64 cell compare on sparse-change frames. (Marginal; we already SIMD-bulk-eq rows.)
Costs.
- Overlay / focus-ring / popup painting becomes awkward — currently
trivial (write cells over existing ones).
- Wide-char and grapheme alignment moves from the canvas's explicit
width=1/2 markers into strip cursor-math.
- Every Element variant in the painter rewrites (TextElement,
ComponentElement, BoxElement, BorderElement, ImageElement,
ElementList, ElementListRef, FlexElement, ...). Every cache
codepath rewrites. The diff and serialize paths get strip-based
equivalents alongside the existing cell-based ones during the
migration.
- Both paths must coexist behind a feature flag during the cutover
— added complexity until the last codepath converts.
Scope estimate. 8-15 hours of focused work for the maya-internal parts plus a multi-day soak in moha to shake out edge cases (wide chars, layout-shift frames, scrollback boundary cases). Not a single- session task.
When to do it. Open question. The bug class A3 closes has been mitigated by other fixes; the wins are architectural insurance and clean-up against future complexity (overlays, scene composition). For maya's current target (chat-UI / agent-TUI use cases) the existing architecture works. Revisit when: - New requirements demand overlay / transparency / scene-level composition that the cell-grid model fights, OR - Profiling identifies the canvas-cell intermediate as a real cost, OR - A new cache-capture bug surfaces that the existing fixes don't cover.
When picked up, sequence:
- Define
Strip/StyledRun/StripFrametypes behindMAYA_STRIP_RENDERER=1so both paths can coexist. - Strip composer for
TextElementfirst (simplest leaf). - Composer for
ElementList/ElementListRef(vstack concat). - Composer for
BoxElement(flex children, h/v stack composition). - Composer for
ComponentElement(preserve current cache shape but with StripFrame storage). - Composer for remaining Element variants in dependency order.
- Strip-based
diff_strips+ inline-frame composer. - Wire into
Runtime::renderbehind the flag. - Soak on moha. Convert tests.
- Flip the default; remove the canvas path after a stability window.
B4 — auto-measure within-frame cache¶
The current component_cache already memoizes rendered child per
(cache_id, width) across measure→paint within a frame, so for
cache_id'd components there's no double-render. What's still
duplicated is the build_layout_tree + layout::compute walk
between measure (width=fixed, height=auto) and paint (width=fixed,
height=fixed) — different constraints, so the cached layout tree
isn't reusable directly. A speculative win would be caching only the
tree-build (not the compute), which is 8-100 nodes per component;
likely sub-microsecond. Needs profile data before chasing.
Order to pick up later¶
- A3 — structural cleanup; do it when the trigger conditions above hit. Not before.
- B4 — perf item, after profile data shows demand.