Decoration retention across focus
Design fragment — the what and why. Sequencing lives in the slice plan: ../operations/slice-plans/archive/decoration-retention.md.
Intent
A buffer's decorations — syntax highlighting, inlay hints, diagnostic underlines, document-highlights, diff signs — are buffer-intrinsic retained state. They are computed when a buffer first becomes visible and invalidated only when that buffer is edited or receives an LSP update. Shifting focus between panes/tabs must never recompute, drop, or fail to paint them. Active vs. inactive is a paint-time opacity, nothing more.
The only state that is genuinely active-only is interaction state: the cursor, the visual selection, the current search match, the cursor-line background. Everything else a pane shows is a property of its buffer and survives focus changes untouched.
Problem (resolved 2026-06-05, DR.1–DR.3)
The original state had active and inactive panes running different decoration machinery, with the inactive machinery producing a lesser set and throwing work away. All five issues below are closed:
- Two syntax producers. The active pane reads the live
visible_highlightscell (continuously maintained) plus thecell_matrix/display_matrix— but the cells worker publishes only for the active document (window.rs:cell_matrix: if render_active {…} else { None }). Inactive panes get nothing from cells and fall back toeditor.pane_highlights[idx]. - Teardown on every refresh.
Editor::refresh_pane_highlights(pane_highlights.rs) doesself.pane_highlights.clear()and rebuilds, re-slicinghighlight_lines(scroll, end)for every inactive Document pane (and re-requesting a reparse when the buffer's text version moved). Already-valid highlight work is discarded wholesale. - Inlays vanish when inactive. Active inlays are published active-buffer-only into
SyntaxRenderState.inlay_hints(build_active_inlay_hints) and woven into the active compose path. The inactive render path (draw_inactive_document, TUI) renderspane_highlightsspans with no inlay weaving — so a buffer that showed inlays loses them the instant it goes inactive. - No republish on focus-gain. When a buffer becomes active again, its active-only decoration publish (cells, woven inlays) lags one dispatch, so the focus-change frame paints the lesser inactive set and the decorations only reappear on the next keystroke (user-observed in the TUI).
- A render-path fork.
draw_inactive_documentis a parallel render function tocompose_visible_linesthat produces a different, smaller decoration set — an everything-is-a-buffer special-case keyed on focus.
Net: an inactive buffer's content has not changed, so its syntax, inlays, and diagnostics are still correct — yet they are torn down and lazily recomputed, with a window of missing decorations on every focus change. That is wasted work and a permanent "what's missing on re-focus" bug source.
Contract
- Per-buffer retained decorations. Syntax (spans/cells), inlay hints, diagnostic underlines, document-highlights, and diff signs live in per-buffer caches keyed by
BufferId(thePerBufferCachepattern inlays/diagnostics already use). A visible buffer's decorations are computed once and held. - Invalidate on the owning buffer's change only. A buffer's decoration cache is invalidated when that buffer is edited or its LSP data updates — never on focus change, never on another buffer's edit.
- One render path. Every pane renders through the same path with the full decoration set. Inactive panes differ only by an opacity (
ui.inactive_pane_opacity, gated byui.dim_inactive) and by the absence of interaction state (cursor/selection/match/cursor-line). - Focus change is free. Activating a pane flips opacity and schedules a repaint; it performs zero decoration recompute and the focus-gain frame already carries the full decoration set.
- Renderer parity. TUI and GPUI satisfy this identically, in lockstep (
feedback_tui_gpui_parity).
Data model
Decoration state is addressed by BufferId, not by "active" / pane index:
- Syntax: the parsed tree already lives per-buffer in
editor.syntax/document_syntax_for(id). The viewport-window slice (spans, or the cell/display matrix) becomes a per-buffer retained product, refreshed only when that buffer's syntax version advances — replacing the cleared-each-framepane_highlightsmap and the active-only cells publish. - Inlay hints:
render_state.lsp.inlay_hints(PerBufferCache<LspInlayHintCache>) is already per-buffer; both render paths source from it viaget_for(buffer_id). The active-onlySyntaxRenderState.inlay_hintsrepublish becomes a convenience, not the source of truth. - Diagnostics / document-highlights / diff signs: already per-buffer / per-URI; the inactive render path must read and paint them, not just reserve their gutter columns.
Render contract
A pane's element/compose input is (buffer decorations, interaction state | None, opacity). Interaction state is Some only for the focused pane. Opacity is 1.0 for the focused pane and ui.inactive_pane_opacity otherwise. There is no second "inactive" producer and no focus-keyed branch in decoration sourcing.
Paramount-goal alignment
UX (higher court): removes the missing-decoration frame and the keystroke-to-recover on every focus change — directly upholds
feedback_decorations_update_in_place("decorations update in place, never flicker; unchanged lines never lose cues"). Paramount goals: protects #1 (no recompute of already-valid decorations; inactive buffers don't change) and #3 (everything-is-a- buffer: decoration data is buffer-intrinsic, so the producer must not branch on focus). Sacrifices nothing real — retaining an unchanging buffer's decorations costs bounded memory, not CPU. Heuristic #1 (long-term fit, on merit): the two-producer split anddraw_inactive_documentfork are kept because they work, not because they are better. One retained per-buffer store read by one render path is the genuinely-better design. Heuristic #2 (paramount, not other editors): anchored on #1/#3, not on "editor X dims this way." Standing rule (feedback_no_ui_thread_work): the fix removes the teardown (architectural relocation) as the first slice, rather than adding a repaint trigger on top of it (symptom patch). Standing rule (feedback_buffers_no_special_case): collapsingdraw_inactive_documentinto the shared path removes a focus-keyed special case.
Rejected alternatives
- (A) Symptom repaint-trigger. Keep the two producers + the clear-and-rebuild, but schedule a repaint + pane-highlight refresh on focus-gain so the keystroke is no longer needed. Removes the visible symptom while preserving the teardown and the duplication — exactly the patch heuristic #1 /
feedback_no_ui_thread_workforbid as a first slice. Rejected. - (B) Retain but keep two producers. Stop clearing
pane_highlightsand source inlays on the inactive path, but leave active-on-cells / inactive-on-spans as separate machinery. Kills the bug but preserves the duplication (the deeper bug surface). Adopted partially as the first slice (DR.1) because it removes the teardown and lands the UX fix green fast; the producer unification (DR.2) and render-path collapse (DR.3) follow.
Slice plan
See ../operations/slice-plans/archive/decoration-retention.md for sequencing (DR.1 retain + repaint → DR.2 per-buffer producer → DR.3 one render path → DR.4 four-artefact close).