Seam inversion: fera's Engine consumes a typed tree, chroix's Backend consumes bytes — and text is shaped on opposite sides of the line
Источник:
dev/fera-research/docs/oc2/seam-inversion-engine-tree-vs-backend-bytes.md(read-only; сгенерировано scripts/sync-docs.mjs)
Seam inversion: fera’s Engine consumes a typed tree, chroix’s Backend consumes bytes — and text is shaped on opposite sides of the line
Section titled “Seam inversion: fera’s Engine consumes a typed tree, chroix’s Backend consumes bytes — and text is shaped on opposite sides of the line”- kind: problem
- area: ui
- severity: high
- evidence:
~/dev/fera/fera/core/crates/ui-contract/src/lib.rs:248-265—trait Engine { fn resize(&mut self, size: Size) -> …; fn paint(&mut self, tree: &RenderTree, size: Size) -> …; }~/dev/fera/fera/core/crates/ui-contract/src/lib.rs:143-168—enum NodeKind { PushClip, PopClip, Rect{fill}, Text(TextRun), Image{content_hash}, Spacer }~/dev/fera/fera/core/crates/ui-contract/src/lib.rs:170-185—struct TextRun { text: String, origin, font, style, color, line_height }(raw string)~/dev/fera/fera/core/crates/ui-engine-vello/src/lower.rs:70-84— engine walks&RenderTree, shapes nothing~/dev/fera/fera/core/crates/ui-engine-vello/src/text_engine.rs:94-103— shaping lives inside the engine (ParleyTextEngine::layout)~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md:288—fn present(&mut self, buf: &[u8], ctx: &mut PresentCtx) -> Result<(), Self::Error>;~/dev/fera/chroix-vello/src/cpu.rs:173-180—let dl = DisplayList::decode(buf)?;~/dev/fera/chroix-headless/src/lib.rs:283-284—DisplayList::decode(buf).map_err(HeadlessError::Decode)?~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md:117-120,149-164—DrawGlyphsreferences aglyph_runside table (font_key: u64interned, positioned glyph ids)~/dev/fera/chroix-project/UI-FRAMEWORK-IDEAS-V2.md:189-191— “The pipeline layer … using Parley, before the display list is built. The display list never shapes.”~/dev/fera/fera/core/crates/ui-contract/src/lib.rs:123-134—Interactionreturned inline (ADR-030 single-pass hit-test)~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md:122-126—HitRegion { node_id, rect, flags }op
Sum (Суть)
Section titled “Sum (Суть)”Mandate question: what blocks replacing fera’s vello engine with chroix. The blunt answer is that there is nothing to plug chroix into — the two projects disagree about what a rendering boundary is, in three independent ways. None of them is a porting chore.
1. The seam points in opposite directions
Section titled “1. The seam points in opposite directions”| input | who calls it | |
|---|---|---|
fera Engine::paint |
&RenderTree — a typed, owned, in-process tree (ui-contract/src/lib.rs:264) |
host holds the tree, hands it to the engine |
chroix Backend::present |
buf: &[u8] — opaque serialized bytes (DISPLAY-LIST.md:288) |
host serializes, backend decodes |
chroix’s backends genuinely decode: DisplayList::decode(buf)? at cpu.rs:177 and
headless/src/lib.rs:283. So chroix is not “a tree API”. To adopt it, fera would need a
RenderTree -> &[u8] encoder, and no such thing exists in any chroix repo — the contract’s
encode() only emits the op stream (all six side-table offsets are hardcoded 0,
encode.rs:43-48), and the pipeline layer that would build a display list from a tree is not
built yet. So this is not “swap the impl”; it is “write the missing middle of chroix first”.
2. Text is shaped on opposite sides of the boundary — the deepest mismatch
Section titled “2. Text is shaped on opposite sides of the boundary — the deepest mismatch”This is the one that will actually cost work, and it is easy to miss because both projects use Parley.
- fera: the tree carries the raw string.
NodeKind::Text(TextRun)whereTextRun.text: String(ui-contract/src/lib.rs:173). Shaping happens inside the engine (ParleyTextEngine::layout,text_engine.rs:94) — deliberately, to keep Parley types out of the HoF layer (ADR-028,text_engine.rs:1-5). - chroix: the display list carries shaped glyph runs.
DrawGlyphsreferences aglyph_runwith an internedfont_key: u64and positioned glyph ids (DISPLAY-LIST.md:117-120, 149-164). Shaping is the pipeline layer’s job, before the list exists: “The display list never shapes” (V2:190-191).
So chroix’s backend is stateless with respect to text layout, and fera’s engine is the text
engine. These are not two implementations of one interface — they place the Parley boundary on
opposite sides. Migrating means moving shaping out of the engine and into the pipeline, which
also means the TextRun shape in ui-contract changes shape (raw string -> glyph run).
3. Hit-testing models are incompatible
Section titled “3. Hit-testing models are incompatible”- fera (ADR-030): single-pass, hit-test decisions are made inline in the component pass
that builds the widget; the result is returned as
Interaction { Handled, Ignored }(ui-contract/src/lib.rs:123-134). There is no hit-test table because there is no second pass. - chroix:
HitRegion { node_id, rect, flags }is an explicit op in the stream (DISPLAY-LIST.md:122-126), resolved by mapping ops back toNodeIds via thenode_indexside table.
And note: node_index does not exist in the code — the NodeBoundary op is still opcode 11
inside the op stream (ops.rs:29), and grep node_index over chroix-contract/{src,tests,examples}
returns zero hits. So chroix’s own hit-testing story is unimplemented, and it presumes a
slotmap NodeId that only the unbuilt pipeline layer can produce. Meanwhile fera’s Node
has no id at all (ui-contract/src/lib.rs:189-194: just kind + bounds).
4. Vocabulary and memory model also differ
Section titled “4. Vocabulary and memory model also differ”NodeKind (6 variants) maps only partially onto the 13 opcodes: PushClip/PopClip/Rect/
Image have direct counterparts; Rect has a fixed Rgba fill, not a brush_ref index.
NodeKind has no transform, layer, stroke, path, node-id, or hit-region concept. And
RenderTree is Clone-able with an owned Vec<Node> and a clear() (lib.rs:202-240) —
append-and-clear per frame. The contract instead demands a rewrite-in-place arena
(DISPLAY-LIST.md:28-29, 49: “Zero per-frame allocation… rewritten in place frame-to-frame”),
and encode() allocates a fresh Vec<u8> per call with no reset/arena entry point.
Options (with tradeoffs)
Section titled “Options (with tradeoffs)”- A. Pick one seam and write the adapter, deliberately. Either (i) keep fera’s tree-shaped
Engineand have chroix grow a tree-consuming entry point, or (ii) adopt chroix’s byte seam and write aRenderTree -> bytesencoder plus move shaping into a pipeline layer. (ii) matches where the ecosystem is going (cross-DLL, host-leased arenas, frame record/replay) but is much bigger. (i) is cheap but forfeits the cross-DLL payoff and keeps two boundary concepts alive. - B. Defer the decision; keep both islands. Given that nothing in fera constructs a
RenderTreeand nothing consumesEngine(see the companion findingfera-ui-stack-unconsumed-island.md), there is no integration pressure today. Nothing is being blocked by not deciding. - C. Solve it upstream. Make the missing chroix pipeline layer the next deliverable; the encoder falls out of it and the seam question answers itself.
Proposed solution
Section titled “Proposed solution”B now, then C — and be explicit that this is sequencing, not deferral.
- Record the decision as a design gap, not an accident. The two seams are not
interchangeable and no doc in the corpus says so.
UI-FRAMEWORK-IDEAS-V2.md:114-118andCHROIX-VIEW-DESIGN.md:170-174both assume the “display list crosses as bytes” model as settled;fera’s ADR-009 Engine axis assumes an in-process typed tree. Both can be right, but the corpus never says which one wins when they meet. - Sequence: finish chroix’s pipeline layer, then adopt. The byte seam needs a producer. The chroix pipeline (element tree -> layout -> display list) does not exist; the contract encoder is a stub; the vello GPU path is a stub. Building it is prerequisite to any fera integration, and is useful to chroix standalone. So: build the pipeline layer, and fera integration becomes a thin adapter rather than a rewrite.
- Decide the text question explicitly, early. It is the one item that is genuinely expensive to reverse, because it moves Parley across the boundary. chroix’s model (shape in pipeline, glyph runs on the wire) is the one that makes the contract diffable — raw strings in the tree mean a text edit re-ships the whole string and every backend re-shapes. Recommend adopting chroix’s model, and treat “where does Parley live” as a named decision, not an accident of whichever code exists first.
The good news: because nothing in fera currently builds a RenderTree or calls Engine,
this decision can be made deliberately and cheaply today. Once a window layer and a real tree
producer land on fera’s side, this becomes a migration with real users to break.