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.
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.
# 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();
});
}
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.
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.
| 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.
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.
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.)
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.
| 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.
| 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.
| 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.
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.
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 | 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.
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 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.