Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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 — DrawGlyphs references a glyph_run side table (font_key: u64 interned, 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 — Interaction returned inline (ADR-030 single-pass hit-test)
    • ~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md:122-126 — HitRegion { node_id, rect, flags } op

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.

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) where TextRun.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. DrawGlyphs references a glyph_run with an interned font_key: u64 and 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).

  • 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 to NodeIds via the node_index side 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.

  • A. Pick one seam and write the adapter, deliberately. Either (i) keep fera’s tree-shaped Engine and have chroix grow a tree-consuming entry point, or (ii) adopt chroix’s byte seam and write a RenderTree -> bytes encoder 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 RenderTree and nothing consumes Engine (see the companion finding fera-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.

B now, then C — and be explicit that this is sequencing, not deferral.

  1. 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-118 and CHROIX-VIEW-DESIGN.md:170-174 both 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.
  2. 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.
  3. 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.