Copied to clipboard
Swap · LayoutEngine

Bring your own layout solver.

The engine turns an element tree into positioned screen boxes through a single trait. bite-gp-morphorm is a second implementation of it: add the crate, hand its factory to the application at startup, and the div() tree you already have lays out the same. Taffy is still there, untouched — none of this is one-way.

1.21.2 on crates.io
0.00 px Max bounds delta Measured across 3501 nodes against taffy.
3.4–3.8× Faster per frame Across dynamic trees of 50 to 1000 changing buttons.
12% Of a 16.7 ms frame At 1000 buttons, against taffy's 44%.
The seam

One trait to implement.

LayoutEngine lives in gpui_engine and has six methods. That crate has "no dependency on taffy": it knows a node only as an opaque LayoutId. The facade gives you one way to replace what implements it — Application::with_layout_engine.

The interface contract gpui_engine::LayoutEngine
Host binary Application::run() Declares the element trees. Names no solver, and cannot: the engine is chosen before the first window exists.
with_layout_engine(…)
one factory per window
bite-gp-morphorm this page · 3.4–3.8× cheaper per frame Active
bite-gp-engine-default the facade's own engine, backed by taffy Default
# Cargo.toml
[dependencies]
bite-gpui = "1.21"
bite-gp-morphorm = "1.21"
use gpui::*;
use gpui_morphorm::MorphormLayoutEngine;

fn main() {
    application()
        // called once per window, before any of them exist
        .with_layout_engine(|| Box::new(MorphormLayoutEngine::new()))
        .run(|cx: &mut App| {
            cx.open_window(WindowOptions::default(), |_, cx| {
                cx.new(|_| MyView)
            })
            .unwrap();
        });
}
No rename needed

Cargo knows a dependency by its library name, and the published package keeps a library target of its own. So bite-gp-morphorm in your manifest is use gpui_morphorm::… in your code — no package = key, no alias to remember.

Conformance

Is it the same layout?

A faster engine is no use if your buttons land four pixels to the left. So this is the first question the crate answers, and it answers it by measurement: every node of every tree below, against the same tree laid out by taffy. Nothing here is within a tolerance — the budget in the test is half a pixel, so a future change has to cross something you could see before it fails.

Node bounds, both engines budget 0.50 px · every node compared
tree scale nodes worst delta
12-node tree 1.0 12 0.00 px · 0 differ
12-node tree 1.5 12 0.00 px · 0 differ
12-node tree 2.0 12 0.00 px · 0 differ
50 buttons, frame 0 1.0 176 0.00 px · 0 differ
50 buttons, frame 7 1.0 176 0.00 px · 0 differ
200 buttons, frame 3 1.0 701 0.00 px · 0 differ
1000 buttons, frame 3 1.0 3501 0.00 px · 0 differ

Fractional scales are in here on purpose, next to the integral ones: that is where two engines that round a length differently start to disagree, and device-pixel snapping is what turns that into a seam you can see.

The trees are files in the crate — src/sample.rs and src/buttons.rs — and the comparison, the benchmark and the demo all drive those same builders, so the claim and the timing cannot drift apart. They cover flex rows and columns, padding, gaps, explicit and percentage sizes, cross-axis stretch, one measured leaf and absolute positioning, and they leave out what the conversion cannot express on purpose: a difference there would be a limitation, not a defect.

The window

Passing every comparison, and painting nothing at all.

The first version of this engine passed every comparison above and then painted nothing — a transparent window. Both statements were true, and that is the interesting part: a benchmark builds a tree and lays it out against an available space, while a facade builds a window root, stretches it to the window, and reads bounds while it paints.

The window root was the reason. gpui's root is size_full — 100% in both axes — and morphorm reads a root's extent with to_px(0.0, 0.0), where a percentage resolves against a parent the root does not have. Zero by zero, and a zero-size root paints nothing: that was the blank window. Taffy lays the root out inside the available space instead, the way CSS resolves a root against the initial containing block. The engine now resolves a root's extent against available_space first.

$ cargo test --features test-support --test facade -- --nocapture
root 0,0 1600x1200 row 16,16 1568x40 row 16,64 1568x40

What caught it was a test that draws an actual frame through the facade, twice — once with the default engine and once with this one — and compares the geometry. That calibration matters: it is what says the harness is right when the swapped engine fails. (The test platform runs at a scale factor of 2, which is why the numbers are twice the 800×600 the test resizes to.)

Cost

What it costs, on a tree that costs something.

A twelve-node tree finishes in microseconds, so it is too close to the floor for a ratio to mean much. The tree worth timing is fifty to a thousand buttons in two columns — the shape of a real list or data grid — each with an icon whose width and a row whose height change every frame, so no frame is a repeat of the last one and neither engine can serve a stale layout.

One frame of layout criterion means · 100 measurements · taffy vs morphorm
buttons nodes taffy morphorm result
50 176 329.5 µs 96.7 µs 3.4× faster
200 701 1.281 ms 379.6 µs 3.4× faster
1000 3501 7.305 ms 2.002 ms 3.7× faster

A second run put the three ratios at 3.48×, 3.49× and 3.82×, so the headline is a range: 3.4–3.8×. The trees are laid out identically by both engines — 0.00 px across all 3501 nodes at a thousand buttons.

The frame budget share of 16.7 ms, at 60 FPS
buttons taffy morphorm result
50 2.0% 0.6% 1.4% of the frame back
200 7.7% 2.3% 5.4% of the frame back
1000 44% 12% 32% of the frame back

The ratio holds as the tree grows rather than converging, which is why the absolute numbers are the interesting ones. At a thousand changing buttons taffy is still deciding where boxes go for nearly half the frame; this one is done in an eighth of it, and the other 88% is yours. The demo in the repository renders exactly this tree and reports the pipeline's own layout phase, so you can watch the two numbers instead of reading them.

On the 12-node tree the same two columns, at the floor
operation taffy morphorm result
frame — clear, build the tree, compute_layout 21.7 µs 6.2 µs 3.5× faster
bounds — layout_bounds for all 12 nodes 2.29 µs 2.22 µs not stable

bounds is the one figure here that is not stable — 1.03× and 1.21× in the two runs this page quotes, against 1.28× and 1.42× on the older compiler the numbers were first taken with, and run 1's taffy interval spans 2.065–2.501 µs on its own. Read it as "comparable, both under three microseconds", not as a ratio.

Limits

What it does not say.

  • One host, one laptop. Every timing here was taken on an Intel i7-8750H with no CPU pinning, and the same binary moves 20–25% from run to run. The correctness result did not move between runs; only the clocks did.
  • Both engines as written. Node storage, caching and bookkeeping are inside the measurement, so the ratio is a claim about these two implementations, not about taffy and morphorm in the abstract.
  • A comparison, not a proof. It says the conversion is right where it claims to be right, on trees built from what it can express. It says nothing about the cases it declines.
  • taffy implements more of CSS. Block flow, wrapping modes, grid track sizing with minmax and fr, flex_shrink, and independent justify_content and align_items. morphorm has fewer concepts, which is part of why it is cheaper here, and the table below is what that costs.
Translation

Where the vocabularies differ.

EngineLayoutStyle is a set of copies of taffy's style enums; morphorm reads its own, and they are not the same shape. So most of the crate is a translation rather than a rename, and every choice it makes is written down: here, and with its reasoning in the module docs.

Construct by construct handled approximate dropped
construct what happens kind
flex_grow Units::Stretch(factor) approximate
flex_shrink dropped — morphorm has no shrink factor dropped
align_items: Stretch Units::Stretch(1.0), only where the parent's cross extent is definite conditional
justify_content + align_items collapse into one nine-way Alignment collapsed
SpaceBetween / Evenly / Around, Baseline, align_self no equivalent; the start of the axis dropped
margin on an in-flow child dropped — morphorm reads spacing only for absolute children dropped
border_widths, scrollbar gutter folded into padding, the same inset the content box sees folded
ColumnReverse, WrapReverse no equivalent; laid out as their forward forms dropped
position: relative + inset dropped, as CSS ignores it at layout time too dropped
grid track MinContent / MaxContent Auto; GridTemplateMinSize::Zero becomes Stretch(1.0) approximate
grid item placement not generated — every item lands in the first cell dropped
the tree root its extent is resolved against available_space handled

Four of these were found by testing rather than by reading, and three of them produce a different layout rather than a crash: an auto cross axis with unequal children, a measured leaf's single intrinsic-size hook, the percentage root below, and the one that follows.

flex_grow is the one that bites.

CSS grows an item beyond its own content; morphorm's Stretch divides the free space by factor from zero . One grown item and the two agree, which is why the twelve-node tree can use it. Two grown items carrying different content and they do not: two flex_grow: 1 buttons whose labels differ came out 170 and 168 px wide under taffy and 169 and 169 under morphorm.

So the buttons tree uses explicit widths, explicit row heights and a column of rows instead of a grid. All three of those are constructs morphorm cannot express, and all three are listed rather than measured. If your layout leans on content-driven flex growth, that is the case for staying on taffy.

Reproduce

Run it yourself.

The harness is in the crate, and its two columns are two implementations behind the same trait — one solver measured alone would say what morphorm costs, not what choosing morphorm costs.

# the crate's own harness, in its repository
# the crate's own harness, in its repository cargo test --test layout_engine -- --nocapture # the frame a facade actually draws, which is a different question cargo test --features test-support --test facade -- --nocapture # both columns, both trees CARGO_PROFILE_BENCH_DEBUG=0 cargo bench --bench layout_engine

The recorded run — command, toolchain, host and every sample — is benchmarks/2026-09-26-layout-engine-swap.md in the crate's repository, which is where every number on this page comes from.