Skip to content

Converge frame composition — ~4,500 lines laying down the same surfaces in two hand-kept orders #735

Description

@JDonaghy

Summary

Frame composition — the order and geometry in which a frame's surfaces are laid down —
is written twice, ~4,500 production lines:

Backend Composer Lines
GTK render_content (src/gtk/mod.rs:8177) 1,533
TUI render_content (src/tui_main/shell_app.rs:1295) 708
TUI draw_frame (src/tui_main/render_impl.rs:285) 749
TUI panels.rs render helpers (render_sidebar, render_source_control, render_ext_panel, render_ai_sidebar, …) 1,555

This is the last of the four big pockets and the hardest, because unlike mouse (#733)
and keys (#734) the two sides are not pure logic — they end in genuinely
different rasterisers.

What is already shared, and what is not

Shared and healthy — the content. render.rs exports ~30 builders both composers
call: build_screen_layout, build_tab_bar_primitive, build_global_status_bar,
build_window_status_line, build_activity_bar, build_minimap_data,
build_terminal_draw_data, build_bottom_panel_tab_bar, build_command_center_view,
build_toast_stack, quickfix_to_list_view, picker_panel_to_palette,
settings_to_form, source_control_to_tree_view, ext_panel_to_tree_view,
debug_output_to_text_display, the populate_*_system family, and more. Every surface
in PROJECT_STATE.md's cross-backend coverage table is ✅ on both backends.

Duplicated — the composition. Which surface is drawn, in what order, into which
rect, under what condition. Both composers walk the same ladder:

menu row → activity bar → sidebar (+ separator) → editor windows → group dividers
→ tab drag overlay → tab hover tooltip → editor popups (completion / hover /
editor-hover / diff-peek / signature-help) → quickfix → separated status line
→ bottom panel (tab bar + terminal | debug output) → debug toolbar → wildmenu
→ status / command line → panel hover popup → folder picker → find/replace
→ unified picker → dialog → context menu → toasts

TUI's is at render_impl.rs:285-1034 with a banner comment per rung; GTK's is the
equivalent run inside mod.rs:8177-9710. Z-order between them has already been
inverted once
#592's own body documents "modal z-order is exactly inverted between
the backends" for the four modals both painted. That is the failure mode: two hand-kept
orderings with nothing asserting they agree.

Why this is the hard one — read before scoping

Do not assume the #733 route_* shape transfers. It does not, on its own:

  1. Units differ. GTK composes in pixels, TUI in cells. render::compute_editor_layout
    already handles this by taking line_height (GTK passes real px, TUI passes 1.0) —
    the shared composer must do the same throughout, not just for chrome heights.
  2. TUI has a raw-Buffer residue. #595 Stage 2 — TuiShellApp::render_content paints for real through &mut dyn Backend #601's scoping note (now in PLAN.md's completed-wave
    section) established that render_content(&self, backend: &mut dyn Backend, ...) can
    never obtain a raw ratatui::Frame. draw_frame can — it is the older path and
    still takes frame. Some of what it paints was never portable to the trait. Enumerate
    that set first; it determines whether this is one issue or three.
  3. Painter model differs. Cairo painter-order vs ratatui cell coalescence is an
    intrinsic divergence, called out in PROJECT_STATE.md. A shared composer must emit an
    order, and let each backend's rasteriser resolve overlap its own way — it must not
    try to unify overdraw semantics.

Proposed shape

A shared render::compose_frame(&ScreenLayout, &AppShellLayout, Metrics) -> Vec<FrameOp>
— an ordered list of "draw surface S into rect R" instructions in unit-agnostic terms.
Each backend walks the list and dispatches each FrameOp to its Backend::draw_*
equivalent. Composition order and geometry become one artefact; rasterisation stays
per-backend, which is where the genuine difference lives.

The z-order test then becomes possible for the first time: assert the two backends
produce the same FrameOp sequence for the same ScreenLayout, which is a cheap
unit test and would have caught #592's inversion at authoring time.

Check quadraui first. If quadraui's AppShell/ShellApp seam can own the composition
list, that is where it belongs and this becomes an adoption issue (CLAUDE.md).

Also in scope: the fields GTK never reads

grep -rn across src/gtk/ finds zero readers for these ScreenLayout fields:
diff_toolbar, tab_scroll_offset, group_dividers, tab_bar_primitive,
menu_dropdown_open. Some are legitimately GTK-computed elsewhere (group_dividers is
discarded into _group_dividers at mod.rs:4743 and :5172); others may be #592-class
gaps that the epic's 14-field table did not cover. A shared composer makes "populated but
never composed" a structurally impossible state, so resolve each one on the way through
and record the verdict per field. (ai_panel is already tracked as #730 — do not
duplicate it here.)

Staging

Genuinely multi-session. Suggested cut:

  1. Enumerate the raw-Buffer residue in draw_frame and file it separately if it is
    more than a rung or two. Everything else waits on this answer.
  2. Chrome band — menu row, activity bar, sidebar, status/command line, wildmenu
  3. Editor band — windows, group dividers, minimap, breadcrumbs, tab bars
  4. Bottom band — quickfix, separated status line, bottom panel, debug toolbar
  5. Overlays and modals — the z-order-sensitive set; land the FrameOp-sequence equality
    test with this slice
  6. Delete draw_frame and collapse GTK's render_content to a FrameOp walk

Acceptance criteria

Per slice, plus:

  • A test asserting both backends emit the same FrameOp sequence for a given
    ScreenLayout, verified RED by reordering one rung on one backend.
  • Black-box coverage on both backends per CLAUDE.md, RED-verified against unfixed
    develop.
  • Both bespoke composition arms deleted in the same PR as the shared one lands.
  • Every field in the "never read" list above has a recorded verdict: composed, or
    deliberately GTK-computed elsewhere with the reason.
  • cargo build && cargo test && cargo clippy -- -D warnings && cargo fmt --check EXIT=0.

Sequencing

Last of the four pockets. After #731, #732, #733 and #734 — those
delete ~5,000 lines that this issue would otherwise have to compose around, and #733
establishes the shared-router pattern this follows.

Files

  • src/render.rs
  • src/gtk/mod.rs
  • src/tui_main/shell_app.rs, src/tui_main/render_impl.rs, src/tui_main/panels.rs
  • src/gtk/testing.rs

Milestone

#7 Platform-Neutral


⚠️ Line numbers in this body drift — locate by symbol

Every line number above was measured on develop @ 4872ab9 (2026-09-01). They are
already wrong.
#727 landed src/gtk/mod.rs +435/−44 within hours and moved
enum Msg 1096→1131, fn dispatch 1799→1896, sync_scrollbar 2657→2754. Seven more
issues sit ahead of this one in the chain, each deleting or moving thousands of lines.

Treat the numbers as provenance — proof the claim was verified once — never as
coordinates. Locate by these anchors instead:

grep -nE "fn render_content" src/gtk/mod.rs src/tui_main/shell_app.rs
grep -nE "fn (draw_frame|paint_editor_popups|build_screen_for_tui)" src/tui_main/render_impl.rs
grep -nE "^(pub )?fn render_" src/tui_main/panels.rs
grep -rn "\.diff_toolbar\|\.tab_scroll_offset\|\.group_dividers\|\.tab_bar_primitive\|\.menu_dropdown_open" src/gtk/   # the never-read audit

This is the same defect #734 exists to fix: src/tui_main/ carries 19
mirrors mod.rs:NNNN comments whose targets all drifted after #540, leaving the only
record of a cross-backend contract pointing at unrelated code. Do not re-point the
numbers when they rot — the anchors are the durable form.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    coordTracked by coord-tui pipelinestatus:readyRefined and ready to enter the work pipelinetier:largeMulti-module / algorithm-heavy work. Routes to opus via models.labels.uiUI/rendering

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions