# Primitive Authoring Rules
Read this when adding or changing a primitive.
1. **Declarative description first.** Add the struct + `Layout` +
`hit_test` to `quadraui/src/primitives/<name>.rs`. Tests for layout
correctness go at the bottom of that file as inline tests; they must
not pull in any backend feature.
2. **One source of truth for layout.** Both rasterisers AND tests must
call the primitive's `layout(...)` — never re-derive bounds inline.
Where a backend needs additional metrics (like TUI's `cell_quantum`),
they go on `LayoutMetrics` so the primitive can apply them.
3. **Both backends, same shape.** Add the rasteriser to
`quadraui/src/tui/<name>.rs` AND `quadraui/src/gtk/<name>.rs`. Even
if the GTK consumer hasn't been written yet, add a stub rasteriser
so the primitive's contract is honoured on every backend it claims
to support.
4. **Paint/click round-trip harness.** Every primitive that has clicks
must have a paint/click round-trip test in its TUI rasteriser
(`quadraui/src/tui/<name>.rs::tests`). The pattern: paint into a
`ratatui::Buffer`, find painted glyphs, hit_test those exact
coordinates, assert paint and click identify the same region.
Examples to copy: `tui::multi_section_view::tests`,
`tui::tree::tests`. The harness must catch the bug class it's
designed for — verify by temporarily mutating the rasteriser to
break the contract; the harness should fail. Restore + commit only
when both sides round-trip cleanly.
5. **Public layout helper.** Each backend exposes a public layout
helper (e.g. `tui_msv_layout`, `tui_tree_layout`) so consumers can
drive hit-testing without re-deriving metrics. The rasteriser uses
this same helper internally — paint and consumer-driven hit_test
consume one source of truth.
6. **Test state-derived paint geometry.** Whenever a painter computes
a position from primitive state — scrollbar thumb position from
`(scroll_offset, content_rows, viewport_rows)`, focus ring from
`has_focus`, badge offset from text length, animation phase from
a tick counter — write a test that paints at a known state and
asserts the painted indicator lands where the formula predicts.
The paint/click harness covers coordinate-system drift; this rule
covers paint-formula bugs (e.g. "thumb hardcoded at top of gutter
instead of computed from scroll_offset"). Verify empirically by
mutating the formula and observing the test fail. See *Coverage
taxonomy* under *Testing* for the full bug-class breakdown.
7. **Add the primitive to the `Backend` trait.** Every primitive MUST
have a `Backend::draw_<name>` (and where applicable, `Backend::<name>_layout`)
method. Per `docs/decisions/BACKEND_TRAIT_PROPOSAL.md` section 4, adding a primitive is
an intentional breaking change to the trait — every backend
implementer sees the new method as a compile error and fills in
their rasteriser. **No primitive ships with TUI/GTK free-function
rasterisers but no trait coverage.** That's how the
*Cross-backend portability commitment* stays load-bearing —
if a primitive isn't on the trait, downstream consumer code has to
pick a backend explicitly. **If the new primitive is composed into
top-level screens (most are), add its `Surface`/`FrameZone` variant
in the same PR** — see "One primitive, one canonical paint path"
below (issue #456).
**Which `<name>_layout` shape to pick (issue #506):**
| Primitive class | Convention | Examples |
|---|---|---|
| **Content-in-rect** — backend paints inline inside a caller-supplied `rect` and can compute the same geometry with no live paint context | **Paired**: `draw_<name>(rect, ..)` + `<name>_layout(&self, rect, ..)`, same signature shape, same resolver underneath. | `data_table_layout`, `tree_layout`, `form_layout`, `list_layout`, `board_layout`, `terminal_layout`, `editor_layout` |
| **Overlay-with-caller-anchor** — host computes anchor/viewport/measure itself and calls the *primitive's own* `.layout(...)` directly; the backend only paints at the resolved bounds | **Draw-takes-layout**: `draw_<name>(&self, thing, layout)`. No `Backend::<name>_layout` — there's nothing backend-specific left to compute. | `Tooltip`, `ContextMenu`, `Completions`, `RichTextPopup`, `Dialog`, `Float` |
| **Interactive chrome** — freestanding widget at its own screen rect | **Paired**, same as content-in-rect. (Draw-returns-layout-only, with no no-paint twin, is the same convention minus the accessor a host needs to hit-test without a frame in progress — add the twin rather than leaving a primitive as the odd one out.) | `chart_layout`, `toolbar_layout`, `sidebar_panel_layout`, `board_layout`, `diff_view_layout` |
A `<name>_layout` gets a **default trait body** only when the
geometry is a *provably uniform* pure function of
`Backend::char_width()` / `Backend::line_height()` (and primitive
state) — i.e. every backend's own `draw_<name>` already resolves the
same two values into the same formula (`terminal_layout`,
`editor_layout`, `diff_view_layout`). "The same two values" can
include a small backend-shaped accessor of the same shape as
`char_width()`/`line_height()` when the *formula* is still uniform
but a scalar it depends on genuinely isn't — `terminal_layout` also
calls `Backend::terminal_scrollbar_default_width()` to reserve the
scrollbar gutter's default width before dividing by `char_width()`,
because every backend's real `draw_terminal` reserves that gutter
with a backend-specific fallback (1 cell on TUI, 8px on GTK/macOS/Win)
before iterating cells — skip that reservation and the default body's
`grid_cols` silently over-reports by the gutter's width whenever a
caller omits an explicit scrollbar width (issue #506 review fix; see
`docs/decisions/DECISIONS.md` D-007's "`terminal_layout`'s scrollbar gap" note). If a backend supplies its own
sizing constants that aren't derivable from those two accessors
(`BoardMeasure`'s per-backend column/card pixel sizes, `ListView`'s
scrollbar reservation), there is no default — every backend
implements it explicitly, same as `draw_<name>` itself. A default
body still needs a from-scratch, per-backend-verified justification
in `tests/conformance/caps.rs`'s `ACCEPTED_DEFAULTS` (quadraui#492) —
it is exempt from that check's "silently defaulted" failure only
because the reason is written down, not because a default exists.
See `quadraui/docs/decisions/DECISIONS.md` D-007 for the full audit, the
off-trait-fn resolutions, and why `Palette` did **not** get a
`palette_layout` method despite fitting the content-in-rect row
above (a latent paint/layout drift, not a design gap — D-007's
"Palette: deferred, not missed").
8. **Public API changes are versioned by deprecation, not by
announcement.** See rule 8 in full below — it is the one rule whose
failure mode lands in *other repos*.
9. **A new `PlatformServices` capability states its TUI story and ships
a driver test proving it.** See rule 9 in full below — it is the rule
the Electron-parity push (`GOAL.md`) will lean on hardest, since that
is where most of the new capabilities land.
## Rule 8 — public-API lifecycle
`quadraui` is publishable (`publish = false` was dropped in `quadraui#797`,
the first tagged release is `v0.1.0`, not yet cut as of this writing — see
that issue). Nothing pins a published *version* today regardless — both
consumers pin a git *revision* instead, so neither floats on `develop`'s
tip. `coord-tui` — `JDonaghy/coord-tui`, a
standalone repo since `claude-coordinator#2899` (2026-08-29) — pinned
first, via `claude-coordinator#1973`. `vimcode` followed via
`vimcode#691`, replacing its old path dependency with a git+rev pin the
same shape as coord-tui's. A breaking change on `develop` reaches
either consumer only when someone bumps its pin, in a reviewable diff
that's the natural place to carry the migration — the urgency framing
of "this breaks two repos the moment it merges" no longer applies. The
deprecation-shim discipline below still matters, though: it's what
keeps that eventual pin bump cheap. And pinning trades floating for a
different hazard, **pin lag** — a pin can sit stale indefinitely with
nothing forcing it to move; vimcode's was 44 commits behind quadraui's
`develop` tip as of 2026-09-04 (`quadraui#782`). quadraui's CI does
carry a `downstream` compile-truth job (#528) that overrides both pins
to check every PR's quadraui against both consumers before merge, but
its coord-tui leg is currently skipped for lack of a read token on that
repo, so coord-tui breakage will *not* show up here. See the
*Downstream consumers* section of `CLAUDE.md` for the declarations, the
token, and the #476 post-mortem.
**What is breaking here vs. what isn't.** Consumers implement `ShellApp`
and `AppLogic` — never `Backend`. So:
| New `Backend` method, no default (rule 7's intentional compile error) | **No** — in-tree backends only. Keep doing this. |
| New required method on `ShellApp` / `AppLogic` | **Yes** — give it a default impl. |
| New field on a public struct a consumer constructs | **Yes** unless the struct is `#[non_exhaustive]` + built via `Default`/builder. |
| New variant on a public enum a consumer matches | **Yes** unless the enum is `#[non_exhaustive]`. |
| Rename / removal of any `pub` item with a consumer hit | **Yes.** |
**`Backend` is `pub` but sealed — this is enforced, not just written down
(quadraui#800).** Before this, `Backend` was a `pub` trait with 100+
required methods and this table's "in-tree backends only" claim was
prose an outsider had no way to verify short of trying it and getting
burned by the next release. `Backend: sealed::Sealed` in `src/backend.rs`
now makes that literally true: `sealed` is a `pub(crate)` module, so its
`Sealed` trait can't be named from outside this crate, and `Backend`
cannot be implemented downstream — see the "Sealed" section of
`Backend`'s own rustdoc (including a `compile_fail` doctest proving an
out-of-crate `impl Backend` is rejected) and `BACKEND.md`'s framing of
"contribute a fifth in-tree backend" as the supported path for a target
none of the four cover. Every one of the four in-tree backends (plus the
in-tree `RecordingBackend`/`MockBackend` test doubles) implements
`sealed::Sealed` alongside `Backend` — do the same for any new backend.
**Measure before you cut.** Both consumers sit beside this checkout:
```bash
grep -rn '<symbol>' ~/src/coord-tui/src ~/src/vimcode/src
```
Zero hits in both plus no in-tree use ⇒ remove it outright; that is what
a dead-API pass is for. **Paste the grep output into the PR body** — the
claim is cheap to assert and cheap to verify, so verify it. Note that
`vimcode` is easy to forget: #476's commit message named coord-tui as
"the only known" consumer and broke vimcode too.
**Deprecation shims, in ascending cost.** All of these keep a consumer
compiling, with a warning that names the fix:
```rust
// Rename a type — the whole cost of a non-breaking rename.
pub struct CardBadge { /* … */ }
#[deprecated(since = "0.0.2", note = "renamed to `CardBadge`")]
pub type Stage = CardBadge;
// Rename an enum variant — keep the old spelling as an associated const.
// (`allow` is required: the quality gate runs clippy with `-D warnings`,
// and a PascalCase const trips `non_upper_case_globals`.)
impl BadgeStatus {
#[allow(non_upper_case_globals)]
#[deprecated(since = "0.0.2", note = "renamed to `BadgeStatus::Warning`")]
pub const RequestChanges: BadgeStatus = BadgeStatus::Warning;
}
// Change a shape — forward from the old one.
#[deprecated(since = "0.0.2", note = "use `CardBadge { label, status }`")]
impl From<(Stage, BadgeStatus)> for CardBadge { /* … */ }
// Drop a field consumers still set — accept and ignore, don't delete.
#[deprecated(since = "0.0.2", note = "no backend ever painted this; drop the call")]
pub fn with_machine(self, _machine: impl Into<String>) -> Self { self }
```
**The two-PR protocol.** A breaking change is never one PR:
- **PR 1** — add the new shape, keep the old one compiling behind
`#[deprecated]`, update in-tree examples and tests to the new shape.
Open the migration issue in each affected consumer repo and link it
here. Consumers are now on a warning, not an error, and can migrate on
their own schedule.
- **PR 2** — delete the shims, once those migrations have merged.
Reference them by number.
**Both PRs get a `CHANGELOG.md` entry** (repo root — see that file's own
header for the format): PR 1 adds a line under `### Deprecated` naming the
old item, its replacement, and the PR-2 tracking issue; PR 2 moves that same
line to `### Removed`. This is the two-PR protocol made visible outside a
diff — a reader of `CHANGELOG.md` alone should be able to follow a
deprecation from introduction to removal without pulling up either PR.
**One breaking change per PR.** #476 removed a type, renamed a variant,
deleted two struct fields and gutted a keymap in a single commit, so the
consumer's migration was all-or-nothing with no partially-compiling
state to bisect from. Split them.
**Every PR touching a `pub` item carries a `## Downstream impact`
section** naming each consumer file that must move, or stating "no
consumer hits" with the grep. A public-API PR without one should be sent
back at review.
## Rule 9 — `PlatformServices` honesty (issue #949)
`Backend`'s defaulted (rule-7-adjacent) methods are policed mechanically
by `tests/conformance/caps.rs`'s `CAP_CONTRACTS` / `backends_declare_only_what_they_override`:
declare a `BackendCaps` field and the matching override must exist, in
both directions. `PlatformServices` had no equivalent — every one of its
methods was on the honour system — and that gap produced a real,
shipped bug: `TuiPlatformServices::open_url` was
`fn open_url(&self, _url: &str) {}`, returning `()` with **no**
`BackendCaps` entry, so an app calling it on TUI had no way to learn it
did nothing. `GtkPlatformServices::send_notification` is also `{}`, but
`BackendCaps::notifications` is honestly `false` there — same empty
body, but a caller can check first. The difference between those two is
the entire point of this rule.
**The mechanism.** `tests/conformance/caps.rs` now also carries
`PLATFORM_SERVICE_CONTRACTS` — one entry per `PlatformServices` method,
parsed from `src/backend.rs` the same source-parsing way
`defaulted_trait_methods()` reads `Backend`. Every method needs a
`ServiceHonesty`:
| `CapBacked(cap)` | The method's return type is already ambiguous (`Option<T>` — `None` on cancel vs. `None` on no-facility-at-all) and a `BackendCaps` field disambiguates it. | `show_message_dialog` / `native_dialogs` |
| `ResultTwin(name)` | The method returns a bare `()` — there is nothing on the method itself that could report absence. Add a `<name>_result` twin returning `ServiceResult<...>` (D-009 seam 2, the same shape as `Clipboard::write_text`/`write_text_result`) and keep the old `()` method for source compatibility. | `open_url` / `open_url_result` |
| `Delegates(trait)` | The method vends another trait object whose own methods carry their own honesty story. | `clipboard()` → `Clipboard` |
| `AlwaysReal` | The method can never silently do nothing — a plain identifier, or a method whose return type is already fully typed and unambiguous with no cap needed. | `platform_name`, a `_result` twin itself |
Two tests make this a gate, not a suggestion:
- `platform_service_contracts_cover_every_method` — a new
`PlatformServices` method with no `PLATFORM_SERVICE_CONTRACTS` entry
fails the build. No silent exemption by omission.
- `every_bare_unit_service_method_has_a_typed_honesty_story` — a method
that returns bare `()` **must** be `CapBacked` or `ResultTwin`.
Declaring `AlwaysReal` or `Delegates` for a `()`-returning method is
exactly the `open_url` bug this rule exists to catch, reintroduced.
**What this means when you add a capability.** Every new
`PlatformServices` method (or new capability *family* — see
`CLAUDE.md`'s Electron-parity scope) needs, in the same PR:
1. **A `PLATFORM_SERVICE_CONTRACTS` entry** stating its honesty story —
pick `ResultTwin` for a fire-and-forget action, `CapBacked` for
something whose success/failure is otherwise ambiguous, or (per the
issue's guidance for a whole new capability *family* — tray, window
control, etc.) prefer vending `Option<&mut dyn SubService>` so
absence is structural and there is no cap flag to keep in sync at
all.
2. **An explicit TUI story.** TUI has no browser, no OS notification
centre, no system tray — most new platform capabilities degrade to
"unsupported" there, and that degrade must be a documented, typed
answer (`Err(BackendError::Unsupported)` from a `_result` method, or
`None` gated by a `false` `BackendCaps` field), never a silent no-op.
3. **A `tui_*` driver test asserting the degrade** — build the example's
`AppLogic`, drive it through `TuiDriver`, and assert the typed
"unsupported" answer actually surfaces to the app. `examples/tui_file_dialog.rs`
+ its driver tests in `tests/tui_example_driver.rs`
(`file_dialog_demo_open_reports_unsupported_on_tui` and siblings) are
the template: the demo exercises the exact contract ("always reports
unsupported"), and the driver test proves it end to end rather than
trusting the doc comment.
## Coordinate frames for `*_layout` methods (issue #505)
Every `Backend::<name>_layout` method — and its `draw_<name>` twin, where
one returns hit-region data — returns its `hit_regions` / `bounds` in
one of exactly two frames, and its doc comment states which one:
- **LOCAL** — relative to the `rect` passed in; `(0, 0)` is `rect`'s
top-left corner. The caller subtracts `rect.x` / `rect.y` from an
absolute click coordinate before calling `hit_test`.
- **ABSOLUTE** — shifted by `rect.x` / `rect.y` (target-surface
coordinates). The caller compares raw click coordinates against the
returned bounds with no further adjustment.
**Which one a given primitive uses is a design choice made once, not a
per-backend one.** In practice: primitives a parent composer paints
*inline* and already tracks the origin for (`tree_layout`,
`form_layout`, `data_table_layout`, `text_display_layout`,
`status_bar_layout`, `activity_bar_layout`) are LOCAL. Primitives
painted as a freestanding widget at their own screen rect, where the
caller has no other reason to track that rect, are ABSOLUTE
(`tab_bar_layout`, `menu_bar_layout`, `split_layout`,
`split_tree_layout`, `panel_layout`, `toast_stack_layout`,
`pipeline_view_layout`, `progress_layout`, `spinner_layout`,
`command_center_layout`, `toolbar_layout`, `sidebar_panel_layout`,
`chart_layout`, `minimap_layout`, `msv_layout`, `text_input_layout`,
`command_line_layout`).
See `quadraui/docs/decisions/DECISIONS.md` D-005 for the full audit and why the
split isn't collapsed to one frame everywhere.
**The rule this enforces is not "always LOCAL" — `docs/LESSONS.md`'s
"Layout helpers must return coords in the same frame across backends"
predates this audit and reads that way; treat this section as its
successor.** The rule is: (1) the method's doc comment states its frame
explicitly — **LOCAL** or **ABSOLUTE**, in those words, so grep finds
every one — and (2) every backend implementation actually agrees with
that statement and with its TUI/GTK/macOS siblings. A `*_layout` doc
comment with neither word is an #505 regression.
**Every `*_layout` method needs a non-zero-origin regression test** on
every backend it ships on (TUI + GTK always; macOS per #483
availability) — `rect.x != 0` or `rect.y != 0`. `area = (0, 0)` is
exactly the case where a LOCAL/ABSOLUTE mixup is invisible (adding or
skipping `rect.x` is a no-op when `rect.x == 0`), which is why the
historical `mac_tree_layout` bug (`docs/LESSONS.md`) shipped past
tests that all used the origin.
### Converging on one convention, ABSOLUTE-only (issue #816)
The LOCAL/ABSOLUTE split above is the audited *current state*, not the
target. quadraui#816 is converging every primitive onto: one `layout()`
argument shape (`fn layout(&self, bounds: Rect, m: &Self::Measure) ->
Self::Layout`, replacing the four in use today), one `hit_test()` shape
(`fn hit_test(&self, p: Point) -> Self::Hit`, with a `Miss`/empty
variant on every `Hit` — seven primitives have none today), and
**ABSOLUTE coordinates everywhere** — once every primitive agrees, this
table has nothing left to record and gets deleted.
`quadraui/src/layout.rs` (new) is the shared infrastructure that
per-primitive convergence PRs adopt one at a time:
- `Anchor` + `Side`/`ResolvedSide` — the "preferred side, flip on
overflow, pin to viewport edge as a last resort" resolution that
`Tooltip`, `Completions`, `ContextMenu`, and `RichTextPopup` each
reimplement today behind their own placement enum.
- `visible_range_walk` + `VisibleItem` — the "visible items from a
scroll offset, clipping the last one to the remaining viewport"
walk that all 19 `Visible*{idx, bounds}` structs (`VisibleListItem`,
`VisibleTreeRow`, `VisibleTab`, ...) hand-roll today.
Landing `layout.rs` does not change any primitive's public API by
itself — nothing in `primitives/` consumes it yet. Converting an
existing primitive onto it (new `Visible*` alias, new placement type)
is a rule-8 breaking change and lands as **its own PR per primitive**,
with a `#[deprecated]` shim per the two-PR protocol above, so no
consumer breaks in one commit — see #816 for the full split and its
coordination with quadraui#785's Phase 2. Do not delete the LOCAL/
ABSOLUTE table until every primitive listed in it has converged; a
partially-converged table with rows removed early is worse than the
current complete one.
(Issue #1098 asked quadraui to finish this entire table's conversion in
the same pass as an unrelated `Rect`-unit-contract fix. `docs/decisions/DECISIONS.md`
D-016 declines that batching for the same one-primitive-per-PR reason
stated above; the table above is unchanged by that decision and remains
the tracker for each remaining conversion.)
## One primitive, one canonical paint path (issue #456)
`quadraui` has two ways to paint a primitive: `backend.draw_<name>(rect,
&data)` directly, or `layout.push(Surface::<Name> { rect, .. })` +
`layout.draw(backend)` via `quadraui::frame::ScreenLayout`. Nothing
stops two backends of the same consumer app from picking different
ones for the identical call site — and when they do, the two paint
paths silently drift, because the compiler can't see across the split.
That's exactly what happened in vimcode: the TUI palette painted via
`b.draw_palette(...)`, the GTK palette via `Surface::Palette`, with
every other line at both call sites identical (#456; contributed to
vimcode#587).
**`ScreenLayout` + `Surface` is the canonical path for a consumer
assembling a top-level screen from multiple primitives** — it forces
both backends through the same call site and its `zone_for` helper
keeps the hit-map in lock-step with what was painted by construction
(see `quadraui/src/frame.rs`'s module doc). `Backend::draw_<name>`
stays public, non-deprecated, low-level API — `ScreenLayout::draw`
calls it internally. As of #1099 every primitive with a `draw_*`
method has a matching `Surface` variant, so this is no longer the
*only* path for any primitive; `draw_*` stays directly reachable by
design, for rasteriser tests and compose helpers that paint a single
primitive outside a full-screen `ScreenLayout`. See
`docs/decisions/DECISIONS.md` D-006 for the full audit.
**Going forward:** when rule 7 above has you adding a `Backend::draw_<name>`
method for a primitive that's composed into top-level screens (i.e. a
consumer will paint it as one layer of a multi-primitive frame, not
just as the sole content of its own pane), add its `Surface::<Name>` /
`FrameZone::<Name>` variant and `ScreenLayout::zone_for` arm in the
**same PR**. Skipping this is how the trait and `Surface` started drifting apart in
the first place — 11 primitives had a trait method but no `Surface`
variant as of the #456 audit (Board, DiffView, PipelineView, Toolbar,
SidebarPanel, TextInput, Spinner, Progress, CommandCenter, DropOverlay,
MessageList), grown to 14 by the #1099 audit (the same 11, plus Image,
Minimap, SplitTree). **Issue #1099 closed that backlog**: every one of
those 14 now has a `Surface`/`FrameZone` variant — see `frame.rs`'s
module doc, "Closing the `Surface` coverage gap." This rule's job now
really is just to stop the gap from reopening, not to describe a
standing backlog.
**When `Backend::draw_*` direct calls are still the right choice**, even
for a primitive that does have a `Surface` variant: a rasteriser's own
paint/click round-trip test (rule 4), a compose helper painting a
primitive it fully owns, or any call site that has no need for the
frame-level hit-map `ScreenLayout` produces. The rule is "assembling a
multi-primitive screen goes through `Surface`," not "never call
`Backend::draw_*` directly."
## Shared pixel-layout math (#499)
`gtk::tree::gtk_tree_layout` and `macos::tree::mac_tree_layout` used to
be byte-identical apart from the function name and one comment — same
magic constants (`line_height * 1.2` header pitch, `* 1.4` item pitch,
`* 0.9` indent, `* 0.65` chevron glyph estimate), same body. The
`MultiSectionView` body-measure match arms were the same story, and had
already **drifted**: GTK measured real `MessageList` content height,
macOS's copy returned `0.0` — a live macOS layout bug born of
copy-paste, not a deliberate backend difference.
**Rule: pixel-backend layout math that doesn't touch a native drawing
handle belongs in `quadraui/src/primitives/layout_metrics.rs`, not in
`gtk::<name>` / `macos::<name>` / `win::<name>`.** A function belongs
there if it can be written as `(primitive, rect, line_height, ..) ->
*Layout` / `*Measure` with no `cairo::Context`, `CGContextRef`,
`pango::Layout`, `CTFont`, or `ID2D1RenderTarget` in its signature.
Each backend's `<name>.rs` then keeps only:
- a thin wrapper (`gtk_tree_layout`, `mac_tree_layout`, …) that calls
the shared fn — kept per-backend only so each rasteriser's public
layout-helper name matches rule 5 above, not because the body
differs;
- the native paint code, which consumes the same `*Layout` /
`*Measure` the shared fn produced.
**When a shared fn needs a real glyph width** (Form's per-item hit
regions can't use a fixed-ratio estimate the way tree/MSV do), it takes
`&dyn primitives::layout_metrics::TextMeasure` instead of a native font
handle. Each backend supplies a private one-method adapter over its own
live font/context — see `macos::form::CtFontMeasure` wrapping `CTFont` +
`measure_text`. The shared fn never learns a native type exists.
**Migrating a primitive's layout math is not the same PR as fixing a
drift it exposes**, unless the drift *is* the primitive's whole reason
for migrating (as MSV's `MessageList` fix was for #499) — see rule 8's
"one breaking change per PR" discipline; the same "don't batch" logic
applies to behavior changes uncovered mid-refactor. If sharing a
function would change one backend's *current* output (see
`layout_metrics::form_field_measure`'s doc on `FieldKind::TextArea` for
a case where sharing was deliberately **not** done for this reason),
leave that backend's existing behavior in place and note the gap in the
shared fn's doc comment rather than silently changing it.
**A file-overlap conflict on a fenced backend file (e.g. concurrent
work on `gtk/backend.rs`) blocks that backend's migration, not the
others.** Migrate every backend whose files are free; leave the
blocked one's duplication in place with a comment pointing at the
tracking issue, and land what's clean rather than waiting on the whole
set. **Before using this escape hatch, read "A fence is a debt, and it
needs an owner" below — it has already produced one shipped bug
(#710).**
## Primitive-first: a new primitive lands its layout shared, before any backend paints it (#481)
The "Shared pixel-layout math" rule above tells you how to *migrate*
duplication that already exists. This rule exists because migrating is
not enough: **the lift rate has been lower than the copy rate.**
### The measurement that produced this rule
A function-level re-audit on 2026-09-03 (`develop` @ `7f00f73`) compared
`gtk/` and `macos/` against the same measurement taken in July
(`73172ff`) — contiguous byte-identical production runs of eight
normalised lines or more:
| contiguous identical lines, gtk↔macos | 626 | **757** (+21%) |
| whole functions identical | 113 | **219** |
#498 and #499 **worked where they were applied** — `services.rs` 15→0,
`backend.rs` 37→0, `tree.rs` 30→19, `multi_section_view.rs` 49→24, and
every exhibit the July audit named is gone. That is roughly −90 lines.
In the same window roughly **+220 lines of new duplication arrived**:
`testing.rs` 0→87, `board.rs` 0→44, `diff_view.rs` 0→34,
`activity_bar.rs` 0→24. macOS had no `board.rs`, `diff_view.rs` or
`testing.rs` in July — each arrived as a copy of GTK's. The `Toolbar`
button-measure formula now exists in **five** places and they agree by
luck, not by construction.
So the per-primitive lifts were correct and effective, and the number
still went up, because nothing stopped the next primitive arriving as N
copies.
**Rule: a new primitive's layout, measurement and state-resolution land
in `quadraui/src/primitives/` in the same PR that introduces it — before
any backend paints it. A backend file may only add an executor.**
An "executor" is code that takes a `*Layout` / `*Measure` the shared
code produced and issues native drawing calls. If a backend file
computes *what* to paint rather than *how* — which theme colour a
hover/pressed/focused state resolves to, which glyph a status or badge
maps to, where a row starts, whether a scrollbar is needed — that
belongs in `primitives/`.
Concretely, these all belong shared and none of them may be written a
second time in a second backend:
- `status → glyph` and `status → colour` tables (`pipeline_view`'s
status icons, `board`'s badges, `toast`'s severity background)
- geometry constants (`*_BOARD_*_PX`, `TOAST_*`, and the ~40 others
currently duplicated with identical values across backends)
- scroll-offset resolution, including keeping a selection visible
- `needs_hscrollbar`-style reservations that hit-test and paint must
agree on
### Porting a primitive to a new backend
When you add backend N+1's copy of an existing primitive, you are not
"porting" it — you are **splitting it**. Extract the shared half into
`primitives/` first, reduce the existing backends to executors in that
same PR, and then write the new backend's executor. Copying
`gtk/<name>.rs` to `<newbackend>/<name>.rs` and editing the draw calls
is how `board.rs`, `diff_view.rs` and `activity_bar.rs` became twins,
and it is what a Windows or web backend will do to every remaining
primitive unless this is enforced at review.
### A fence is a debt, and it needs an owner
The "Shared pixel-layout math" rule permits leaving a blocked backend's
duplication in place behind a comment pointing at a tracking issue.
**That escape hatch has already failed once.** `GtkBackend::form_layout`
carried a comment reading *"fenced by concurrent #683 work … migrate
this one once #683 lands. Tracking: #499."* #683 landed; nobody came
back; GTK kept measuring `TextArea` as `row_h * visible_rows` while its
own painter drew one row, so every row below a `TextArea` hit-tested to
the `TextArea` (#710).
If you fence a file:
- name the issue that **unblocks** it, not just the issue that tracks
the migration — a reader needs to know what to watch for;
- open a follow-up issue *at fence time* to remove the fence, and link
it in the comment. A comment is not a work item;
- state what the duplication currently does differently, so the next
reader can tell a deliberate difference from drift.
A fence whose blocker has closed is a bug waiting to be found, not
technical debt being managed.
## Native-vs-painted menu convention
`ContextMenu` has two display paths and apps pick by trigger, not by
backend. The split is deliberate — it's what makes "feels native on
Mac while staying portable" the default outcome.
| `MouseDown { button: Right }` on a canvas / row / cell | `backend.show_context_menu(&menu, position)` | User-triggered system menu — gets the platform's fonts, accent colour, Dark Mode, VoiceOver, ⌘-shortcuts. macOS uses `NSMenu.popUpMenuPositioningItem` natively; TUI/GTK fall through to the painted overlay today (stash-and-paint default lands when a consumer asks). Activation arrives as `UiEvent::ContextMenuItemActivated(WidgetId)`; dismissal as `UiEvent::ContextMenuDismissed`. |
| Left-click on a UI affordance that opens a menu-style dropdown (palette sub-menu, in-window action list) | `Backend::draw_context_menu` in the next render pass | App-controlled rendering, pixel-consistent across backends, positioned relative to a widget rather than the cursor. Use the `MenuSystem` compose helper. |
## Primitive maturity levels
A primitive file in `primitives/` is not "shipped" until it has all
three legs: (1) descriptor + layout + hit_test + layout tests, (2) TUI
and GTK rasterisers with paint/click harnesses, (3) `Backend` trait
methods. Primitives with only leg (1) are **descriptors** — their shape
is real and tested, but consumers cannot adopt them yet because there's
nothing to paint. As of 2026-05-03, all previously descriptor-only
primitives have shipped with full rasteriser + Backend trait coverage.
Don't delete descriptors — the layout + hit_test work is real and
reusable. Do prioritise adding rasterisers for any descriptor that
blocks a consumer's bespoke-paint elimination.