From e6d3b70ba5d69115bf57c7dc9960eabe84170eeb Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 13:36:27 +0200 Subject: [PATCH 01/29] refactor(css): use one parser and resolve each style once --- .changeset/class-name-prop.md | 33 + .changeset/css-lengths-and-calc.md | 22 + .changeset/custom-properties-and-var.md | 18 + .changeset/motion-keeps-the-style-cache.md | 19 + .changeset/read-colours-with-lightningcss.md | 35 + .changeset/read-styles-without-buffering.md | 26 + .changeset/resolve-styles-once.md | 10 + .changeset/split-the-renderer-by-subject.md | 12 + .github/workflows/ci.yml | 4 + CONTEXT.md | 106 ++ docs/css-and-classname-plan.md | 938 ++++++++++ packages/native/Cargo.lock | 481 ++++- packages/native/Cargo.toml | 10 +- packages/native/benches/style_parse.rs | 116 ++ packages/native/css/Cargo.lock | 1045 +++++++++++ packages/native/css/Cargo.toml | 11 + packages/native/css/src/color.rs | 251 +++ packages/native/css/src/length.rs | 282 +++ packages/native/css/src/lib.rs | 318 ++++ packages/native/index.d.ts | 12 + packages/native/src/color.rs | 97 +- .../native/src/custom_elements/anchored.rs | 4 +- packages/native/src/custom_elements/code.rs | 4 +- packages/native/src/custom_elements/diff.rs | 10 +- packages/native/src/custom_elements/img.rs | 16 +- packages/native/src/custom_elements/input.rs | 4 +- .../native/src/custom_elements/markdown.rs | 8 +- packages/native/src/custom_elements/mod.rs | 14 + .../native/src/{element_tree.rs => events.rs} | 16 +- packages/native/src/inheritance.rs | 421 +++++ packages/native/src/lib.rs | 7 +- packages/native/src/motion.rs | 12 +- packages/native/src/renderer.rs | 1628 +---------------- packages/native/src/renderer/batch.rs | 267 +++ packages/native/src/renderer/frame.rs | 711 +++++++ packages/native/src/renderer/virtual_list.rs | 273 +++ packages/native/src/retained_tree.rs | 84 +- packages/native/src/style.rs | 604 +++++- packages/native/src/style/resolve.rs | 784 ++++++++ packages/native/src/style/vars.rs | 471 +++++ packages/native/src/test_renderer.rs | 23 +- packages/native/src/theme.rs | 71 +- packages/react/package.json | 1 + .../react/src/__tests__/class-names.test.tsx | 99 + .../src/__tests__/color-functions.test.tsx | 28 +- .../react/src/__tests__/css-lengths.test.tsx | 121 ++ .../src/__tests__/custom-properties.test.tsx | 265 +++ .../src/__tests__/host-config-style.test.tsx | 286 +++ .../react/src/__tests__/inheritance.test.tsx | 105 ++ .../react/src/__tests__/selection.test.tsx | 4 +- .../__tests__/style-resolution-cache.test.tsx | 143 ++ .../react/src/__tests__/style-types.check.ts | 35 + packages/react/src/__tests__/styles.test.tsx | 29 + packages/react/src/index.ts | 1 + packages/react/src/reconciler/class-names.ts | 134 ++ packages/react/src/reconciler/host-config.ts | 57 +- packages/react/src/reconciler/reconciler.ts | 14 +- packages/react/src/testing.ts | 17 +- packages/react/src/types/host.ts | 152 +- packages/react/tsconfig.typecheck.json | 11 + 60 files changed, 8906 insertions(+), 1874 deletions(-) create mode 100644 .changeset/class-name-prop.md create mode 100644 .changeset/css-lengths-and-calc.md create mode 100644 .changeset/custom-properties-and-var.md create mode 100644 .changeset/motion-keeps-the-style-cache.md create mode 100644 .changeset/read-colours-with-lightningcss.md create mode 100644 .changeset/read-styles-without-buffering.md create mode 100644 .changeset/resolve-styles-once.md create mode 100644 .changeset/split-the-renderer-by-subject.md create mode 100644 CONTEXT.md create mode 100644 docs/css-and-classname-plan.md create mode 100644 packages/native/benches/style_parse.rs create mode 100644 packages/native/css/Cargo.lock create mode 100644 packages/native/css/Cargo.toml create mode 100644 packages/native/css/src/color.rs create mode 100644 packages/native/css/src/length.rs create mode 100644 packages/native/css/src/lib.rs rename packages/native/src/{element_tree.rs => events.rs} (92%) create mode 100644 packages/native/src/inheritance.rs create mode 100644 packages/native/src/renderer/batch.rs create mode 100644 packages/native/src/renderer/frame.rs create mode 100644 packages/native/src/renderer/virtual_list.rs create mode 100644 packages/native/src/style/resolve.rs create mode 100644 packages/native/src/style/vars.rs create mode 100644 packages/react/src/__tests__/class-names.test.tsx create mode 100644 packages/react/src/__tests__/css-lengths.test.tsx create mode 100644 packages/react/src/__tests__/custom-properties.test.tsx create mode 100644 packages/react/src/__tests__/host-config-style.test.tsx create mode 100644 packages/react/src/__tests__/inheritance.test.tsx create mode 100644 packages/react/src/__tests__/style-resolution-cache.test.tsx create mode 100644 packages/react/src/__tests__/style-types.check.ts create mode 100644 packages/react/src/reconciler/class-names.ts create mode 100644 packages/react/tsconfig.typecheck.json diff --git a/.changeset/class-name-prop.md b/.changeset/class-name-prop.md new file mode 100644 index 00000000..982bf247 --- /dev/null +++ b/.changeset/class-name-prop.md @@ -0,0 +1,33 @@ +--- +"@gpuix/react": minor +--- + +Add a `className` prop and a resolver seam for it. + +`className` is `string | undefined` on every element, so `clsx` and `cn` need no +special handling. GPUIX ships no resolver. A root registers one: + +```ts +createRoot(renderer, { resolveClassName }) +``` + +The resolver reads one class token, such as `p-4`, and returns the `StyleDesc` +it declares, or `null` for a token it does not know. `@gpuix/tailwind` will be +one. Without a resolver a `className` does nothing and the root warns once. + +A declaration in `style` beats one from a class, key by key, and in the hover +and active states too. [CSS Style Attributes][spec] gives the attribute "a +specificity higher than any selector", so an element with +`style={{ backgroundColor: "red" }}` stays red under a `hover:bg-blue-500` +class, the way a browser keeps it red. + +Caching is per token. `clsx("p-4", a && "bg-blue-500", b && "text-lg")` writes up +to eight strings from three tokens, and the resolver sees three. A bounded cache +over whole strings sits in front of it, so a repeated string skips both the split +and the merge. + +`hideInstance` and `unhideInstance` now send the class-derived style as well. +React drives that pair for Suspense, and before this an element that suspended +came back with only its inline style. + +[spec]: https://www.w3.org/TR/css-style-attr/#cascading diff --git a/.changeset/css-lengths-and-calc.md b/.changeset/css-lengths-and-calc.md new file mode 100644 index 00000000..d90e54a3 --- /dev/null +++ b/.changeset/css-lengths-and-calc.md @@ -0,0 +1,22 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Read `lineHeight` the way CSS reads it, and accept `calc()` in any length. + +`lineHeight` used to mean pixels, so `lineHeight: 1.5` set a 1.5 px line. CSS +reads a bare number as a multiple of the font size, and that is what it now +means. A length keeps its unit, so `"24px"` is still 24 px, and `"150%"` and +`1.5` are the same thing. Anything at or below zero declares nothing. + +**This changes existing layouts.** A `lineHeight` written as a bare number was +already close to useless at pixel scale, so most of them are small numbers that +now read as multiples. To keep the old result, write the unit: `lineHeight: 20` +becomes `lineHeight: "20px"`. + +Every length also takes `calc()`, `min()`, `max()` and `clamp()`, folded by +lightningcss while the value parses. `rem` becomes pixels first, against the +window rem size, so `calc(1rem + 4px)` reaches a single number. This is what +makes the Tailwind spacing scale work, because every step in it is +`calc(var(--spacing) * n)`. diff --git a/.changeset/custom-properties-and-var.md b/.changeset/custom-properties-and-var.md new file mode 100644 index 00000000..18da7d44 --- /dev/null +++ b/.changeset/custom-properties-and-var.md @@ -0,0 +1,18 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Support CSS custom properties in the `style` prop. A `--name` key declares a value for the element and everything below it, and `var(--name)` reads it, with fallbacks including the empty one Tailwind writes as `var(--tw-ring-inset,)`. A missing variable with no fallback drops the declaration, which is what CSS calls invalid at computed-value time. + +Support `currentColor`. It reads the computed `color`, whether the element declares it or an ancestor does. + +Take text in every numeric style field. `padding`, `borderWidth`, `fontSize` and the other 33 numeric fields now accept `number | string`, so `8`, `"8px"` and `"var(--pad)"` all mean the same thing. A unit the renderer cannot read, such as `2rem`, drops the declaration instead of painting the number as pixels. + +Type custom properties with a pattern index signature, so `"-pad"` is a type error rather than a name that silently never resolves. `hover` and `active` reject them, because a state has no cascade of its own to declare into. + +Keep the resolved-style cache correct without giving up on elements that use no variable. A resolution that reads nothing inherited holds under every cascade, so only a `var()` or `currentColor` reader is ever invalidated by an ancestor. + +Stop re-resolving an element that has no `style` prop. The reconciler skips the call for an empty style at mount but sends `{}` on every update, so the first update on every unstyled element read as a change. + +Typecheck the `style` prop rules. `src/__tests__` was excluded from every tsconfig, so nothing checked the type assertions. `bun run typecheck` covers them now, and CI runs it. diff --git a/.changeset/motion-keeps-the-style-cache.md b/.changeset/motion-keeps-the-style-cache.md new file mode 100644 index 00000000..a104afd9 --- /dev/null +++ b/.changeset/motion-keeps-the-style-cache.md @@ -0,0 +1,19 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Keep the resolved-style cache for an element that animates. + +A motion frame drives eight numbers: `width`, `height`, `top`, `right`, +`bottom`, `left`, `borderRadius` and `opacity`. It used to drive them by +copying the element's whole style, writing the numbers into the copy and +resolving that copy. Every declaration the element made was reparsed on every +frame of the animation to change one value. + +None of the eight reads a custom property, `currentColor` or the font size, so +each one now lands on the element after the cached resolution does. An animated +element resolves its style once, the same as any other element. + +Custom elements resolve a style themselves, so a motion frame still reaches +them folded into one. diff --git a/.changeset/read-colours-with-lightningcss.md b/.changeset/read-colours-with-lightningcss.md new file mode 100644 index 00000000..ca1db249 --- /dev/null +++ b/.changeset/read-colours-with-lightningcss.md @@ -0,0 +1,35 @@ +--- +"@gpuix/native": minor +--- + +Read every colour with the same CSS parser the rest of the engine uses. + +GPUIX held two CSS parsers. Colour went through csscolorparser, and every other +value went through lightningcss. The two agreed on most colours and disagreed at +the edges, which is where the specifications matter most. Colour now goes through +lightningcss as well, and csscolorparser is gone. + +Three colour syntaxes work that did not before: + +- `color-mix()`, which Tailwind writes for every opacity modifier such as + `bg-red-500/50` +- `light-dark()`, which reads the appearance of the window +- `color()`, such as `color(display-p3 1 0 0)` + +`currentColor` now resolves wherever it sits, including inside `light-dark()`. +Before, only a value that was exactly `currentColor` resolved, and a nested one +made the whole declaration invalid. + +Four syntaxes no longer parse, because no CSS specification defines any of them: + +- `hsv()` and `hsva()` +- `hwba()` +- hex with no leading `#`, such as `ff0000ff` + +A declaration that uses one of these is invalid, so the property keeps the value +it would have had with no declaration at all. Write `hwb()` instead of `hwba()`, +and add the `#` to a bare hex colour. There is no CSS replacement for `hsv()`, +so convert the value to `hsl()` or `hwb()`. + +Alpha on `rgb()`, `hsl()` and `hwb()` now rounds to 8 bits, because that is how +lightningcss holds an sRGB colour. The wider colour spaces keep the exact value. diff --git a/.changeset/read-styles-without-buffering.md b/.changeset/read-styles-without-buffering.md new file mode 100644 index 00000000..5d971d1f --- /dev/null +++ b/.changeset/read-styles-without-buffering.md @@ -0,0 +1,26 @@ +--- +"@gpuix/native": patch +--- + +Read a `style` prop without buffering it, and stop carrying it by value. + +`StyleDesc` used `#[serde(flatten)]` to collect custom properties, and `Numeric` +and `FontWeightValue` used `#[serde(untagged)]`. Each of those makes serde read +the whole value into an intermediate tree before it looks at one field, and +every `setStyle` call paid for it. All three now have a hand written +`Deserialize`. A macro declares `StyleDesc` and its reader from one field list, +so the name JS writes and the name Rust reads come from the same literal. + +The struct is 1,728 bytes, so the read now writes straight into a box rather +than building on the stack and copying. Measured over 200,000 parses: + +| shape | before | after | no flatten, no untagged | +| --- | --- | --- | --- | +| two fields | 320 ns | 84 ns | 74 ns | +| eleven fields | 531 ns | 399 ns | 341 ns | + +A retained element holds that box instead of the struct, which takes it from +2,000 bytes to 280. A tree of 10,000 elements was carrying 17 MB of mostly +empty styles. Each op in a batched mutation shrank the same way. + +The wire format is unchanged. diff --git a/.changeset/resolve-styles-once.md b/.changeset/resolve-styles-once.md new file mode 100644 index 00000000..ccfd2612 --- /dev/null +++ b/.changeset/resolve-styles-once.md @@ -0,0 +1,10 @@ +--- +"@gpuix/native": minor +"@gpuix/react": patch +--- + +Resolve each element style once instead of on every frame. GPUI rebuilds its element tree on every frame, so the renderer used to run all 52 style branches again for styles that had not changed since the last update from React. The resolved style is now kept on the retained element and dropped when the style changes. + +Apply the `visibility` style. It reached the native side but nothing read it, so `visibility: "hidden"` did nothing. + +Keep the element style when React hides an element. `hideInstance` replaced the whole style with `visibility: "hidden"`, which dropped the layout box and every other style on the element. The `hover` and `active` styles are dropped while an element is hidden, so neither can paint an element React asked to hide. diff --git a/.changeset/split-the-renderer-by-subject.md b/.changeset/split-the-renderer-by-subject.md new file mode 100644 index 00000000..51160b98 --- /dev/null +++ b/.changeset/split-the-renderer-by-subject.md @@ -0,0 +1,12 @@ +--- +"@gpuix/native": patch +--- + +Split the renderer into modules by subject. + +`renderer.rs` held the napi binding, the GPUI view, the frame walk, the virtual +list state and the batch parser in one 3,358 line file. The frame walk now lives +in `renderer/frame.rs`, the retained state of one virtual list in +`renderer/virtual_list.rs`, and the batch parser in `renderer/batch.rs`. + +Nothing changed about what any of it does. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 500b3fe8..b50ad85b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,10 @@ jobs: name: bindings-aarch64-apple-darwin path: packages/native/ + - name: Typecheck React package + run: bun run typecheck + working-directory: packages/react + - name: Run React tests run: bun run test working-directory: packages/react diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..0c2fd0ce --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,106 @@ +# CONTEXT.md + +The shared vocabulary for GPUIX. Every term here has one meaning in this +codebase, and module names come from this list. + +Most of these words are taken from the CSS specifications. Where a word appears +in a specification, this file uses the specification meaning and nothing else. +That rule matters more than usual here, because GPUIX is being built to match +the specifications 1:1, so a word that drifts costs a reader twice. + +## Values and declarations + +**Declaration.** One property name and one value. `color: red` is a +declaration. This is the smallest unit the engine moves around. + +**Declaration block.** A set of declarations that arrived together from one +source. lightningcss keeps normal and important declarations in two separate +vectors inside one block, so "block" does not mean "all of equal weight". + +**Specified value.** A value as written, before anything reads it. Holds +`var()` references and relative units. + +**Computed value.** A value after the engine reads custom properties, resolves +relative units against the element and its ancestors, and applies inheritance. +This is what the engine stores and what animation interpolates. + +**Used value.** A computed value after layout supplies what was missing, such as +a percentage width that needed a containing block. Layout produces these, not +the cascade. + +**Scope.** The custom properties visible to one element. An element reads its +own declarations first and then its ancestors'. + +## Selecting and cascading + +**Cascade.** The specification algorithm that picks one winning declaration when +several declare the same property. It sorts by origin, importance, layer and +specificity. It is not inheritance. + +**Inheritance.** Passing a computed value from an element to its children, +for the properties the specification marks as inherited. A separate mechanism +from the cascade, and easy to confuse with it. + +**Cascade level.** One rank the cascade sorts by. GPUIX has four author levels: +normal class, normal inline, important class, important inline. Important +levels reverse the order of the normal ones. + +**Specificity.** The three-number weight of a selector, used by the cascade as a +tiebreak. `parcel_selectors` computes it, so GPUIX does not. + +**Matching context.** The view of the retained tree that can answer a selector +question. It knows an element's classes, id, attributes, siblings and position. +It exists so the tree stays a data structure and matching lives beside it. + +**Condition.** Anything that gates whether a declaration block applies. A state +pseudo-class such as `:hover`, a media query, or a container query. Conditions +are an open set. Nothing in the engine may hardcode a fixed list of them. + +**Class channel.** The stylesheet a GPUIX app hands the engine, as CSS text. +Replaces the per-token resolver callback. Tailwind output goes here unchanged. + +## Building a frame + +**Retained tree.** The element tree GPUIX keeps between frames, mutated by +React through the reconciler. The engine reads it. GPUI does not see it. + +**Retained element.** One node of the retained tree. + +**Element tree.** The GPUI element tree, rebuilt every frame from the retained +tree. GPUI is immediate mode, so this is thrown away and rebuilt each time. +Do not use this term for the retained tree. + +**Frame phase.** One named step in turning the retained tree into an element +tree. The phases are matching, resolving, layout, the container query second +pass, and paint order. They run in a fixed sequence, and container queries make +the sequence run twice. + +**Frame walk.** The recursion that turns the retained tree into an element +tree, once per frame. `renderer/frame.rs` owns it, and every frame phase runs +inside it. + +**Motion frame.** The values an animation drives for one element on one frame: +`width`, `height`, `top`, `right`, `bottom`, `left`, `borderRadius` and +`opacity`. A motion frame is not a declaration. It reaches the style sink after +the resolved style does, so an animated element keeps its cached resolution. + +**Resolved style.** The output of the resolve phase for one element: computed +values plus the conditional blocks that paint may still apply. Cached on the +retained element and dropped when the style changes. + +**Style sink.** The one trait at the GPUI edge. It takes computed values and +writes them onto a GPUI type. `gpuix-css` defines it, `gpuix-native` implements +it. This trait is the only thing in the engine that knows GPUI exists. + +**Wire format.** The JSON shape React sends over napi. `StyleDesc` is the wire +format for the `style` prop. It stops at the crate edge and the engine never +sees it. + +## Naming rules + +A word from a CSS specification keeps its specification meaning. If code needs a +concept the specifications do not name, give it a name that is clearly not a +specification word. + +A module is named for the concept it owns, not for the layer it sits in. Prefer +`cascade` over `style_utils`. diff --git a/docs/css-and-classname-plan.md b/docs/css-and-classname-plan.md new file mode 100644 index 00000000..2be569af --- /dev/null +++ b/docs/css-and-classname-plan.md @@ -0,0 +1,938 @@ +# CSS values, the cascade, and `className` + +Status: draft, waiting for sign-off. Branch: `feat/css-values-and-classname`. + +Revision 2. An architecture review of revision 1 changed eight decisions. The section +"What revision 2 changed" lists them, with the reason for each. + +## What this adds + +GPUIX today takes a finished style object from JavaScript. Every length is a pixel number. +Every colour is a string that `parse_color` reads once. Nothing is left to work out. + +This document specifies four layers: + +0. `gpuix-css`, a crate that parses CSS values and resolves `var()`. It does not link gpui. +1. A resolved style seam in `gpuix-native`. One function turns a style plus its inherited + environment into a `gpui::StyleRefinement`, and the result is cached per element. +2. A `className` prop in `@gpuix/react`, resolved through one function and one root option. +3. `@gpuix/tailwind`, a thin adapter that turns Tailwind classes into CSS declarations. + +The goal is not "make Tailwind work". The goal is to make GPUIX understand CSS, so that +Tailwind works because it emits CSS. + +## What the code does today + +These facts come from gpuix at `9f0fb6d` and the pinned zed fork at `4d80927`. Read them +before you design against them. + +### GPUIX + +- `className` is in `RESERVED_PROPS` at `packages/react/src/reconciler/host-config.ts:134`. + The reconciler drops it without a message. +- `apply_styles` at `renderer.rs:2971-3277` is 307 lines and 52 `if let Some` branches. + It runs for every element on every frame. +- `renderer.rs:2571` already calls `apply_styles(refinement, hover_style)` on a bare + `StyleRefinement`, because `apply_styles` is generic over `E: Styled`. +- `RetainedElement` (`retained_tree.rs:13-31`) holds `style: Option` and + `subtree_revision: u64`. +- `RetainedTree::set_style` (`retained_tree.rs:170-181`) compares the old style against the new + one and marks the element changed only when they differ. +- `packages/native/src/color.rs` uses `csscolorparser 0.8.3`. Named colours, hex, `rgb()`, + `hsl()`, `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()` and relative `from` syntax all parse. + `color-mix()` does not. +- `DimensionValue` in `style.rs` accepts a number, `"N%"` or `"auto"`, and applies to `width`, + `height` and the min and max variants only. Every other length field is a bare `f64`. +- `StyleDesc.boxShadow` holds one `BoxShadowValue`, not a list. +- `StyleDesc.background` is only a fallback colour for `backgroundColor` (`renderer.rs:3150`). +- `Inherited` (`renderer.rs:1833-1868`) carries two fields, `selectable` and `selection_wash`. +- `renderer.rs` is 3,683 lines. +- Four call sites send a style: `host-config.ts:125` (`sendStyle`), `:369` (`commitUpdate`), + `:395` (`hideInstance`), `:399` (`unhideInstance`). `setStyle` replaces, never merges. + +### GPUI, in the pinned fork + +- `Styled::style(&mut self) -> &mut StyleRefinement` (`crates/gpui/src/styled.rs:24`). +- `Style` derives `Refineable`, with `#[refineable(Debug, PartialEq, Serialize, Deserialize, + JsonSchema)]` (`crates/gpui/src/style.rs:178`). So `StyleRefinement` compares, serializes and + deserializes. +- `Refineable::Refinement` is itself `Refineable` with the same `Refinement` type + (`crates/refineable/src/refineable.rs:30`). Refinements merge into refinements. +- Every variant API takes the same type (`crates/gpui/src/elements/div.rs`): + `hover` at 806, `group_hover` at 816, `focus` at 1213, `active` at 1500, + `group_active` at 1509, `group_drag_over` at 1150, all + `impl FnOnce(StyleRefinement) -> StyleRefinement`. `group(name)` at 737. + There is no `group_focus`. +- `Style.padding` is `Edges`. `margin` and `inset` are `Edges`. + `gap` is `Size`. `flex_basis` is `Length`. `border_widths` is + `Edges`. Percentages, rems and `auto` already work in all of them. + GPUIX flattens each one to a pixel number and throws the capability away. +- `linear_gradient(angle, from, to)` takes exactly two colour stops. No radial, no conic. +- taffy is pinned `=0.13.0` and has calc. GPUI's `Length` has no calc variant, so nothing + reaches taffy's resolver. + +## What revisions 2 and 3 changed + +| # | Change | Reason | +|---|--------|--------| +| 1 | `resolve()` returns a `gpui::StyleRefinement`, cached per element. `apply_styles` is deleted. | Revision 1 stacked CSS work on a 52-branch per-frame scan, then set a 2% budget to contain it. GPUI already has the type and the merge. | +| 2 | ~~One `when` list replaces `hover`, `active`, `focus`, `groupHover`, `groupActive`, `groupFocus` and `media`.~~ **Reversed in revision 3.** `style` carries no conditions at all. | Revision 2 replaced seven fields with one list. Revision 3 removed the list. A CSS style attribute holds declarations, so a condition belongs in a class. See layer 2. | +| 3 | `gpuix-css` is its own crate and does not depend on gpui. | Revision 1 put pure value tests behind Metal, the zed submodule and a macOS runner. | +| 4 | `hideInstance` joins the call sites that funnel through one style function. | Revision 1 named three of four. The missing one is the only one that destroys state. | +| 5 | The resolver is a `createRoot` option, not a global. `invalidateClassNameCache` is gone. | One adapter is a hypothetical seam. A global also serialises tests that vitest runs in parallel. | +| 6 | Rust keeps `StyleDesc`. Every numeric field deserializes through one `Numeric` type, and colour fields stay strings. | The full move to declarations-only is real but it is not this branch. See follow-ups. | +| 7 | The steady-state gate asserts a counter, not a duration. | A 2% wall-clock band on a CI runner is noise. It would be muted, and a muted gate reads as coverage. | +| 8 | The cascade lives in `packages/native/src/cascade.rs`. | `renderer.rs` is already 3,683 lines. | + +## Layer 0: the `gpuix-css` crate + +New crate at `packages/native/css`, named `gpuix-css`. It depends on `lightningcss` with +`default-features = false`, and on nothing else. It must not depend on gpui. + +`gpuix-native` adds it as a path dependency. `csscolorparser` is removed. + +### Why a separate crate + +`gpuix-native` links gpui, gpui_platform, gpui_macos, core-text, core-graphics, fifteen +tree-sitter grammars and the whole zed submodule. The value tests in this specification are +pure functions: a CSS string goes in, a value comes out. Inside `gpuix-native` they need a +Metal toolchain, the zed checkout and a macOS runner, and CI runs macos-latest only. + +In their own crate they run on Linux in milliseconds with `cargo test -p gpuix-css`. + +This seam is real, not hypothetical. Two adapters already sit on it: the `style` prop and +`className`. + +### Interface + +```rust +pub fn parse(property: &str, value: &str) -> Result; +pub fn substitute(unparsed: &Unparsed, vars: &Vars) -> Result; + +pub enum Parsed { + Ready(Property<'static>), // no var(), folded now + Pending(Unparsed), // contains var(), finish later +} +``` + +Three items. Everything else in the crate is private. + +### What lightningcss provides + +- `Property::parse_string(property_id, input, options)` parses a declaration into a typed value. +- `Property::Unparsed(UnparsedProperty)` holds a value that contains `var()`. + `UnparsedProperty::substitute_variables(&self, vars)` finishes it. +- `Property::Custom(CustomProperty)` holds a `--x` declaration. +- `CssColor` covers every CSS colour grammar, plus `color-mix()` and `currentColor`. + `Calc` folds during the parse. +- `MediaQuery` and `MediaCondition` parse. There is no evaluator, because lightningcss is a + compiler. Write that in `gpuix-css` and keep it pure: it takes a size, not a window. + +Tailwind v4 ships the same library. `@tailwindcss/node@4.3.3` depends on `lightningcss@1.32.0`. + +Do not gate the crate behind a cargo feature. Measure the `.node` size before and after, and +put both numbers in the pull request body. + +### What folds now, and what does not + +A value with no `var()` folds the moment it arrives, once, forever: + +``` +padding: calc(1rem + 2px) -> Ready +background-color: oklch(...) -> Ready +width: calc(1 / 2 * 100%) -> Ready, 50% +``` + +A value that mentions `var()` becomes `Pending`, because it depends on the element's ancestors. + +`calc()` that mixes a percentage with a length is rejected. Name the expression in the error: + +``` +calc(100% - 20px) -> error: mixed percentage and length in calc, not supported +``` + +taffy can do this. GPUI cannot express it without a calc variant on `Length` in the zed fork, +and every future upstream sync would carry that patch. It is a follow-up. + +### Supported functions + +Ship `calc()`, `min()`, `max()`, `clamp()`, `var()`, `color-mix()`, `linear-gradient()`. + +Leave out `radial-gradient()`, `conic-gradient()`, `env()`, `attr()`, `image-set()`. + +`linear-gradient()` maps to `gpui::linear_gradient(angle, from, to)`, which takes exactly two +stops. If a gradient declares more, keep the first and the last, drop the rest, warn once. + +### Units + +Keep `rem` symbolic, so a `rem` reaches GPUI as `AbsoluteLength::Rems`. A change to the window +rem size then reflows with no style re-resolution. + +A `calc()` that mixes `rem` with `px` must fold, so it takes the rem size as a parameter. That +is the one place `rem` stops being live. Say so in the reference documentation. + +`border-*` is `AbsoluteLength` in GPUI, so it takes `px` and `rem` and rejects percentages. +Report that as an error rather than rounding to zero. + +## Layer 1: the resolved style seam + +### The problem this replaces + +`apply_styles` is a shallow module. Its interface is 307 lines long, because you cannot know +what it does without reading all of it. It runs per element per frame, scanning 52 optional +fields that are almost always `None`. + +Revision 1 added CSS parsing, variable substitution and media evaluation on top of that loop. + +### The seam + +```rust +// packages/native/src/style/resolve.rs +pub fn resolve(style: &StyleDesc, env: &Cascade) -> StyleRefinement; +``` + +One function. It returns GPUI's own type. + +This works because `Styled::style()` returns `&mut StyleRefinement`, `Style` derives +`Refineable`, and a `Refinement` is itself `Refineable`, so refinements merge into refinements. +`renderer.rs:2571` already calls `apply_styles` on a bare `StyleRefinement`, so the pattern is +in the codebase already. It was never named. + +### The cache + +Add one field to `RetainedElement`: + +```rust +pub resolved: Option, + +pub struct Resolved { + refinement: StyleRefinement, + variants: Vec<(Condition, StyleRefinement)>, + /// The cascade generation that produced this. Compare before reuse. + generation: u64, +} +``` + +`RetainedTree::set_style` already compares the old style against the new one +(`retained_tree.rs:173`). Clear `resolved` in the branch that already exists. There is no new +invalidation point to invent. + +Per frame, an element that has not changed does: + +```rust +el.style().refine(&cached.refinement); +``` + +That is a merge of set fields. No branch scan, no parsing, no substitution. + +### Applying variants + +Every GPUI variant API takes `impl FnOnce(StyleRefinement) -> StyleRefinement`, so a cached +variant refinement applies directly: + +```rust +for (condition, refinement) in &cached.variants { + el = match condition { + Condition::Hover => el.hover(|_| refinement.clone()), + Condition::Active => el.active(|_| refinement.clone()), + Condition::Focus => el.focus(|_| refinement.clone()), + Condition::Group { name, state: GroupState::Hover } => el.group_hover(name, |_| refinement.clone()), + Condition::Group { name, state: GroupState::Active } => el.group_active(name, |_| refinement.clone()), + // A media condition is not a GPUI variant. It is evaluated during the walk. + Condition::Media { .. } => el.style().refine(refinement), + }; +} +``` + +Use GPUI's plain `focus`, not `focus_visible` or `in_focus`. Tailwind's `focus:` is the +unqualified one. + +A media condition is not a GPUI variant. Evaluate it against the window size during the walk +and merge the refinement when it matches. + +### Delete `apply_styles` + +The 307-line function becomes the private body of `resolve`, converted to write into a +`StyleRefinement` rather than to chain builder calls on `E: Styled`. Nothing else calls it. + +Note the counterintuitive result: the body does not shrink much at first. The win is that it +runs once per style change instead of once per element per frame, that its output is a value +you can compare and serialize, and that the cascade and the variants get a return type. + +## Layer 1b: the cascade + +New module: `packages/native/src/cascade.rs`. `Inherited` moves here from `renderer.rs:1833`. + +```rust +impl Cascade { + pub fn root(theme: &Theme, window: Size) -> Self; + pub fn descend(&self, style: Option<&StyleDesc>) -> Self; + pub fn resolve(&self, style: &StyleDesc) -> Resolved; +} +``` + +Three methods hide inheritance, the variable map and condition evaluation. The tree walk in +`renderer.rs` calls `descend` going down and `resolve` at each node, and learns nothing about +any of it. + +### Inheritance + +**GPUI already does this, and revision 2 said the opposite.** Revision 2 claimed a `color` on a +div does not reach a nested ``, called the fix a visible behaviour change, and asked for a +changeset naming it. That is wrong. It was written from reading `apply_styles`, which has no +inheritance in it, without checking what GPUI does underneath. + +A `div` pushes its text style onto a window stack at `div.rs:1840`, and `window.text_style()` +composes the whole stack. A `` with no style of its own paints with the nearest ancestor +declaration. `SelectableText` already documents its dependence on this at +`packages/native/src/text/paint.rs:155`. + +Measured, not reasoned. Each row declares the property on the ancestor, on the text itself, and +nowhere, then compares the three screenshots byte for byte. + +| Property | Ancestor against nothing | Ancestor against text | +| --- | --- | --- | +| `color` | 0.02 | identical | +| `fontSize` | 0.01 | identical | +| `fontWeight` | 0.01 | identical | +| `fontFamily` | 0.43 | identical | +| `lineHeight` | 0.86 | identical | +| `textAlign` | 0.48 | identical | + +Lower means more different. The left column shows the declaration does something. The right +column shows the ancestor and the text produce the same pixels, which is inheritance. + +`packages/react/src/__tests__/inheritance.test.tsx` pins all six, plus a nested case where the +nearer ancestor wins. The behaviour comes from the pinned fork rather than from this repository, +so a fork bump could remove it silently. That is what the test is for. + +So there is no inheritance work for text properties, no behaviour change, and nothing for a +changeset to name. What is left of the original list: + +- `userSelect` and `selectionColor` already inherit through `Cascade`, which is the old + `Inherited` struct moved out of `renderer.rs`. +- `cursor` does not inherit. It is not in GPUI's `TextStyle`, and a screenshot cannot see a + cursor, so this needs a different test before it is worth building. Left out of this branch. +- Custom properties are the real work, and the rest of this section covers them. + +A note on why the earlier claim survived review: every reviewer, including this one, read the +GPUIX code and stopped there. The behaviour lives one layer down. + +### Custom properties + +**Built. What follows is what shipped, not a proposal.** + +Declare them in `style`, exactly as on the web: + +```tsx +
+
+
+``` + +Type them with a template literal pattern index signature: + +```ts +export interface StyleDesc { + [key: `--${string}`]: string | number | undefined + // ...the existing keys +} +``` + +Verified with `tsc --strict`: `"-pad"` is rejected because it does not match the pattern, and +`color: 42` stays an error. React's own `CSSProperties` uses an open index signature, which +lets every typo through. + +Resolution runs in three steps. + +1. Serde collects every key that is not a known field into `StyleDesc.custom` through + `#[serde(flatten)]`. `declared_variables` keeps the `--` names and sorts them. +2. During the walk, `descend` layers the node's variables over the inherited map. It runs + before the node's own style resolves, so a declaration is in scope for the `var()` beside it. +3. `Scope::value` substitutes textually, and the existing value parsers read the result as if + the author had written it in place. + +Substitution is textual and does not go through `gpuix-css`. The plan routed it through typed +`Property` values, which would mean parsing and re-emitting every inline style. Substituting +text keeps `StyleDesc` typed, keeps `gpuix-css` off the per-element path, and matches what CSS +says a custom property is: text, held uninterpreted until a property reads it. + +Three rules keep this fast. + +The variable map is shared by pointer. A node that declares no variables passes the parent's map +down unchanged, and a node that redeclares the value it already has keeps the same pointer too. + +`Resolved.cascade` holds the cascade a resolution read, or `None` when it read nothing +inherited. `None` is the common case, and a resolution marked `None` survives every cascade +change. Only an element that used `var()` or `currentColor` is ever invalidated by an ancestor. +The key is the whole `Cascade` rather than the variable map alone, so an ancestor changing +`userSelect` also invalidates a `var()` reader below it. That is one pointer to compare instead +of two, and both changes are rare. + +`descend` is memoized per element on the parent cascade pointer, and the root cascade is +memoized on the theme. Without both, a declaration would build a new `Arc` on every frame and +the whole subtree below it would re-resolve on every frame, which is exactly what the cache +exists to stop. `packages/react/src/__tests__/custom-properties.test.tsx` pins this with a +counter: ten frames over twenty readers under one declaration must add zero resolutions. + +While wiring this, a second cache thrash turned up and is fixed. `sendStyle` skips the napi call +for an empty style at mount, but `commitUpdate` always sends `{}`. So the first update on every +element with no `style` prop read as a change and resolved a style with nothing in it. +`set_style` now stores `None` for a style that declares nothing, which makes the two paths agree. + +Order inside one element does not matter. `var()` resolves against the element's final set of +custom properties, not against the position of the declaration. Tailwind depends on this: +`text-sm` emits `line-height: var(--tw-leading, var(--text-sm--line-height))` while `leading-6` +emits `--tw-leading`, and both land on the same element in either order. + +`var()` supports a fallback, including an empty one. Tailwind emits `var(--tw-ring-inset,)`. + +### currentColor + +**Built.** `Cascade` tracks the computed `color` and `Scope::color` resolves the keyword against +it. Tailwind needs this because `ring-*` emits `var(--tw-ring-color, currentcolor)`. + +The root starts at `gpui::black()`, which is what `TextStyle::default` uses, so the cascade's +copy of the colour and GPUI's own text style stack agree without setting a colour on the root +wrapper. Setting one would change how every unstyled `` paints, and that is a separate +decision. + +Two limits. Only a bare `currentColor` resolves, so one nested inside `color-mix()` falls +through to the colour parser and fails there. And `color: currentColor` declares nothing, +because CSS computes it to `inherit`. + +### Window media queries + +**Not built.** Nothing emits a media condition until `className` lands in layer 3, and building +an evaluator with no producer would repeat the mistake that `group` was. The design below stands. + +`gpuix-css` parses the condition. `Cascade` holds the window size and evaluates it. + +On a resize, bump the cascade generation and re-resolve. This is the same invalidation the +variable cascade already needs, so it costs no new machinery. + +Treat `@media (hover: hover)` as always true. Tailwind wraps every `hover:` utility in it, and +a desktop window always has a pointer. + +Container queries stay out of this branch. A container query makes style depend on layout and +layout depend on style, and terminating that loop needs real containment rules. + +## Layer 2: `StyleDesc` changes + +### Numeric fields take text + +**Built.** Every numeric field on `StyleDesc` was `Option`, which cannot hold +`var(--pad)`. All 36 now deserialize through one `Numeric` type: + +```rust +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum Numeric { + Number(f64), + Text(String), +} +``` + +In TypeScript that is `number | string`. A bare number still means pixels, so `8`, `"8px"` and +`"var(--pad)"` all declare the same padding. `Scope::length` hands the text to +`gpuix_css::length`, which reads a number, a `px` or `rem` length, a percentage, and any +`calc()`, `min()`, `max()` or `clamp()` over them. A unit it cannot fold drops the declaration, +because painting 2 pixels for `2vw` is worse than painting nothing. The unitless fields such as +`opacity` and `flexGrow` widened too, since `var()` is legal in any property and a field left +as `f64` would reject it. + +### Lengths, `calc()` and `rem` + +**Built.** `packages/native/css/src/length.rs` reads one length and returns pixels, a fraction +or a bare number. lightningcss does the parsing, which means `calc()`, `min()`, `max()` and +`clamp()` fold while the value parses, with no evaluator of ours in the middle. Two shapes need +handling before the handoff. + +A bare `1.5` reads as `1.5px` in lightningcss, because CSS quirks mode says so. A number is +read first and never reaches the parser. + +`rem` is rewritten to pixels before parsing, against the window rem size. lightningcss holds +`rem` as a relative length and will not add it to a `px`, so `calc(1rem + 4px)` would come back +unfolded. This deviates from what layer 3 planned, which was to keep `rem` symbolic all the way +to `AbsoluteLength::Rems` so a rem size change reflowed with no re-resolution. Folding early +costs that: the root cascade keys on the window rem size, so a `set_rem_size` call re-resolves +every style that reads a rem. Nothing in GPUIX calls it today. + +### `lineHeight` is a multiple, not a length + +**Built, and breaking.** A bare `lineHeight` used to mean pixels. It now means a multiple of +the font size, which is what CSS means, and reaches GPUI as `gpui::relative(n)`. A percentage +is the same multiple. A length keeps its unit. Zero or less declares nothing. + +`packages/react/src/__tests__/css-lengths.test.tsx` pins all four against a wrapped paragraph, +with a differ check first, so a line height that quietly did nothing would fail rather than +pass. + +### Reading a style without buffering it + +**Built.** `Numeric` and `FontWeightValue` were `#[serde(untagged)]` and `StyleDesc` was +`#[serde(flatten)]`. Each of those makes serde read the whole value into an intermediate tree +before it looks at one field, and every `setStyle` paid for it. All three now have a hand +written `Deserialize`. A `style_desc!` macro declares `StyleDesc` and its reader from one field +list, so the name JS writes and the name Rust reads come from the same literal. The wire format +did not change, and one test reads what `Serialize` writes against the names the reader knows, +which fails if the two halves ever disagree. + +That left the cost of the struct itself. `StyleDesc` is 1,728 bytes, so the parse spent more +time moving it than reading it. `StyleDesc::from_json_boxed` writes into a `Box` from the +start, through the same `fill` the ordinary `Deserialize` uses, so the two cannot disagree. +Measured over 200,000 parses: + +| shape | before | hand written | into a box | no flatten, no untagged | +| --- | --- | --- | --- | --- | +| two fields | 320 ns | 178 ns | 84 ns | 74 ns | +| eleven fields | 531 ns | 466 ns | 399 ns | 341 ns | + +A `RetainedElement` holds that box rather than the struct, which takes it from 2,000 bytes to +280. A tree of 10,000 elements was carrying 17 MB of styles that were mostly empty. `BatchOp` +shrank the same way, since one `SetStyle` variant made every op in a batch as wide as a style. + +### `style` holds declarations, never selectors + +Revision 2 proposed a `when` list on `StyleDesc`, holding conditions such as hover, active, +group and media, plus a `group` field to name an ancestor. Revision 3 removes both. They were +built, tested and then deleted. + +[CSS Style Attributes][css-style-attr] defines the attribute value as + +> the syntax of the contents of a CSS declaration block (excluding the delimiting braces) + +and nothing else. It cannot express `:hover`, it cannot express +`.card:hover &`, and it cannot express `@media`. Every condition in CSS comes from a selector or +an at-rule, and both of those live in a stylesheet reached through a class. GPUIX must behave +the same way, so `style` carries no conditions and every condition arrives through `className`. + +The `group` field was the clearer mistake. It copied Tailwind's model, where `group` is a class +name rather than a property, and it duplicated `className` before `className` shipped. Naming an +ancestor is what a class already does. + +`hover` and `active` stay on `StyleDesc` because they predate this plan and removing them now +would leave no way to express a hover until layer 3 lands. They are the two exceptions, they get +no siblings, and they are candidates for removal in the release that ships `className`. That +removal is a breaking change and needs its own sign-off. + +### What GPUI actually models as style + +Worth recording, because it took a trait-by-trait read of the pinned fork to establish and it +decides where conditions belong. + +| Method | Trait | +| --- | --- | +| `group`, `hover`, `focus`, `in_focus`, `group_hover` | `InteractiveElement` | +| `active`, `group_active` | `StatefulInteractiveElement` | +| every layout, colour and text setter | `Styled` | + +Not one conditional method is on `Styled`, and `Styled::style()` returns only the base +refinement. GPUI does not treat a condition as a style property either. It treats it as +interactivity. The existing `style.hover` already conflated the two before this plan started. + +Three more facts from the same read, so layer 3 does not rediscover them: + +- `hover()` holds `debug_assert!(hover_style.is_none(), "hover style already set")`. Two rules + that resolve to the same condition on one element must merge into a single call, or a debug + build panics. +- There is no `group_focus`. Tailwind's `group-focus:` has nothing to map onto. +- `focus()` needs `track_focus` and a focus handle. A plain `div` owns neither, so `:focus` + needs focus-handle plumbing before it can work. + +### Resolved conditions + +`Resolved` currently holds `base`, `hover` and `active`. Layer 3 replaces those two fields with +`Vec<(Condition, StyleRefinement)>`, because a stylesheet can produce any number of conditions +and can produce the same one twice. `Condition` is an internal type built by the selector +parser. It never crosses the FFI boundary, so it needs no serde and no unknown-kind variant. + +## Layer 3: the `className` seam + +### The prop + +**Built.** `className?: string` on the shared `Props` base. Both `jsx-runtime.d.ts` and +`jsx-dev-runtime.d.ts` map every intrinsic element to `Props`, so they needed no edit. + +`string | undefined` is the whole type, so `clsx` and `cn` work with no special handling. + +### What a resolver returns + +**Built, and different from what this section first planned.** The plan had the resolver +return declarations, `Array<{ on: Condition | null; declarations: Array<[string, string]> }>`. +It returns a `StyleDesc` instead: + +```ts +export type ClassNameResolver = (token: string) => StyleDesc | null +``` + +Declarations would have put a CSS property name to `StyleDesc` key table in TypeScript, next to +the one Rust already has, and the two would drift. `StyleDesc` already carries `hover` and +`active`, so the shape loses nothing that `setStyle` can carry today. The adapter owns every +piece of CSS knowledge and layer 3 is a merge with no table in it. + +The cost is that only `hover` and `active` are reachable from a class. Focus, group and media +conditions need the resolved-style seam of layer 1 rather than `setStyle`, which takes a +`StyleDesc`. Layer 4 warns and drops them, as it already does for `group-focus:`. + +### One function, four call sites + +```ts +function computeStyle(props: Props, container: Container): StyleDesc +``` + +**Built.** All four call sites use it: `sendStyle`, `commitUpdate`, `hideInstance` and +`unhideInstance`. + +`hideInstance` is the one revision 1 missed, and it is the only one that destroys state. + +It used to send `{ visibility: "hidden" }` over the inline style, and `setStyle` replaces +rather than merges. That round-tripped only because `props.style` was the sole source of truth. +With `className` it is not, so hiding an element would discard every class-derived style and +`unhideInstance` would restore only the inline prop. React drives that pair for Suspense, so +the symptom is content that unstyles itself after it suspends. + +`hideInstance` now sends `computeStyle(props, container)` with `visibility` overridden, and +with `hover` and `active` dropped, or a hover style that sets `visibility` would paint an +element React asked to hide. `host-config-style.test.tsx` pins both. + +### Registering a resolver + +**Built.** The resolver is an option on the root, not a global: + +```ts +createRoot(renderer, { resolveClassName }) +``` + +`createTestRoot` takes the same options, so a test registers a resolver over a fixed table. + +One adapter means a hypothetical seam, and this specification plans exactly one, Tailwind v4. +It already puts v3 behind a different seam, `TailwindEngine`. A global buys nothing here. + +A global costs something. Global mutable state plus a global cache means two tests with +different `appearance` settings cannot run at once, and vitest runs files concurrently, and +`createTestRoot()` drives a real renderer. + +There is no precedent for a global setter in this package. `packages/react/src/index.ts` +exports `createRenderer`, `render` and `resetRender`, and `src` contains no global setter at all. + +If no resolver is set and an element has a `className`, do nothing and print one development +warning. Never throw. + +`invalidateClassNameCache()` does not exist. The cache's lifetime is the root's. Exporting a +manual invalidation would make the caller responsible for knowing when the cache is stale, +which is knowledge the module owns. + +### Precedence + +**Built.** [CSS Style Attributes][css-style-attr] settles this, and it is stricter than it first looks: + +> These declarations are considered to have author origin and a specificity higher than any +> selector. + +So `style` beats `className` always, key by key, and it beats a conditional rule too. Given +`style={{ backgroundColor: "red" }}` and a class that sets `background-color` on hover, the +element stays red while hovered. A browser behaves the same way, and only `!important` changes +it. GPUIX has no `!important`. + +That is a constraint on the resolver, not a note. A condition resolved from `className` must not +write a key the `style` prop already set, or the element will change colour on hover where CSS +says it must not. + +`motion` keeps overwriting its eight numeric keys every frame, ahead of both. + +A conflict inside one class string is the adapter's problem. There is no specificity and no +selector engine among classes. That rule is flat: last write wins. + +[css-style-attr]: https://www.w3.org/TR/css-style-attr/#cascading + +### The cache + +**Built.** Cache one class token, not one class string. + +`clsx("p-4", isActive && "bg-blue-500", isLarge && "text-lg")` produces up to eight strings from +three tokens. Five toggles produce thirty-two. A token cache stores the tokens. + +A cached token holds the `StyleDesc` the resolver returned, or `null` for a token it rejected, +so an unknown class is asked about once. + +A bounded cache over whole strings sits in front, 256 entries, least recently used out first. +It matters because the same string usually repeats between two frames, and then neither the +split nor the merge runs. The token cache under it is unbounded, because the set of tokens an +application uses is fixed by its source code while the set of strings grows with every +combination of conditional classes. Both live on the root. + +One test drives five class strings built from three tokens and asserts the resolver saw exactly +those three. + +## Layer 4: `@gpuix/tailwind` + +### Version and modularity + +Target Tailwind v4 only. npm `latest` is `4.3.3`. v3 lives on as `v3-lts` at `3.4.19` and needs +entirely different code, because v4 has no JavaScript config. + +Build the seam anyway: + +```ts +interface TailwindEngine { + resolve(classes: string[]): CachedToken[] +} +``` + +Default it to `@gpuix/tailwind/v4`. Put the v4 code behind the interface from the first commit, +so adding v3 later is a new file rather than a refactor. + +### Loading + +```ts +const resolveClassName = await createTailwindResolver({ css: "./src/app.css" }) +createRoot(container, { resolveClassName }) +``` + +`__unstable__loadDesignSystem` is async, but resolution during a commit is synchronous, so the +application awaits the resolver before it mounts. A lazy resolver would render the first frame +unstyled. + +Options accept `{ css: path }` or `{ source: "...css text..." }`. The path form reads the file +and resolves `@import` through the filesystem, which works under `bun --hot`. + +A packaged application has no `node_modules`, so the path form will not work there. That is a +named follow-up. + +### Resolving a class + +``` +class string -> ds.getClassOrder() -> ds.candidatesToAst() -> walk -> declarations +``` + +`getClassOrder` gives Tailwind's real precedence, which sorts by property rather than by source +order, and matches what a browser produces. Apply in that order, last write wins. + +A user who wants `tailwind-merge` semantics runs `twMerge()` on the string first. The adapter +does not depend on it. + +An unknown class returns an empty array from `candidatesToAst`. That is the support test. + +Send `var()` through untouched. Rust owns the cascade. A value the adapter pre-flattened would +not respond to an ancestor that overrides the variable. + +### Harvesting `@property` + +The AST contains `@property` rules with `initial-value`. Collect them into the element's +variable defaults before the class declarations apply. + +This is not optional. A bare `border` emits `border-style: var(--tw-border-style)` and the value +lives only in `@property --tw-border-style { initial-value: solid }`. Without the harvest, +`border`, `shadow-*`, `ring-*` and `space-*` all resolve to nothing. + +### Variant mapping + +Every variant becomes a `Condition`: + +| Tailwind | `Condition` | +|----------------------|----------------------------------------------------| +| `hover:` | `{ kind: "hover" }` | +| `active:` | `{ kind: "active" }` | +| `focus:` | `{ kind: "focus" }` | +| `group-hover/name:` | `{ kind: "group", name, state: "hover" }` | +| `group-active/name:` | `{ kind: "group", name, state: "active" }` | +| `sm: md: lg:` | `{ kind: "media", query }` | +| `max-lg: min-lg:` | `{ kind: "media", query }` | + +An unnamed group uses `""`. + +Flatten `dark:` at resolve time from an `appearance: "dark" | "light"` option. Key the cache by +appearance and clear it when the value flips. + +Warn once and drop: `group-focus:`, `first:`, `last:`, `odd:`, `even:`, `has-`, `peer-*`, +`*`, `**`, `motion-safe:`, `print:`. + +`group-focus:` is dropped for a concrete reason. GPUI has `group_hover` (`div.rs:816`) and +`group_active` (`div.rs:1509`) but no `group_focus`. Supporting it means either tracking focus +state per group in GPUIX, or adding the method upstream. Both are follow-ups. + +`group-hover` is Tailwind's spelling, but the meaning is plain CSS: an ancestor in `:hover` plus +a descendant combinator. GPUI has `group(name)` and `group_hover(name, f)` natively. The +`Condition` names the CSS idea, not the Tailwind one. + +### Reporting unsupported input + +One option, `tolerance: "warn" | "error"`, default `"warn"`. `error` throws at resolve time. + +The resolver always exposes `getUnsupported()`, whatever the tolerance is. It returns both +unknown classes and declarations that no GPUI style can hold, so a test asserts on one thing. + +## Deviations from CSS + +List these in the package README as well. + +- No specificity and no selector engine. Precedence is flat and last write wins. +- `calc()` cannot mix a percentage with a length. +- A gradient has at most two colour stops. No radial or conic gradients. +- No `radial-gradient()`, `conic-gradient()`, `env()`, `attr()` or `image-set()`. +- No container queries. +- No transforms, transitions or CSS animations. Use `motion` instead. +- No `text-decoration`, no `z-index`. +- `border` sets `border-width` only. There is no border style beyond a solid fill. +- Percentage padding, margin and inset work. Percentage border width does not. +- Utilities that need a child or sibling selector do nothing. `space-x-*` and `divide-*` compile + to `:where(.x > :not(:last-child))`, which has no meaning without a selector engine. +- Variant nesting is one level deep. +- `style` keeps `hover` and `active`, which a CSS style attribute cannot express. They predate + this plan. They gain no siblings, and they are candidates for removal in the release that + ships `className`. +- `group-focus` has no GPUI equivalent. `group-hover` and `group-active` do. + +## Tests + +Five tiers. The first three need no GPU. + +### `gpuix-css` value tables + +`cargo test -p gpuix-css`, on any platform, in milliseconds. Table-driven: a CSS value string +in, a `Parsed` out. Cover `calc`, every unit, `color-mix`, gradients, `var()` fallbacks +including the empty one, media conditions, and malformed input. + +Port the existing `color.rs` tests unchanged. They already cover named colours, every colour +function family and relative syntax, so they become the proof that moving from `csscolorparser` +to `lightningcss` changed nothing. + +### Resolution snapshots + +`StyleRefinement` derives `Serialize` and `PartialEq` (`style.rs:178`), so a resolution test is +a value comparison with no window, no view and no GPU: + +```rust +assert_eq!(resolve(&style, &cascade), expected); +``` + +Snapshot the serialized refinement for the wider cases. This is the tier that revision 1 could +not have, because `apply_styles` had no return value. + +Cover: inheritance through three levels, a variable overridden in a subtree, a variable with an +empty fallback, a media condition on both sides of its boundary, and every `Condition` variant. + +### The reconciler + +`computeStyle` is a pure function, so test it directly. Cover the hide and unhide round trip +named in layer 3, className and style precedence, and the token cache under `clsx`-style input. + +### Adapter snapshots and the coverage floor + +Fixture files of class strings by category in `packages/tailwind/test/fixtures/`. Snapshot the +resolver's output, and print the matching `ds.candidatesToCss()` output inside the same snapshot +so a reviewer sees the CSS the mapping came from without running anything. + +Hand-write edge cases: negative values, opacity modifiers, condition merging, `@theme` overrides, +and `text-sm` with `leading-6` in both orders. + +Then walk all 23,337 entries of `ds.getClassList()`, resolve each one, and assert that the +supported fraction never drops below a number committed in the repository. On failure, print +every class that became unsupported. + +This is the test that makes the suite hard to fake. A change that quietly breaks a utility +family fails here even when no snapshot covers it. + +### Pixels + +`comparePixels` pairs through `createTestRoot()`. Cover: a `className` and the equivalent +`style` render the same pixels, an inherited `color` reaches a nested ``, a variable +override changes only its subtree, and a resize crosses a media query boundary. + +## Performance gates + +The reason to use GPUI instead of a browser is speed. A change that makes GPUIX slower has +failed, whatever else it does. + +### Gate on counters, not on durations + +The cache design makes a falsifiable claim: on a frame where nothing changed, zero styles are +resolved. Count the calls and assert the count. + +```rust +assert_eq!(stats.style_resolutions, 0); // steady state +assert_eq!(stats.style_resolutions, 1); // after one setStyle +assert_eq!(stats.style_resolutions, 412); // subtree under a changed variable +``` + +This is deterministic, it runs anywhere, and it fails for the exact reason a wall-clock gate +would be reaching for. It also names which elements re-resolved, which a timing number never +does. + +A 2% wall-clock band on a shared macOS runner is noise. Thermal state and the GPU scheduler move +frame time more than that. Such a gate gets muted, and a muted gate reads as coverage. + +The counter mechanism is half built. `renderer.rs:1085` and `:1105` already expose +`reset_debug_frame_overlay_stats` and `get_debug_frame_overlay_stats`, and there is a commit +titled "Add a chat performance regression test and overlay draw stats." Add one field. + +### Gated + +| Gate | Threshold | +|------|-----------| +| `style_resolutions` per steady-state frame, 10k elements with `var()` styles | exactly 0 | +| `style_resolutions` after one `setStyle` | exactly 1 | +| `style_resolutions` after one root variable change | exactly the subtree size | +| Mount, the existing 10k-row benchmark | within 10% of the baseline | + +### Reported, not gated + +Steady-state frame time, theme-change time and resize time. Record the baseline on `main` +before the first commit, and put both numbers in every pull request body. Read them. Do not +fail the build on them. + +## Follow-ups + +Write these into the specification. Do not build them now. + +- **Declarations as the only input to Rust.** Once Rust owns the mapping, `StyleDesc` in Rust is + a second hand-maintained mirror of CSS. Deleting it and sending declarations for the `style` + prop as well would concentrate the mapping in one place. It is staged out of this branch for + two reasons: the typed serde path is measurably cheaper than parsing CSS text for inline + styles that carry dynamic numbers, and the migration touches every element type. Revisit once + the counters from the performance section exist to measure it honestly. +- **`!important`.** Layer 3 gives `style` a specificity higher than any selector, per + [CSS Style Attributes][css-style-attr]. That leaves a class with no way to override an inline + declaration, because in the cascade an important author declaration is the only thing that + outranks a normal inline one, and an important inline declaration outranks that in turn. This + is not hypothetical: Tailwind's `bg-red-500!` compiles to `!important`, so every such utility + is silently lost against any `style` prop touching the same key. Needs an importance flag on + each declaration and four cascade levels rather than two. Decide before layer 4 ships, because + adding importance later changes which declaration wins and is therefore breaking. +- **Conditions past hover and active.** A resolver returns a `StyleDesc`, which carries those + two and nothing else, so focus, group and media conditions cannot reach an element from a + class. Reaching them means a second napi call carrying the layer 1 `Resolved` shape rather + than a `StyleDesc`. Layer 4 warns and drops them until then. +- Container queries. Needs containment rules to terminate the layout and style loop. +- Layout-time `calc()`. Needs a calc variant on GPUI's `Length` in the zed fork, wired to + taffy's `calc_resolver`. +- Theme resolution for a packaged application with no `node_modules`. +- A Tailwind v3 engine behind the existing interface. +- `group_focus` in GPUI, which would make Tailwind's `group-focus:` resolvable. +- Radial and conic gradients, and gradients with more than two stops. Needs upstream GPUI work. +- Splitting `renderer.rs`. This branch removes the cascade and style application from it. The + remaining 3,000 lines still hold the napi surface, window setup, the GPUI view, virtual lists, + element builders, events and batch parsing. That split is its own piece of work. + +## Delivery + +One branch, `feat/css-values-and-classname`, for the prototype. Split it into sequenced pull +requests once the gates pass. + +Land in this order. The first two carry no new dependency and no behaviour change, so they can +merge to `main` on their own even if the rest slips. + +1. **The resolved style seam.** `resolve()` returns a `StyleRefinement`, cached on + `RetainedElement`, invalidated where `set_style` already compares. Delete `apply_styles`. + Add the `style_resolutions` counter. No new dependency. +2. **The `hideInstance` fix.** Failing test first. It is a real bug today, waiting only for a + second source of style. +3. **The `gpuix-css` crate.** Split it before writing it. Retrofitting a crate seam is the + expensive version. +4. **The `StyleDesc` widening.** Done, minus the conditions. `style` carries declarations only, + so `hover` and `active` gain no siblings and every other condition waits for layer 3. +5. **The cascade.** `cascade.rs`, inheritance, custom properties, media queries. +6. **The `className` seam.** +7. **`@gpuix/tailwind`.** + +Add a changeset. Never edit `CHANGELOG.md` by hand. Never publish locally. diff --git a/packages/native/Cargo.lock b/packages/native/Cargo.lock index 8a0ae8ad..4b1e62f3 100644 --- a/packages/native/Cargo.lock +++ b/packages/native/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -129,6 +129,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -137,6 +148,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -604,6 +616,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -681,6 +702,18 @@ dependencies = [ "core2", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -758,6 +791,28 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.24.0" @@ -1084,6 +1139,21 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1348,15 +1418,35 @@ dependencies = [ ] [[package]] -name = "csscolorparser" -version = "0.8.3" +name = "cssparser" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199f851bd3cb5004c09474252c7f74e7c047441ed0979bf3688a7106a13da952" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ - "num-traits", - "phf", - "serde", - "uncased", + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-color" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa233e1dcd9c13a5d3e3a8a2c0f5a727bac380398345dbcb31db4597edc86b" +dependencies = [ + "cssparser", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.114", ] [[package]] @@ -1385,6 +1475,34 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1514,6 +1632,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dtor" version = "0.1.1" @@ -1952,6 +2085,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.34" @@ -2511,6 +2650,13 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "gpuix-css" +version = "0.1.0" +dependencies = [ + "lightningcss", +] + [[package]] name = "gpuix-native" version = "0.4.0" @@ -2518,12 +2664,12 @@ dependencies = [ "anyhow", "core-graphics 0.24.0", "core-text", - "csscolorparser", "env_logger", "futures", "gpui", "gpui_macos", "gpui_platform", + "gpuix-css", "log", "napi", "napi-build", @@ -2585,6 +2731,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2981,6 +3136,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3170,6 +3334,47 @@ dependencies = [ "libc", ] +[[package]] +name = "lightningcss" +version = "1.0.0-alpha.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d31b760f96e8fdfe1d0c295e4bf76c503d6f15d2d470d53bd8cd1f7aa8c7d934" +dependencies = [ + "ahash 0.8.12", + "bitflags 2.10.0", + "const-str", + "cssparser", + "cssparser-color", + "dashmap", + "data-encoding", + "getrandom 0.3.4", + "indexmap", + "itertools 0.10.5", + "lazy_static", + "lightningcss-derive", + "parcel_selectors", + "parcel_sourcemap", + "pastey", + "pathdiff", + "rayon", + "serde", + "serde-content", + "smallvec", + "static-self", +] + +[[package]] +name = "lightningcss-derive" +version = "1.0.0-alpha.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3324,6 +3529,12 @@ dependencies = [ "libc", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4024,6 +4235,43 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + +[[package]] +name = "parcel_selectors" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05f71e01edca03d245ab0a9f7ce13a974ceb79baaae8faf2ba0b11de6b90913" +dependencies = [ + "bitflags 2.10.0", + "cssparser", + "log", + "phf 0.11.3", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.1", + "smallvec", + "static-self", +] + +[[package]] +name = "parcel_sourcemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" +dependencies = [ + "base64-simd", + "data-url 0.1.1", + "rkyv", + "serde", + "serde_json", + "vlq", +] + [[package]] name = "parking" version = "2.2.1" @@ -4065,6 +4313,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -4109,6 +4363,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -4116,10 +4379,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4127,7 +4410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -4136,12 +4419,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.114", - "uncased", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] @@ -4151,7 +4442,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", - "uncased", ] [[package]] @@ -4320,6 +4610,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -4413,6 +4709,26 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pulldown-cmark" version = "0.12.2" @@ -4493,6 +4809,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -4758,6 +5080,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -4790,6 +5121,35 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5017,6 +5377,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -5164,6 +5533,15 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -5179,6 +5557,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -5329,6 +5713,28 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "static-self" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6635404b73efc136af3a7956e53c53d4f34b2f16c95a15c438929add0f69412" +dependencies = [ + "indexmap", + "smallvec", + "static-self-derive", +] + +[[package]] +name = "static-self-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5268c96d4b907c558a9a52d8492522d6c7b559651a5e1d8f2d551e461b9425d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -5493,6 +5899,17 @@ dependencies = [ "zeno", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.114" @@ -5573,6 +5990,12 @@ dependencies = [ "objc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -6098,15 +6521,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicase" version = "2.9.0" @@ -6198,7 +6612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ "base64", - "data-url", + "data-url 0.3.2", "flate2", "fontdb", "imagesize", @@ -6311,6 +6725,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vlq" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" + [[package]] name = "vswhom" version = "0.1.0" @@ -7204,6 +7624,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -7510,7 +7939,7 @@ name = "zed-xim" version = "0.4.0-zed" source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ - "ahash", + "ahash 0.8.12", "hashbrown 0.14.5", "log", "x11rb", diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index ab9c6135..49916843 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -9,12 +9,14 @@ license = "Apache-2.0" crate-type = ["cdylib", "rlib"] [dependencies] +# CSS value reading, including `calc()`. Kept in its own crate so its tests run +# without a GPU. See `css/`. +gpuix-css = { path = "css" } napi = { version = "3", features = ["napi8", "serde-json"] } napi-derive = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -csscolorparser = "0.8.3" log = "0.4" env_logger = "0.11" parking_lot = "0.12" @@ -65,3 +67,9 @@ path = "examples/hello.rs" [profile.release] lto = true + +# The style parse benchmark. `harness = false` because it prints its own +# numbers rather than running as a test. +[[bench]] +name = "style_parse" +harness = false diff --git a/packages/native/benches/style_parse.rs b/packages/native/benches/style_parse.rs new file mode 100644 index 00000000..3288e63e --- /dev/null +++ b/packages/native/benches/style_parse.rs @@ -0,0 +1,116 @@ +//! How long `setStyle` spends turning JSON into a `StyleDesc`. +//! +//! Every mutation from React lands here, so this is on the path of every +//! update. Run with `cargo bench --bench style_parse`. + +use std::time::Instant; + +use gpuix_native::style::StyleDesc; + +const TYPICAL: &str = r##"{"display":"flex","flexDirection":"row","alignItems":"center","gap":8,"padding":12,"backgroundColor":"#1f2230","borderRadius":6,"borderWidth":1,"borderColor":"#5d6481","color":"#a4accd","fontSize":14}"##; + +const WITH_VARIABLES: &str = r##"{"--brand":"#ff0000","--pad":"8px","display":"flex","padding":"var(--pad)","backgroundColor":"var(--brand)","borderRadius":6,"color":"#a4accd","fontSize":14}"##; + +const SMALL: &str = r##"{"width":40,"height":40}"##; + +fn bench(name: &str, json: &str) { + let rounds = 200_000; + // Warm up, so the first parse does not pay for lazy setup. + for _ in 0..1_000 { + std::hint::black_box(serde_json::from_str::(json).unwrap()); + } + let start = Instant::now(); + for _ in 0..rounds { + std::hint::black_box(serde_json::from_str::(json).unwrap()); + } + let each = start.elapsed().as_secs_f64() / rounds as f64; + println!("{name:16} {:>8.0} ns/parse", each * 1e9); +} + +/// The same shape with no `flatten` and no `untagged`, as a floor to aim at. +/// +/// It drops custom properties and takes numbers only, so it is not a working +/// `StyleDesc`. It exists to show what those two attributes cost. +mod floor { + use serde::Deserialize; + + #[derive(Debug, Default, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Plain { + pub display: Option, + pub flex_direction: Option, + pub align_items: Option, + pub gap: Option, + pub padding: Option, + pub background_color: Option, + pub border_radius: Option, + pub border_width: Option, + pub border_color: Option, + pub color: Option, + pub font_size: Option, + pub width: Option, + pub height: Option, + } +} + +fn bench_floor(name: &str, json: &str) { + let rounds = 200_000; + for _ in 0..1_000 { + std::hint::black_box(serde_json::from_str::(json).unwrap()); + } + let start = Instant::now(); + for _ in 0..rounds { + std::hint::black_box(serde_json::from_str::(json).unwrap()); + } + let each = start.elapsed().as_secs_f64() / rounds as f64; + println!("{name:16} {:>8.0} ns/parse", each * 1e9); +} + +/// How long an empty `StyleDesc` takes to build, with nothing parsed. +/// +/// The reader starts from a default and fills in what it reads, so every parse +/// pays this. `StyleDesc` holds 78 fields, so the write is not free, and the +/// floor struct below has 13 and does not show it. +fn bench_empty() { + let rounds = 200_000; + for _ in 0..1_000 { + std::hint::black_box(StyleDesc::default()); + } + let start = Instant::now(); + for _ in 0..rounds { + std::hint::black_box(StyleDesc::default()); + } + let each = start.elapsed().as_secs_f64() / rounds as f64; + println!("{:16} {:>8.0} ns/parse", "empty", each * 1e9); +} + +/// The same read, into a box. +/// +/// The tree keeps a pointer to a style, not the struct, so this is the read the +/// renderer actually calls. +fn bench_boxed(name: &str, json: &str) { + let rounds = 200_000; + for _ in 0..1_000 { + std::hint::black_box(StyleDesc::from_json_boxed(json).unwrap()); + } + let start = Instant::now(); + for _ in 0..rounds { + std::hint::black_box(StyleDesc::from_json_boxed(json).unwrap()); + } + let each = start.elapsed().as_secs_f64() / rounds as f64; + println!("{name:16} {:>8.0} ns/parse", each * 1e9); +} + +fn main() { + bench("small", SMALL); + bench("typical", TYPICAL); + bench("with variables", WITH_VARIABLES); + println!("--- into a box ---"); + bench_boxed("small", SMALL); + bench_boxed("typical", TYPICAL); + bench_boxed("with variables", WITH_VARIABLES); + println!("--- no flatten, no untagged ---"); + bench_empty(); + bench_floor("small", SMALL); + bench_floor("typical", TYPICAL); +} diff --git a/packages/native/css/Cargo.lock b/packages/native/css/Cargo.lock new file mode 100644 index 00000000..16145e8e --- /dev/null +++ b/packages/native/css/Cargo.lock @@ -0,0 +1,1045 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-color" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa233e1dcd9c13a5d3e3a8a2c0f5a727bac380398345dbcb31db4597edc86b" +dependencies = [ + "cssparser", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gpuix-css" +version = "0.1.0" +dependencies = [ + "lightningcss", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lightningcss" +version = "1.0.0-alpha.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d31b760f96e8fdfe1d0c295e4bf76c503d6f15d2d470d53bd8cd1f7aa8c7d934" +dependencies = [ + "ahash 0.8.12", + "bitflags", + "const-str", + "cssparser", + "cssparser-color", + "dashmap", + "data-encoding", + "getrandom 0.3.4", + "indexmap", + "itertools", + "lazy_static", + "lightningcss-derive", + "parcel_selectors", + "parcel_sourcemap", + "pastey", + "pathdiff", + "rayon", + "serde", + "serde-content", + "smallvec", + "static-self", +] + +[[package]] +name = "lightningcss-derive" +version = "1.0.0-alpha.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + +[[package]] +name = "parcel_selectors" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05f71e01edca03d245ab0a9f7ce13a974ceb79baaae8faf2ba0b11de6b90913" +dependencies = [ + "bitflags", + "cssparser", + "log", + "phf 0.11.3", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "smallvec", + "static-self", +] + +[[package]] +name = "parcel_sourcemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" +dependencies = [ + "base64-simd", + "data-url", + "rkyv", + "serde", + "serde_json", + "vlq", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static-self" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6635404b73efc136af3a7956e53c53d4f34b2f16c95a15c438929add0f69412" +dependencies = [ + "indexmap", + "smallvec", + "static-self-derive", +] + +[[package]] +name = "static-self-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5268c96d4b907c558a9a52d8492522d6c7b559651a5e1d8f2d551e461b9425d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vlq" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/native/css/Cargo.toml b/packages/native/css/Cargo.toml new file mode 100644 index 00000000..133fd3e9 --- /dev/null +++ b/packages/native/css/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "gpuix-css" +version = "0.1.0" +edition = "2021" +description = "CSS value parsing for GPUIX. Knows nothing about GPUI." +license = "Apache-2.0" + +# This crate must not depend on gpui. Its tests are pure CSS value tests, so +# they run on any machine with no GPU, no window and no Metal toolchain. +[dependencies] +lightningcss = { version = "1.0.0-alpha.72", features = ["into_owned"] } diff --git a/packages/native/css/src/color.rs b/packages/native/css/src/color.rs new file mode 100644 index 00000000..e1528f78 --- /dev/null +++ b/packages/native/css/src/color.rs @@ -0,0 +1,251 @@ +//! Colour values for GPUIX. +//! +//! One CSS colour string becomes four channels. This module knows nothing +//! about GPUI, so its tests run with no GPU and no Metal toolchain. +//! +//! lightningcss reads every colour syntax in CSS Color 4 and CSS Color 5, +//! including `color-mix()`, `oklch()` and relative colour syntax. Three colours +//! it cannot finish on its own, because each one reads something only the +//! engine knows. `currentColor` reads the computed `color` of the element. +//! `light-dark()` reads the appearance of the window. A system colour reads the +//! platform palette. Those three arrive here as their own variants, and +//! `ColorContext` supplies what they need. + +use lightningcss::traits::Parse; +use lightningcss::values::color::{ColorSpace, CssColor, SRGB}; + +use crate::CssError; + +/// An sRGB colour with straight alpha. Every channel runs from 0 to 1. +/// +/// The engine defines its own colour type so that nothing here depends on the +/// renderer. `gpuix-native` converts this into `gpui::Rgba` at the edge. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rgba { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Rgba { + pub const BLACK: Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + pub const TRANSPARENT: Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }; +} + +/// What a colour needs from the element and the window to finish. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColorContext { + /// The computed `color` of this element, which `currentColor` reads. + pub current_color: Rgba, + /// Whether the window is in the dark appearance, which `light-dark()` + /// reads. + pub dark: bool, +} + +impl Default for ColorContext { + fn default() -> Self { + Self { current_color: Rgba::BLACK, dark: false } + } +} + +/// A colour, and what it needed from the context to finish. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Reading { + pub color: Rgba, + /// Whether the value read `currentColor` anywhere inside it. + /// + /// The resolved-style cache needs this. A colour that reads the inherited + /// colour stops being valid when an ancestor changes it, and a colour that + /// does not stays valid forever. Reporting it here beats making the caller + /// search the text, because `currentColor` nests. + pub read_current_color: bool, +} + +/// Read one colour. +pub fn color(value: &str, context: &ColorContext) -> Result { + read(value, context).map(|reading| reading.color) +} + +/// Read one colour and report what it needed. +pub fn read(value: &str, context: &ColorContext) -> Result { + let parsed = CssColor::parse_string(value).map_err(|_| CssError::BadValue { + property: "color".to_string(), + value: value.to_string(), + })?; + Ok(Reading { + color: resolve(&parsed, context)?, + read_current_color: reads_current_color(&parsed), + }) +} + +/// Whether a colour reads `currentColor` at any depth. +/// +/// `light-dark()` holds two colours and `color-mix()` holds two more, and the +/// keyword is legal inside any of them, so this walks rather than matching one +/// level. Only the side of `light-dark()` the appearance selects counts, +/// because the other side never reaches paint. +pub fn reads_current_color(parsed: &CssColor) -> bool { + match parsed { + CssColor::CurrentColor => true, + CssColor::LightDark(light, dark) => { + reads_current_color(light) || reads_current_color(dark) + } + _ => false, + } +} + +/// Turn a colour lightningcss already read into channels. +/// +/// The engine calls this once it holds a `Property`, so the string never gets +/// parsed twice. +pub fn resolve(parsed: &CssColor, context: &ColorContext) -> Result { + match parsed { + CssColor::CurrentColor => Ok(context.current_color), + CssColor::LightDark(light, dark) => { + resolve(if context.dark { dark } else { light }, context) + } + CssColor::System(system) => Err(CssError::Unsupported { + feature: "system colour".to_string(), + value: format!("{system:?}"), + }), + other => { + // sRGB rather than RGBA, because RGBA holds 8-bit channels and + // would quantise alpha. An alpha of 0.5 comes back as 128/255. + // GPUI blends in f32, so that rounding buys nothing. + let srgb = SRGB::try_from(other).map_err(|_| CssError::BadValue { + property: "color".to_string(), + value: String::new(), + })?; + // `none` arrives as NaN, which the specification treats as zero + // outside interpolation. Anything wider than sRGB has to land + // inside it, because that is what GPUI paints. + let srgb = srgb.resolve_missing(); + Ok(Rgba { + r: srgb.r.clamp(0.0, 1.0), + g: srgb.g.clamp(0.0, 1.0), + b: srgb.b.clamp(0.0, 1.0), + a: srgb.alpha.clamp(0.0, 1.0), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(value: &str) -> Rgba { + color(value, &ColorContext::default()) + .unwrap_or_else(|error| panic!("did not read `{value}`: {error}")) + } + + fn assert_same(input: &str, expected: &str) { + let actual = ok(input); + let expected = ok(expected); + for (actual, expected) in [actual.r, actual.g, actual.b, actual.a] + .into_iter() + .zip([expected.r, expected.g, expected.b, expected.a]) + { + assert!((actual - expected).abs() <= 1.0 / 255.0, "{input}"); + } + } + + #[test] + fn reads_every_absolute_syntax() { + assert_same("#ff0000", "rgb(255, 0, 0)"); + assert_same("#f00", "rgb(255, 0, 0)"); + assert_same("#ff0000ff", "rgb(255, 0, 0)"); + assert_same("red", "rgb(255, 0, 0)"); + assert_same("hsl(0, 100%, 50%)", "rgb(255, 0, 0)"); + assert_same("hwb(0 0% 0%)", "rgb(255, 0, 0)"); + assert_same("rgb(255 0 0 / 50%)", "rgba(255, 0, 0, 0.5)"); + } + + #[test] + fn reads_the_wide_gamut_syntaxes() { + // csscolorparser could read these too. The reason to move is what + // follows in the next three tests, not this one. + for value in [ + "lab(50% 40 30)", + "lch(50% 40 30)", + "oklab(0.5 0.1 0.1)", + "oklch(0.637 0.237 25.331)", + "color(display-p3 1 0 0)", + ] { + assert!(color(value, &ColorContext::default()).is_ok(), "{value}"); + } + } + + #[test] + fn mixes_two_colours() { + // Tailwind opacity modifiers such as `bg-red-500/50` emit color-mix, + // which csscolorparser cannot read at all. + assert_same( + "color-mix(in srgb, #ff0000 100%, #0000ff 0%)", + "#ff0000", + ); + } + + #[test] + fn reads_current_color_from_the_element() { + let context = ColorContext { + current_color: Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + dark: false, + }; + assert_eq!(color("currentColor", &context).unwrap(), context.current_color); + } + + #[test] + fn picks_the_side_of_light_dark_that_matches_the_window() { + let light = ColorContext { dark: false, ..ColorContext::default() }; + let dark = ColorContext { dark: true, ..ColorContext::default() }; + let value = "light-dark(#ff0000, #0000ff)"; + assert_eq!(color(value, &light).unwrap(), ok("#ff0000")); + assert_eq!(color(value, &dark).unwrap(), ok("#0000ff")); + } + + #[test] + fn resolves_current_color_inside_a_nested_function() { + // `light-dark()` may hold `currentColor` on either side, so resolving + // has to recurse rather than match one level. + let context = ColorContext { + current_color: Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }, + dark: true, + }; + let got = color("light-dark(#ff0000, currentColor)", &context).unwrap(); + assert_eq!(got, context.current_color); + } + + #[test] + fn reports_a_system_colour_rather_than_guessing() { + // The platform palette is not wired up. Failing loudly beats painting + // a colour the operating system did not choose. + assert!(matches!( + color("ButtonFace", &ColorContext::default()), + Err(CssError::Unsupported { .. }) + )); + } + + #[test] + fn reports_whether_a_colour_read_the_element() { + let context = ColorContext::default(); + assert!(read("currentColor", &context).unwrap().read_current_color); + assert!( + read("light-dark(#ff0000, currentColor)", &context) + .unwrap() + .read_current_color, + "nested inside light-dark" + ); + assert!(!read("#ff0000", &context).unwrap().read_current_color); + assert!(!read("oklch(0.5 0.1 30)", &context).unwrap().read_current_color); + } + + #[test] + fn reports_a_value_that_is_not_a_colour() { + assert!(matches!( + color("definitely-not-a-colour", &ColorContext::default()), + Err(CssError::BadValue { .. }) + )); + } +} diff --git a/packages/native/css/src/length.rs b/packages/native/css/src/length.rs new file mode 100644 index 00000000..75549669 --- /dev/null +++ b/packages/native/css/src/length.rs @@ -0,0 +1,282 @@ +//! Reading a CSS length, including `calc()`. +//! +//! lightningcss folds an arithmetic expression while it parses, so +//! `calc(8px + 2px)` arrives here as `10px` and `min(4px, 8px)` as `4px`. This +//! module maps what comes back onto the three shapes GPUIX can use, and +//! converts `rem` with the root font size the caller passes in. +//! +//! `rem` is converted to pixels before the text reaches the parser. lightningcss +//! holds `rem` as a relative unit and will not add it to a `px`, so +//! `calc(1rem + 4px)` would come back unfolded. Rewriting it to `calc(16px + +//! 4px)` first leaves one absolute unit and lets the parser fold the whole +//! expression. +//! +//! An expression that mixes a percentage with an absolute length, such as +//! `calc(100% - 8px)`, cannot fold without doing layout first. GPUI has no +//! length type that carries an unfolded expression, so those return `None` and +//! the declaration drops. That is the one case this cannot finish, and adding +//! it needs a calc variant on GPUI's own `Length`. + +use lightningcss::traits::Parse; +use lightningcss::values::length::LengthValue; +use lightningcss::values::percentage::DimensionPercentage; + +/// A length folded as far as it goes without layout. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Length { + /// An absolute length, in pixels. + Pixels(f32), + /// A fraction of a value the layout decides, from a percentage. + Fraction(f32), + /// A number with no unit, such as the `1.5` in `line-height: 1.5`. + Number(f32), +} + +/// Read a CSS length. `rem` is the root font size in pixels. +/// +/// Handles `calc()`, `min()`, `max()`, `clamp()` and every absolute unit, plus +/// `rem`. Returns `None` for a value that needs layout to finish and for a unit +/// this does not know. +pub fn length(text: &str, rem: f32) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + + // A bare number first. lightningcss reads `1.5` as `1.5px`, which is the + // quirks-mode rule for a length, and CSS gives a unitless number its own + // meaning in `line-height` and in `opacity`. Reading it here keeps that + // meaning and skips the parser for the shape most declarations arrive in. + if let Ok(number) = text.parse::() { + return Some(Length::Number(number)); + } + // `8px` is the next most common shape, and stripping the suffix beats + // running the parser over it. + if let Some(pixels) = text + .strip_suffix("px") + .and_then(|number| number.trim_end().parse::().ok()) + { + return Some(Length::Pixels(pixels)); + } + + let converted = expand_rem(text, rem); + match DimensionPercentage::::parse_string(&converted).ok()? { + DimensionPercentage::Dimension(value) => absolute(&value, rem), + DimensionPercentage::Percentage(percentage) => Some(Length::Fraction(percentage.0)), + // The expression did not fold, so it holds a percentage next to an + // absolute length and only layout can finish it. + DimensionPercentage::Calc(_) => None, + } +} + +/// One folded dimension in pixels, where the unit allows it. +fn absolute(value: &LengthValue, rem: f32) -> Option { + if let Some(pixels) = value.to_px() { + return Some(Length::Pixels(pixels)); + } + match value { + // `expand_rem` handles this before the parser sees it. A `rem` still + // reaching here came from a place that scan does not cover, so convert + // it rather than drop it. + LengthValue::Rem(value) => Some(Length::Pixels(value * rem)), + // `em`, `ex` and `ch` need the element's own font size, and the + // viewport units need the window. Neither is here yet. + _ => None, + } +} + +/// `text` with every `rem` length written as pixels. +/// +/// Borrows when there is no `rem` in it, which is most values. A match has to +/// be a whole token: the `rem` in `2rems` is not a unit, and neither is the one +/// in `--border-rem`. +fn expand_rem(text: &str, rem: f32) -> std::borrow::Cow<'_, str> { + if !text.contains("rem") { + return std::borrow::Cow::Borrowed(text); + } + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut at = 0; + let mut copied = 0; + while let Some(offset) = text[at..].find("rem") { + let start = at + offset; + let end = start + "rem".len(); + at = end; + // Anything word-like after it means this is not the unit `rem`. + if bytes.get(end).is_some_and(|c| { + c.is_ascii_alphanumeric() || *c == b'-' || *c == b'_' || *c == b'%' || *c == b'.' + }) { + continue; + } + let Some(number_at) = number_start(text, start) else { + continue; + }; + let Ok(number) = text[number_at..start].parse::() else { + continue; + }; + out.push_str(&text[copied..number_at]); + out.push_str(&format!("{}px", number * rem)); + copied = end; + } + if copied == 0 { + return std::borrow::Cow::Borrowed(text); + } + out.push_str(&text[copied..]); + std::borrow::Cow::Owned(out) +} + +/// Where the number that a unit at `unit_at` belongs to starts. +/// +/// `None` when there is no number there, or when what runs up to the unit is +/// part of a longer word such as `--my-rem`. +fn number_start(text: &str, unit_at: usize) -> Option { + let bytes = text.as_bytes(); + let mut start = unit_at; + while start > 0 { + let before = bytes[start - 1]; + if before.is_ascii_digit() || before == b'.' { + start -= 1; + continue; + } + if (before == b'-' || before == b'+') && start - 1 == first_of_token(bytes, start - 1) { + start -= 1; + } + break; + } + if start == unit_at { + return None; + } + // A digit run that follows a letter is part of a word, not a number. + if start > 0 && (bytes[start - 1].is_ascii_alphabetic() || bytes[start - 1] == b'_') { + return None; + } + Some(start) +} + +/// Whether `at` can start a signed number, meaning nothing word-like precedes it. +fn first_of_token(bytes: &[u8], at: usize) -> usize { + if at == 0 { + return at; + } + match bytes[at - 1] { + c if c.is_ascii_alphanumeric() || c == b'_' || c == b'%' || c == b')' => usize::MAX, + _ => at, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REM: f32 = 16.0; + + fn read(text: &str) -> Option { + length(text, REM) + } + + #[test] + fn a_bare_number_keeps_its_own_meaning() { + // Not 1.5 pixels. `line-height: 1.5` means one and a half times the + // font size, and lightningcss would read this as a quirks-mode length. + assert_eq!(read("1.5"), Some(Length::Number(1.5))); + assert_eq!(read("0"), Some(Length::Number(0.0))); + assert_eq!(read("-2"), Some(Length::Number(-2.0))); + } + + #[test] + fn pixels_read_as_pixels() { + assert_eq!(read("8px"), Some(Length::Pixels(8.0))); + assert_eq!(read(" 8px "), Some(Length::Pixels(8.0))); + assert_eq!(read("-1.5px"), Some(Length::Pixels(-1.5))); + } + + #[test] + fn a_percentage_reads_as_a_fraction() { + assert_eq!(read("150%"), Some(Length::Fraction(1.5))); + assert_eq!(read("50%"), Some(Length::Fraction(0.5))); + } + + #[test] + fn rem_converts_with_the_root_font_size() { + assert_eq!(read("2rem"), Some(Length::Pixels(32.0))); + assert_eq!(length("2rem", 10.0), Some(Length::Pixels(20.0))); + } + + #[test] + fn the_other_absolute_units_convert_too() { + assert_eq!(read("1in"), Some(Length::Pixels(96.0))); + assert_eq!(read("12pt"), Some(Length::Pixels(16.0))); + } + + #[test] + fn calc_folds_arithmetic() { + assert_eq!(read("calc(8px + 2px)"), Some(Length::Pixels(10.0))); + assert_eq!(read("calc(8px - 2px)"), Some(Length::Pixels(6.0))); + assert_eq!(read("calc(8px / 2)"), Some(Length::Pixels(4.0))); + assert_eq!(read("calc(4px * 3)"), Some(Length::Pixels(12.0))); + } + + #[test] + fn calc_folds_the_tailwind_spacing_shape() { + // Tailwind's whole spacing scale is `calc(var(--spacing) * n)`, and + // `--spacing` is `0.25rem`. `var()` is already substituted by the time + // this runs. + assert_eq!(read("calc(0.25rem * 6)"), Some(Length::Pixels(24.0))); + assert_eq!(read("calc(0.25rem * 1)"), Some(Length::Pixels(4.0))); + } + + #[test] + fn calc_mixes_units() { + assert_eq!(read("calc(1rem + 4px)"), Some(Length::Pixels(20.0))); + } + + #[test] + fn min_max_and_clamp_fold_too() { + assert_eq!(read("min(4px, 8px)"), Some(Length::Pixels(4.0))); + assert_eq!(read("max(4px, 8px)"), Some(Length::Pixels(8.0))); + assert_eq!(read("clamp(2px, 8px, 4px)"), Some(Length::Pixels(4.0))); + } + + #[test] + fn a_percentage_next_to_a_length_cannot_fold() { + // Only layout knows what the percentage is a percentage of, and GPUI + // has no length that carries the unfinished expression. + assert_eq!(read("calc(100% - 8px)"), None); + } + + #[test] + fn a_unit_this_does_not_know_reads_as_nothing() { + assert_eq!(read("2em"), None); + assert_eq!(read("10vw"), None); + } + + #[test] + fn nonsense_reads_as_nothing() { + assert_eq!(read(""), None); + assert_eq!(read(" "), None); + assert_eq!(read("not-a-length"), None); + assert_eq!(read("calc(8px +)"), None); + } + + #[test] + fn rem_next_to_another_unit_still_folds() { + assert_eq!(read("calc(1rem + 4px)"), Some(Length::Pixels(20.0))); + assert_eq!(read("calc(2rem - 1rem)"), Some(Length::Pixels(16.0))); + assert_eq!(read("max(1rem, 20px)"), Some(Length::Pixels(20.0))); + } + + #[test] + fn a_word_that_ends_in_rem_is_not_a_unit() { + assert_eq!(expand_rem("2rems", REM), "2rems"); + assert_eq!(expand_rem("theorem", REM), "theorem"); + assert_eq!(expand_rem("var(--my-rem)", REM), "var(--my-rem)"); + assert_eq!(expand_rem("8px", REM), "8px"); + } + + #[test] + fn rem_conversion_keeps_the_rest_of_the_text() { + assert_eq!(expand_rem("calc(1rem + 4px)", REM), "calc(16px + 4px)"); + assert_eq!(expand_rem("calc(-1rem)", REM), "calc(-16px)"); + assert_eq!(expand_rem("clamp(1rem, 2rem, 3rem)", REM), "clamp(16px, 32px, 48px)"); + } +} diff --git a/packages/native/css/src/lib.rs b/packages/native/css/src/lib.rs new file mode 100644 index 00000000..6486c8ce --- /dev/null +++ b/packages/native/css/src/lib.rs @@ -0,0 +1,318 @@ +//! CSS values for GPUIX. +//! +//! This crate turns a CSS declaration, one property name and one value string, +//! into a parsed value. It knows nothing about GPUI, so its tests are pure +//! value tests that run on any machine with no GPU and no Metal toolchain. +//! +//! A value that holds `var()` cannot finish here, because the variables it +//! reads live on the element and its ancestors. Such a value comes back as +//! `Parsed::Pending`, and the cascade finishes it later with `substitute`. + +pub mod color; +pub mod length; + +use std::collections::HashMap; + +use lightningcss::properties::custom::{TokenList, TokenOrValue}; +use lightningcss::properties::{Property, PropertyId}; +use lightningcss::stylesheet::ParserOptions; +use lightningcss::traits::IntoOwned; + +/// Custom property values in scope, keyed without the leading dashes. +pub type Vars = HashMap; + +/// A value that still holds one or more `var()` references. +#[derive(Debug, Clone, PartialEq)] +pub struct Unparsed { + property: String, + value: String, +} + +/// The result of reading one CSS declaration. +#[derive(Debug, Clone, PartialEq)] +pub enum Parsed { + /// The value is complete. + Ready(Property<'static>), + /// The value reads a custom property, so the cascade has to finish it. + Pending(Unparsed), +} + +/// Why a declaration could not be read. +#[derive(Debug, Clone, PartialEq)] +pub enum CssError { + /// The property name is not one this build knows. + UnknownProperty { property: String }, + /// The property is known but the value does not fit it. + BadValue { property: String, value: String }, + /// A `var()` reference has no value and no fallback. + MissingVariable { property: String, name: String }, + /// The value is valid CSS that this build cannot finish. + Unsupported { feature: String, value: String }, +} + +impl std::fmt::Display for CssError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CssError::UnknownProperty { property } => { + write!(f, "unknown CSS property `{property}`") + } + CssError::BadValue { property, value } => { + write!(f, "`{value}` is not a valid value for `{property}`") + } + CssError::MissingVariable { property, name } => { + write!(f, "`{property}` reads `--{name}`, which has no value") + } + CssError::Unsupported { feature, value } => { + write!(f, "`{value}` needs {feature}, which this build does not read") + } + } + } +} + +impl std::error::Error for CssError {} + +/// Read one CSS declaration. +pub fn parse(property: &str, value: &str) -> Result { + let id = PropertyId::from(property); + if matches!(id, PropertyId::Custom(_)) && !property.starts_with("--") { + return Err(CssError::UnknownProperty { + property: property.to_string(), + }); + } + + let parsed = Property::parse_string(id, value, ParserOptions::default()).map_err(|_| { + CssError::BadValue { + property: property.to_string(), + value: value.to_string(), + } + })?; + + // lightningcss keeps anything it cannot fold as `Unparsed`, so this covers + // two different cases. A value holding `var()` is pending, because the + // variables live on the element and its ancestors. A value with no `var()` + // is one lightningcss could not read at all, which means it is wrong. + if let Property::Unparsed(held) = &parsed { + if !reads_a_variable(&held.value) { + return Err(CssError::BadValue { + property: property.to_string(), + value: value.to_string(), + }); + } + return Ok(Parsed::Pending(Unparsed { + property: property.to_string(), + value: value.to_string(), + })); + } + + Ok(Parsed::Ready(parsed.into_owned())) +} + +/// Finish a value that reads custom properties. +pub fn substitute(unparsed: &Unparsed, vars: &Vars) -> Result { + let substituted = expand_vars(&unparsed.value, vars, &unparsed.property)?; + parse(&unparsed.property, &substituted) +} + +/// Whether a held value reads a custom property anywhere inside it. +/// +/// `var()` nests, as in `calc(var(--spacing) * 4)`, so this walks the whole +/// token tree instead of only its top level. +fn reads_a_variable(tokens: &TokenList) -> bool { + tokens.0.iter().any(|token| match token { + TokenOrValue::Var(_) => true, + TokenOrValue::Function(function) => reads_a_variable(&function.arguments), + _ => false, + }) +} + +/// Replace every `var(--name, fallback)` with the value in scope. +fn expand_vars(value: &str, vars: &Vars, property: &str) -> Result { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + + while let Some(start) = rest.find("var(") { + out.push_str(&rest[..start]); + let after = &rest[start + 4..]; + let end = match_paren(after).ok_or_else(|| CssError::BadValue { + property: property.to_string(), + value: value.to_string(), + })?; + let inner = &after[..end]; + + let (name, fallback) = match inner.find(',') { + Some(comma) => (inner[..comma].trim(), Some(inner[comma + 1..].trim())), + None => (inner.trim(), None), + }; + let key = name.trim_start_matches("--"); + + match vars.get(key) { + Some(found) => out.push_str(found), + None => match fallback { + // A fallback may itself read a variable. + Some(fallback) => out.push_str(&expand_vars(fallback, vars, property)?), + None => { + return Err(CssError::MissingVariable { + property: property.to_string(), + name: key.to_string(), + }) + } + }, + } + + rest = &after[end + 1..]; + } + + out.push_str(rest); + Ok(out) +} + +/// Byte offset of the `)` that closes the group `input` starts inside. +fn match_paren(input: &str) -> Option { + let mut depth = 0usize; + for (index, byte) in input.bytes().enumerate() { + match byte { + b'(' => depth += 1, + b')' if depth == 0 => return Some(index), + b')' => depth -= 1, + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vars(pairs: &[(&str, &str)]) -> Vars { + pairs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + } + + fn ready(property: &str, value: &str) -> Property<'static> { + match parse(property, value) { + Ok(Parsed::Ready(property)) => property, + other => panic!("expected a complete value, got {other:?}"), + } + } + + fn pending(property: &str, value: &str) -> Unparsed { + match parse(property, value) { + Ok(Parsed::Pending(unparsed)) => unparsed, + other => panic!("expected a pending value, got {other:?}"), + } + } + + #[test] + fn reads_a_plain_length() { + assert!(matches!(parse("padding", "4px"), Ok(Parsed::Ready(_)))); + } + + #[test] + fn reads_a_calc_with_no_variables() { + assert!(matches!( + parse("width", "calc(100% - 2rem)"), + Ok(Parsed::Ready(_)) + )); + } + + #[test] + fn holds_a_value_that_reads_a_variable() { + let held = pending("padding", "calc(var(--spacing) * 4)"); + let done = substitute(&held, &vars(&[("spacing", "0.25rem")])).unwrap(); + assert_eq!(done, parse("padding", "calc(0.25rem * 4)").unwrap()); + } + + #[test] + fn reads_the_tailwind_default_palette() { + // Tailwind v4 emits its palette in oklch, not in hex. + let held = pending("color", "var(--color-red-500)"); + let done = substitute(&held, &vars(&[("color-red-500", "oklch(0.637 0.237 25.331)")])); + assert!(matches!(done, Ok(Parsed::Ready(_)))); + } + + #[test] + fn uses_the_fallback_when_the_variable_has_no_value() { + // `text-sm` emits `line-height: var(--tw-leading, ...)`, and the + // element only sets `--tw-leading` when a `leading-*` class is present. + let held = pending("line-height", "var(--tw-leading, 1.25)"); + let done = substitute(&held, &vars(&[])).unwrap(); + assert_eq!(done, parse("line-height", "1.25").unwrap()); + } + + #[test] + fn prefers_the_variable_over_the_fallback() { + let held = pending("line-height", "var(--tw-leading, 1.25)"); + let done = substitute(&held, &vars(&[("tw-leading", "2")])).unwrap(); + assert_eq!(done, parse("line-height", "2").unwrap()); + } + + #[test] + fn reads_a_fallback_that_reads_another_variable() { + let held = pending("color", "var(--a, var(--b, #ff0000))"); + let done = substitute(&held, &vars(&[("b", "#00ff00")])).unwrap(); + assert_eq!(done, parse("color", "#00ff00").unwrap()); + } + + #[test] + fn reports_a_variable_with_no_value_and_no_fallback() { + let held = pending("padding", "var(--nothing)"); + assert_eq!( + substitute(&held, &vars(&[])), + Err(CssError::MissingVariable { + property: "padding".to_string(), + name: "nothing".to_string(), + }) + ); + } + + #[test] + fn reports_a_property_it_does_not_know() { + assert_eq!( + parse("not-a-property", "1px"), + Err(CssError::UnknownProperty { + property: "not-a-property".to_string(), + }) + ); + } + + #[test] + fn reports_a_value_that_does_not_fit_the_property() { + assert_eq!( + parse("width", "definitely-not-a-width"), + Err(CssError::BadValue { + property: "width".to_string(), + value: "definitely-not-a-width".to_string(), + }) + ); + } + + #[test] + fn keeps_a_custom_property() { + assert!(parse("--spacing", "0.25rem").is_ok()); + } + + #[test] + fn reads_a_colour_that_mixes_two_colours() { + // Tailwind opacity modifiers such as `bg-red-500/50` emit color-mix. + assert!(matches!( + parse( + "background-color", + "color-mix(in oklab, oklch(0.637 0.237 25.331) 50%, transparent)" + ), + Ok(Parsed::Ready(_)) + )); + } + + #[test] + fn the_resolved_value_does_not_borrow_the_input() { + // `Parsed::Ready` owns its value, so the caller can cache it. + let held = { + let value = String::from("8px"); + ready("padding", &value) + }; + assert_eq!(Parsed::Ready(held), parse("padding", "8px").unwrap()); + } +} diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index e8196b00..30438153 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -140,6 +140,18 @@ export declare class TestGpuixRenderer { * In tests, this is a no-op — flush() handles the actual re-render. */ commitMutations(): void + /** + * How many styles the renderer has resolved since the last reset. + * + * The performance tests read this instead of measuring wall-clock time. + * GPUI rebuilds its element tree every frame, so the number that matters + * is how much of that rebuild repeats work the renderer already did. A + * frame that changes nothing must add nothing here. A wall-clock budget + * flakes on a loaded machine, and a flaky gate gets muted. + */ + styleResolutions(): number + /** Set the style resolution counter back to zero. */ + resetStyleResolutions(): void /** * Apply a batch of mutations in a single FFI call. * Same format as GpuixRenderer::apply_batch (string op names). diff --git a/packages/native/src/color.rs b/packages/native/src/color.rs index ab9f8b45..e13f44c2 100644 --- a/packages/native/src/color.rs +++ b/packages/native/src/color.rs @@ -1,13 +1,49 @@ -/// Parse any color accepted by csscolorparser 0.8.3 into GPUI's sRGB paint type. -/// Out-of-gamut channels are hard-clipped because GPUI paints sRGB Rgba/Hsla. +//! The GPUI edge for colour. +//! +//! `gpuix-css` reads a colour string into `gpuix_css::color::Rgba`, which is +//! four channels and nothing else. This file is the only place that turns those +//! channels into a GPUI paint type. Keeping the conversion here is what lets +//! the colour tests in `gpuix-css` run with no GPU. + +use gpuix_css::color::{ColorContext, Rgba}; + +/// Turn engine channels into GPUI's sRGB paint type. +pub(crate) fn to_gpui(color: Rgba) -> gpui::Rgba { + gpui::Rgba { r: color.r, g: color.g, b: color.b, a: color.a } +} + +/// Turn a GPUI colour into engine channels. +/// +/// The theme is still written in GPUI types, so a theme colour crosses back +/// this way to reach the cascade. +pub(crate) fn from_gpui(color: impl Into) -> Rgba { + let color = color.into(); + Rgba { r: color.r, g: color.g, b: color.b, a: color.a } +} + +/// Turn engine channels into GPUI's HSL paint type. +/// +/// GPUI takes `Hsla` in most style setters, so this is the shape the style +/// sink needs most often. +pub(crate) fn to_hsla(color: Rgba) -> gpui::Hsla { + to_gpui(color).into() +} + +/// Read a colour that depends on the element or the window. +/// +/// `currentColor` and `light-dark()` both need context, so this is the entry +/// point the cascade uses once it knows the computed `color` and the window +/// appearance. +pub(crate) fn parse_color_in(value: &str, context: &ColorContext) -> Option { + gpuix_css::color::color(value, context).ok().map(to_gpui) +} + +/// Read a colour that stands on its own. +/// +/// Callers that hold no cascade get the default context, where `currentColor` +/// is black and the appearance is light. pub(crate) fn parse_color_rgba(value: &str) -> Option { - let parsed = csscolorparser::parse(value).ok()?.clamp(); - Some(gpui::Rgba { - r: parsed.r, - g: parsed.g, - b: parsed.b, - a: parsed.a, - }) + parse_color_in(value, &ColorContext::default()) } /// Compatibility helper kept at the gpuix-native crate root. @@ -37,9 +73,11 @@ mod tests { #[test] fn parses_every_absolute_function_family() { + // `hsv()`, `hsva()` and `hwba()` used to appear in this list. No CSS + // specification defines any of them. They came from csscolorparser, + // which this crate no longer uses. let cases = [ ("#f00f", "#ff0000ff"), - ("ff0000ff", "#ff0000ff"), ("rebeccapurple", "#663399"), ("transparent", "#00000000"), ("rgb(255 0 0)", "#ff0000"), @@ -47,9 +85,6 @@ mod tests { ("hsl(0 100% 50%)", "#ff0000"), ("hsla(0, 100%, 50%, 1)", "#ff0000"), ("hwb(0 0% 0%)", "#ff0000"), - ("hwba(0, 0%, 0%, 1)", "#ff0000"), - ("hsv(0 100% 100%)", "#ff0000"), - ("hsva(0, 100%, 100%, 1)", "#ff0000"), ("lab(100% 0 0)", "#ffffff"), ("lch(100% 0 0)", "#ffffff"), ("oklab(0.62796 0.22486 0.12585)", "#ff0000"), @@ -64,15 +99,16 @@ mod tests { #[test] fn parses_alpha_in_every_function_family() { + // lightningcss keeps `rgb()`, `hsl()` and `hwb()` in an 8-bit RGBA, + // so an alpha of 50% comes back as 128/255. The wider colour spaces + // below keep the exact float. One step of 8-bit alpha is the tolerance + // every other test in this file already uses. let cases = [ "rgb(0 0 0 / 50%)", "rgba(0, 0, 0, 0.5)", "hsl(0 0% 0% / 50%)", "hsla(0, 0%, 0%, 0.5)", "hwb(0 0% 100% / 50%)", - "hwba(0, 0%, 100%, 0.5)", - "hsv(0 0% 0% / 50%)", - "hsva(0, 0%, 0%, 0.5)", "lab(0% 0 0 / 50%)", "lch(0% 0 0 / 50%)", "oklab(0 0 0 / 50%)", @@ -82,7 +118,7 @@ mod tests { for input in cases { let (_, _, _, alpha) = parse_color(input).unwrap_or_else(|| panic!("did not parse {input}")); - assert!((alpha - 0.5).abs() < f32::EPSILON, "{input}"); + assert!((alpha - 0.5).abs() <= 1.0 / 255.0, "{input}"); } } @@ -92,7 +128,6 @@ mod tests { ("rgb(from #bad455 b r g / alpha)", "#55bad4"), ("hsl(from #bad455 h s l / alpha)", "#bad455"), ("hwb(from #bad455 h w b / alpha)", "#bad455"), - ("hsv(from #bad455 h s v / alpha)", "#bad455"), ("lab(from #bad455 l a b / alpha)", "#bad455"), ("lch(from #bad455 l c h / alpha)", "#bad455"), ("oklab(from #bad455 calc(l * 0.7) a b)", "#708500"), @@ -115,6 +150,15 @@ mod tests { } } + #[test] + fn reads_the_syntaxes_csscolorparser_could_not() { + // Both are CSS Color specifications, and both used to come back as + // `None`. Tailwind emits color-mix for every opacity modifier such as + // `bg-red-500/50`. + assert!(parse_color("color(display-p3 1 0 0)").is_some()); + assert_same("color-mix(in srgb, #ff0000 100%, #0000ff 0%)", "#ff0000"); + } + #[test] fn rejects_values_outside_the_parser_contract() { for input in [ @@ -122,7 +166,10 @@ mod tests { "reddish", "#gg0000", "hsl(nope)", - "color(display-p3 1 0 0)", + // Bare hex with no `#` is not a CSS colour. + "ff0000ff", + // `hsv()` is not a CSS colour function. + "hsv(0 100% 100%)", ] { assert_eq!(parse_color_hex(input), None, "{input}"); } @@ -136,6 +183,18 @@ mod tests { } } + #[test] + fn reads_current_color_from_the_context() { + let context = ColorContext { + current_color: Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + dark: false, + }; + assert_eq!( + parse_color_in("currentColor", &context), + parse_color_rgba("#ff0000") + ); + } + #[test] fn compatibility_helpers_share_the_same_result() { let rgba = parse_color_rgba("oklch(0.62796 0.25768 29.23388 / 50%)").unwrap(); diff --git a/packages/native/src/custom_elements/anchored.rs b/packages/native/src/custom_elements/anchored.rs index b5ee765a..c069ba44 100644 --- a/packages/native/src/custom_elements/anchored.rs +++ b/packages/native/src/custom_elements/anchored.rs @@ -290,9 +290,7 @@ impl CustomElement for AnchoredElement { use gpui::prelude::*; let mut content = gpui::div().flex_col(); - if let Some(style) = ctx.style { - content = crate::renderer::apply_styles(content, style); - } + content = ctx.styled(content); // Deferred overlays paint over the window blur. A missing fill lets the // page show through the card. Force an opaque surface when JS omitted one. let has_fill = ctx.style.is_some_and(|style| { diff --git a/packages/native/src/custom_elements/code.rs b/packages/native/src/custom_elements/code.rs index d4c1b807..80ea75da 100644 --- a/packages/native/src/custom_elements/code.rs +++ b/packages/native/src/custom_elements/code.rs @@ -186,9 +186,7 @@ impl CustomElement for CodeElement { let mut block = block .id(SharedString::from(format!("__gpuix_code_{}", ctx.id))) .child(body); - if let Some(style) = ctx.style { - block = crate::renderer::apply_styles(block, style); - } + block = ctx.styled(block); block = wire_standard_events(block, &ctx); block.into_any_element() } diff --git a/packages/native/src/custom_elements/diff.rs b/packages/native/src/custom_elements/diff.rs index a22a84e2..e379c99a 100644 --- a/packages/native/src/custom_elements/diff.rs +++ b/packages/native/src/custom_elements/diff.rs @@ -260,9 +260,7 @@ impl CustomElement for DiffElement { .text_size(px(12.0)) .text_color(theme.text_faint) .child(ctx.chrome_text("No changes", None)); - if let Some(style) = ctx.style { - empty = crate::renderer::apply_styles(empty, style); - } + empty = ctx.styled(empty); return empty.into_any_element(); } @@ -276,7 +274,7 @@ impl CustomElement for DiffElement { let wants_show_more = ctx.events.contains("showMore"); let radius = ctx .style - .and_then(|style| style.border_radius) + .and_then(|style| ctx.cascade.scope().number(&style.border_radius)) .unwrap_or(0.0) as f32; let row_theme = theme.clone(); @@ -375,9 +373,7 @@ impl CustomElement for DiffElement { } container = super::code::wire_standard_events(container, &ctx); - if let Some(style) = ctx.style { - container = crate::renderer::apply_styles(container, style); - } + container = ctx.styled(container); container.into_any_element() } diff --git a/packages/native/src/custom_elements/img.rs b/packages/native/src/custom_elements/img.rs index f89cb268..8e3a507d 100644 --- a/packages/native/src/custom_elements/img.rs +++ b/packages/native/src/custom_elements/img.rs @@ -91,9 +91,7 @@ impl CustomElement for ImgElement { .text_color(gpui::rgba(0xa4accdff)) .child("img: no src"); - if let Some(style) = ctx.style { - fallback = crate::renderer::apply_styles(fallback, style); - } + fallback = ctx.styled(fallback); return fallback.into_any_element(); } @@ -114,9 +112,7 @@ impl CustomElement for ImgElement { .into_any_element() }); - if let Some(style) = ctx.style { - el = crate::renderer::apply_styles(el, style); - } + el = ctx.styled(el); el.into_any_element() } @@ -201,9 +197,7 @@ impl CustomElement for SvgElement { let Some(bytes) = self.bytes.as_deref() else { let mut empty = gpui::div(); - if let Some(style) = ctx.style { - empty = crate::renderer::apply_styles(empty, style); - } + empty = ctx.styled(empty); return empty.into_any_element(); }; @@ -213,9 +207,7 @@ impl CustomElement for SvgElement { .and_then(crate::color::parse_color_rgba) .unwrap_or_else(|| gpui::rgb(0xe2e2e2).into()); let mut icon = gpui::svg().data(bytes).flex_none().text_color(tint); - if let Some(style) = ctx.style { - icon = crate::renderer::apply_styles(icon, style); - } + icon = ctx.styled(icon); icon.into_any_element() } diff --git a/packages/native/src/custom_elements/input.rs b/packages/native/src/custom_elements/input.rs index d93c0546..b3f85e24 100644 --- a/packages/native/src/custom_elements/input.rs +++ b/packages/native/src/custom_elements/input.rs @@ -309,9 +309,7 @@ impl CustomElement for TextEditorElement { .w_full() .track_focus(&focus_handle) .child(state); - if let Some(style) = ctx.style { - editor = crate::renderer::apply_styles(editor, style); - } + editor = ctx.styled(editor); if ctx .style .and_then(|style| style.position.as_deref()) diff --git a/packages/native/src/custom_elements/markdown.rs b/packages/native/src/custom_elements/markdown.rs index 380ea1b7..0afce322 100644 --- a/packages/native/src/custom_elements/markdown.rs +++ b/packages/native/src/custom_elements/markdown.rs @@ -75,9 +75,7 @@ impl CustomElement for MarkdownElement { let tree = self.tree(); if tree.is_empty() { let mut empty = gpui::div(); - if let Some(style) = ctx.style { - empty = crate::renderer::apply_styles(empty, style); - } + empty = ctx.styled(empty); return empty.into_any_element(); } @@ -118,9 +116,7 @@ impl CustomElement for MarkdownElement { .child(body); container = super::code::wire_standard_events(container, &ctx); - if let Some(style) = ctx.style { - container = crate::renderer::apply_styles(container, style); - } + container = ctx.styled(container); container.into_any_element() } diff --git a/packages/native/src/custom_elements/mod.rs b/packages/native/src/custom_elements/mod.rs index 2d169ecb..24b00dbd 100644 --- a/packages/native/src/custom_elements/mod.rs +++ b/packages/native/src/custom_elements/mod.rs @@ -46,9 +46,23 @@ pub struct CustomRenderContext<'a> { pub selectable: bool, /// Inherited selection wash colour. pub selection_wash: gpui::Hsla, + /// Everything this element inherits, so `var()` and `currentColor` in its + /// style resolve the same way they do on a plain div. + pub cascade: crate::inheritance::Inherited, } impl CustomRenderContext<'_> { + /// Apply this element's `style` prop, if it has one. + /// + /// Every custom element that takes a style calls this rather than + /// `apply_styles`, so none of them can forget the variable scope. + pub fn styled(&self, el: E) -> E { + let Some(style) = self.style else { + return el; + }; + crate::renderer::apply_styles(el, style, &self.cascade.scope()) + } + /// Build a selectable text run for this element. `sub` distinguishes /// multiple runs painted by the same element, such as code-block lines, and /// must be stable across frames or the selection flickers. diff --git a/packages/native/src/element_tree.rs b/packages/native/src/events.rs similarity index 92% rename from packages/native/src/element_tree.rs rename to packages/native/src/events.rs index 0ce6ff19..1278dd06 100644 --- a/packages/native/src/element_tree.rs +++ b/packages/native/src/events.rs @@ -1,10 +1,12 @@ -/// Event types for Rust → JS communication. -/// Element IDs are f64 (JS numbers) — lossless for integers up to 2^53. -/// -/// EventPayload is the single struct sent across the napi boundary for ALL -/// event types. Fields are optional — each event type populates only the -/// fields it needs. This avoids N different napi structs while keeping the -/// FFI surface small. +//! What an event looks like on the way from Rust back to JavaScript. +//! +//! Element ids travel as f64, because that is what a JavaScript number is. +//! Every integer up to 2^53 survives the trip unchanged. +//! +//! `EventPayload` is the one struct that crosses napi for every event type. +//! Each field is optional, and one event type fills in only the fields it +//! needs. One struct with optional fields keeps the FFI small, where a struct +//! per event type would not. use napi_derive::napi; /// Event payload sent back to JS when a user interacts with an element. diff --git a/packages/native/src/inheritance.rs b/packages/native/src/inheritance.rs new file mode 100644 index 00000000..295af4eb --- /dev/null +++ b/packages/native/src/inheritance.rs @@ -0,0 +1,421 @@ +//! What an element inherits from its ancestors, and how a style resolves +//! against it. +//! +//! CSS resolves a property in two steps. A declaration on the element wins. If +//! there is none, an inherited property takes the parent's computed value. The +//! walk in `renderer.rs` calls `descend` on the way down and `resolve` at each +//! node, and learns nothing about either step. +//! +//! # Why this is an `Arc` +//! +//! Once a property inherits, an element's resolved style stops depending only +//! on that element. It depends on every ancestor as well. So the resolved-style +//! cache has to know which inherited context produced it, and it has to answer +//! "is that context still current" once per element per frame. +//! +//! Comparing a dozen fields that many times is the wrong price. Instead +//! `descend` returns the parent's own `Arc` when an element declares nothing +//! inheritable, which is the common case. Checking the cache is then a pointer +//! comparison, and an element only re-resolves when an ancestor actually +//! changed something it inherits. + +use std::sync::Arc; + +use gpuix_css::color::Rgba; + +use crate::style::StyleDesc; + +/// The computed value of every inherited property at one point in the tree. +#[derive(Debug, Clone, PartialEq)] +struct Values { + /// False once an ancestor sets `userSelect: "none"`. + selectable: bool, + /// Selection wash colour for this subtree. + selection_wash: Rgba, + /// The computed `color` here, which is what `currentColor` names. + /// + /// GPUI inherits text colour itself through the window text style stack, so + /// this is a second copy of the same value. It exists because `currentColor` + /// has to resolve while the style resolves, and the GPUI stack only exists + /// during paint. The root starts at `black`, which is what + /// `TextStyle::default` uses, so the two agree unless something declares a + /// colour this does not follow. + color: Rgba, + /// Whether the window is in the dark appearance, which `light-dark()` + /// reads. + /// + /// It is a window-wide fact rather than something an element declares, but + /// it sits here because it is an input to resolving a colour, and this is + /// what the resolved-style cache already keys on. + dark: bool, + /// The root font size in pixels, which is what `rem` is a multiple of. + /// + /// It comes from the window rather than a constant here, so a call to + /// `set_rem_size` reaches every `rem` length. It sits in the cascade + /// because that is what the resolved-style cache already keys on, so a + /// change to it invalidates exactly the styles that read a `rem`. + rem_size: f32, + /// Every custom property in scope, nearest declaration winning. + /// + /// Held behind its own `Arc` so an element that declares no variables + /// shares its parent's map instead of copying it. Most elements declare + /// none, so most of the tree shares one allocation. + variables: Arc, +} + +/// Custom properties in scope, sorted by name. +/// +/// A sorted list beats a map here. There are rarely more than a handful, a +/// linear scan over contiguous memory beats hashing at that size, and it +/// compares cheaply, which the cascade needs on every descend. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct Variables(Vec<(String, String)>); + +impl Variables { + /// The declared text of `name`, which includes the leading dashes. + pub fn get(&self, name: &str) -> Option<&str> { + self.0 + .binary_search_by(|(known, _)| known.as_str().cmp(name)) + .ok() + .map(|index| self.0[index].1.as_str()) + } + + /// This scope with `declared` layered over it, nearest declaration winning. + pub fn layer(&self, declared: &[(String, String)]) -> Self { + let mut next = self.0.clone(); + for (name, value) in declared { + match next.binary_search_by(|(known, _)| known.as_str().cmp(name)) { + Ok(index) => next[index].1 = value.clone(), + Err(index) => next.insert(index, (name.clone(), value.clone())), + } + } + Self(next) + } +} + +/// The inherited context for one element. +/// +/// Cloning is a refcount bump. Two cascades that compare equal by pointer are +/// the same context, which is what the resolved-style cache tests. +#[derive(Debug, Clone)] +pub(crate) struct Inherited(Arc); + +impl Inherited { + /// The context at the root of the tree, before any element declares + /// anything. + /// + /// Takes plain values rather than a `Theme` so that nothing about + /// inheritance depends on the renderer. The caller reads the theme. + pub fn root(accent: Rgba, dark: bool, rem_size: f32) -> Self { + let wash = Rgba { a: 0.35, ..accent }; + Self(Arc::new(Values { + selectable: true, + selection_wash: wash, + color: Rgba::BLACK, + dark, + rem_size, + variables: Arc::new(Variables::default()), + })) + } + + /// The context for the children of an element carrying `style`. + /// + /// Returns this same context, by pointer, when the element declares nothing + /// that inherits. That is what keeps the cache check cheap, so the + /// comparison against the old value is load-bearing rather than an + /// optimisation. + pub fn descend(&self, style: Option<&StyleDesc>) -> Self { + let Some(style) = style else { + return self.clone(); + }; + let mut next = (*self.0).clone(); + + match style.user_select.as_deref() { + Some("none") => next.selectable = false, + Some("text") | Some("auto") => next.selectable = true, + _ => {} + } + if let Some(text) = style.selection_color.as_deref() { + let context = gpuix_css::color::ColorContext { + current_color: next.color, + dark: next.dark, + }; + if let Ok(color) = gpuix_css::color::color(text, &context) { + next.selection_wash = color; + } + } + + let declared = crate::style::declared_variables(style); + if !declared.is_empty() { + let layered = next.variables.layer(&declared); + if layered != *next.variables { + next.variables = Arc::new(layered); + } + } + + if let Some(text) = style.color.as_deref() { + // Variables layer first, so `color: var(--fg)` computes the same + // colour here that it paints in the style itself. `currentColor` on + // `color` means the inherited value, so it declares nothing. + let scope = crate::style::vars::Scope::new( + &next.variables, + next.color, + next.dark, + next.rem_size, + ); + if let Some(color) = scope.color(text) { + next.color = color; + } + } + + if next == *self.0 { + return self.clone(); + } + Self(Arc::new(next)) + } + + /// Whether two contexts are the same one. + pub fn same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } + + /// Whether this subtree takes part in text selection. + pub fn selectable(&self) -> bool { + self.0.selectable + } + + /// The selection wash colour for this subtree. + pub fn selection_wash(&self) -> Rgba { + self.0.selection_wash + } + + /// Every custom property in scope. + /// + /// The render path reads these through `scope()`. This is the read side + /// the module's own tests go through, so it compiles under `cfg(test)`. + #[cfg(test)] + pub fn variables(&self) -> &Arc { + &self.0.variables + } + + /// The computed `color` here. + /// + /// Same as `variables`: the render path reads it through `scope()`. + #[cfg(test)] + pub fn color(&self) -> Rgba { + self.0.color + } + + /// A scope for resolving one style against this context. + pub fn scope(&self) -> crate::style::vars::Scope<'_> { + crate::style::vars::Scope::new( + &self.0.variables, + self.0.color, + self.0.dark, + self.0.rem_size, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> Inherited { + Inherited::root(Rgba { r: 0.4, g: 0.4, b: 0.9, a: 1.0 }, true, 16.0) + } + + fn styled(build: impl FnOnce(&mut StyleDesc)) -> StyleDesc { + let mut style = StyleDesc::default(); + build(&mut style); + style + } + + #[test] + fn an_element_with_no_style_keeps_its_parent_context() { + let parent = root(); + assert!(parent.same(&parent.descend(None))); + } + + #[test] + fn an_element_that_inherits_nothing_keeps_its_parent_context() { + // The pointer has to stay the same here. A new Arc for every styled + // element would make every element re-resolve on every frame. + let parent = root(); + let style = styled(|s| s.padding = Some(8.0.into())); + assert!(parent.same(&parent.descend(Some(&style)))); + } + + #[test] + fn a_declaration_that_inherits_makes_a_new_context() { + let parent = root(); + let style = styled(|s| s.user_select = Some("none".to_string())); + let child = parent.descend(Some(&style)); + assert!(!parent.same(&child)); + assert!(parent.selectable()); + assert!(!child.selectable()); + } + + #[test] + fn redeclaring_the_value_it_already_has_keeps_the_context() { + // `userSelect: "text"` at the root is what the root already computes, + // so it must not invalidate the subtree below it. + let parent = root(); + let style = styled(|s| s.user_select = Some("text".to_string())); + assert!(parent.same(&parent.descend(Some(&style)))); + } + + #[test] + fn an_inherited_value_reaches_a_grandchild() { + let parent = root(); + let style = styled(|s| s.user_select = Some("none".to_string())); + let child = parent.descend(Some(&style)); + let grandchild = child.descend(None); + assert!(!grandchild.selectable()); + assert!(child.same(&grandchild)); + } + + #[test] + fn a_descendant_can_turn_selection_back_on() { + let parent = root(); + let off = styled(|s| s.user_select = Some("none".to_string())); + let on = styled(|s| s.user_select = Some("text".to_string())); + let child = parent.descend(Some(&off)); + let grandchild = child.descend(Some(&on)); + assert!(grandchild.selectable()); + } + + fn declaring(pairs: &[(&str, &str)]) -> StyleDesc { + let custom = pairs + .iter() + .map(|(name, value)| (name.to_string(), serde_json::json!(value))) + .collect(); + StyleDesc { + custom, + ..Default::default() + } + } + + #[test] + fn a_declared_variable_is_in_scope_below_it() { + let parent = root(); + let child = parent.descend(Some(&declaring(&[("--brand", "#ff0000")]))); + assert_eq!(child.variables().get("--brand"), Some("#ff0000")); + assert_eq!(parent.variables().get("--brand"), None); + } + + #[test] + fn a_variable_reaches_a_grandchild() { + let parent = root(); + let child = parent.descend(Some(&declaring(&[("--brand", "#ff0000")]))); + let grandchild = child.descend(None); + assert_eq!(grandchild.variables().get("--brand"), Some("#ff0000")); + } + + #[test] + fn a_nearer_declaration_wins() { + let parent = root().descend(Some(&declaring(&[("--brand", "#ff0000")]))); + let child = parent.descend(Some(&declaring(&[("--brand", "#00ff00")]))); + assert_eq!(child.variables().get("--brand"), Some("#00ff00")); + assert_eq!(parent.variables().get("--brand"), Some("#ff0000")); + } + + #[test] + fn a_second_declaration_leaves_the_first_alone() { + let scope = root() + .descend(Some(&declaring(&[("--a", "1px")]))) + .descend(Some(&declaring(&[("--b", "2px")]))); + assert_eq!(scope.variables().get("--a"), Some("1px")); + assert_eq!(scope.variables().get("--b"), Some("2px")); + } + + #[test] + fn a_number_declares_the_same_thing_as_its_text() { + let mut style = StyleDesc::default(); + style + .custom + .insert("--pad".to_string(), serde_json::json!(8)); + let scope = root().descend(Some(&style)); + assert_eq!(scope.variables().get("--pad"), Some("8")); + } + + #[test] + fn a_key_without_the_two_dashes_is_not_a_variable() { + // Serde's flatten collects every unknown key, so a typo or a field a + // newer client knows about lands in the same map. Only `--` names are + // declarations. + let mut style = StyleDesc::default(); + style + .custom + .insert("someFutureThing".to_string(), serde_json::json!("4")); + let parent = root(); + assert!(parent.same(&parent.descend(Some(&style)))); + } + + #[test] + fn redeclaring_a_variable_with_its_own_value_keeps_the_context() { + // The cache below this element compares cascade pointers, so an + // unchanged declaration must not build a new one. + let parent = root().descend(Some(&declaring(&[("--brand", "#ff0000")]))); + let same = parent.descend(Some(&declaring(&[("--brand", "#ff0000")]))); + assert!(parent.same(&same)); + } + + #[test] + fn an_undefined_value_declares_nothing() { + // `undefined` in the style prop arrives as null. CSS has no way to + // write an undeclared value, so it has to read as absent. + let mut style = StyleDesc::default(); + style + .custom + .insert("--brand".to_string(), serde_json::Value::Null); + let parent = root(); + assert!(parent.same(&parent.descend(Some(&style)))); + } + + fn colored(color: &str) -> StyleDesc { + styled(|s| s.color = Some(color.to_string())) + } + + #[test] + fn a_declared_colour_becomes_the_current_colour() { + let parent = root(); + assert_eq!(parent.color(), Rgba::BLACK); + let child = parent.descend(Some(&colored("#ff0000"))); + assert_eq!( + Some(child.color()), + gpuix_css::color::color("#ff0000", &Default::default()).ok() + ); + } + + #[test] + fn the_current_colour_reaches_a_grandchild() { + let child = root().descend(Some(&colored("#ff0000"))); + assert_eq!(child.descend(None).color(), child.color()); + } + + #[test] + fn a_colour_written_as_a_variable_still_becomes_the_current_colour() { + let scope = root().descend(Some(&declaring(&[("--fg", "#ff0000")]))); + let child = scope.descend(Some(&colored("var(--fg)"))); + assert_eq!( + Some(child.color()), + gpuix_css::color::color("#ff0000", &Default::default()).ok() + ); + } + + #[test] + fn current_color_on_color_itself_declares_nothing() { + // CSS computes `color: currentColor` to `inherit`, so the element keeps + // what it already had and the context below it does not change. + let parent = root().descend(Some(&colored("#ff0000"))); + assert!(parent.same(&parent.descend(Some(&colored("currentColor"))))); + } + + #[test] + fn an_unparseable_colour_leaves_the_current_colour_alone() { + let parent = root().descend(Some(&colored("#ff0000"))); + let child = parent.descend(Some(&colored("not-a-colour"))); + assert_eq!(child.color(), parent.color()); + } +} diff --git a/packages/native/src/lib.rs b/packages/native/src/lib.rs index 9d840f37..c973c519 100644 --- a/packages/native/src/lib.rs +++ b/packages/native/src/lib.rs @@ -1,15 +1,16 @@ #![deny(clippy::all)] mod automation; +mod inheritance; mod color; mod custom_elements; mod diff; -mod element_tree; +mod events; mod markdown; mod motion; mod renderer; mod retained_tree; -mod style; +pub mod style; mod syntax; mod text; mod theme; @@ -17,6 +18,6 @@ mod theme; #[cfg(all(feature = "test-support", target_os = "macos"))] mod test_renderer; -pub use element_tree::*; +pub use events::*; pub use renderer::*; pub use style::*; diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index e5eae66c..d96f4b9e 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -45,22 +45,22 @@ impl MotionStyle { style.height = Some(DimensionValue::Pixels(value)); } if let Some(value) = self.opacity { - style.opacity = Some(value); + style.opacity = Some(value.into()); } if let Some(value) = self.top { - style.top = Some(value); + style.top = Some(value.into()); } if let Some(value) = self.right { - style.right = Some(value); + style.right = Some(value.into()); } if let Some(value) = self.bottom { - style.bottom = Some(value); + style.bottom = Some(value.into()); } if let Some(value) = self.left { - style.left = Some(value); + style.left = Some(value.into()); } if let Some(value) = self.border_radius { - style.border_radius = Some(value); + style.border_radius = Some(value.into()); } } } diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index dc6222c6..8608841d 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -21,7 +21,7 @@ use napi::bindgen_prelude::*; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi_derive::napi; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::rc::Rc; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] use std::sync::mpsc::{sync_channel, RecvTimeoutError, SyncSender}; @@ -29,15 +29,25 @@ use std::sync::{Arc, Mutex}; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] use std::time::Duration; -use crate::custom_elements::{CustomElementRegistry, CustomRenderContext}; -use crate::element_tree::EventPayload; +use crate::custom_elements::CustomElementRegistry; +use crate::events::EventPayload; use crate::retained_tree::RetainedTree; +// Custom elements still style their own sub-parts directly. +pub(crate) use crate::style::resolve::apply_styles; use crate::style::StyleDesc; -use crate::text::{selectable_text, selection_frame_reset, selection_key, SharedSelection}; +use crate::text::{selection_frame_reset, SharedSelection}; use crate::theme::Theme; gpui::actions!(gpuix_focus, [FocusNext, FocusPrevious]); +mod batch; +mod frame; +mod virtual_list; + +pub(crate) use batch::apply_batch_to_tree; +use frame::{build_element, BuildCtx}; +use virtual_list::VirtualListEntry; + pub(crate) fn init_key_bindings(cx: &mut gpui::App) { cx.bind_keys([ gpui::KeyBinding::new("tab", FocusNext, None), @@ -45,33 +55,6 @@ pub(crate) fn init_key_bindings(cx: &mut gpui::App) { ]); } -/// Parse a CSS font-weight value (string or number) into a GPUI FontWeight. -/// Accepts named keywords ("bold", "semibold"), numeric strings ("700"), -/// and raw numbers (700). Falls back to 400 (normal) for unrecognized values. -fn parse_font_weight(value: &crate::style::FontWeightValue) -> gpui::FontWeight { - match value { - crate::style::FontWeightValue::Num(n) => gpui::FontWeight((*n as f32).clamp(1.0, 1000.0)), - crate::style::FontWeightValue::Str(s) => { - let lower = s.trim().to_ascii_lowercase(); - match lower.as_str() { - "100" | "thin" => gpui::FontWeight(100.0), - "200" | "extralight" | "extra-light" => gpui::FontWeight(200.0), - "300" | "light" => gpui::FontWeight(300.0), - "400" | "normal" => gpui::FontWeight(400.0), - "500" | "medium" => gpui::FontWeight(500.0), - "600" | "semibold" | "semi-bold" => gpui::FontWeight(600.0), - "700" | "bold" => gpui::FontWeight(700.0), - "800" | "extrabold" | "extra-bold" => gpui::FontWeight(800.0), - "900" | "black" => gpui::FontWeight(900.0), - _ => lower - .parse::() - .map(|n| gpui::FontWeight(n.clamp(1.0, 1000.0))) - .unwrap_or(gpui::FontWeight(400.0)), - } - } - } -} - /// Abstracted event callback — both production and test renderers use this. /// Production: wraps ThreadsafeFunction (async, queued on Node.js event loop). /// Tests: wraps Arc>> (synchronous collection). @@ -856,7 +839,7 @@ impl GpuixRenderer { #[napi] pub fn set_style(&self, id: f64, style_json: String) -> Result<()> { let id = to_element_id(id)?; - let style: StyleDesc = serde_json::from_str(&style_json) + let style = StyleDesc::from_json_boxed(&style_json) .map_err(|e| Error::from_reason(format!("Failed to parse style: {}", e)))?; let mut tree = self.tree.lock().unwrap(); tree.set_style(id, style); @@ -1642,6 +1625,14 @@ pub(crate) struct GpuixView { virtual_lists: HashMap, /// Motion / review clock. Live wall time unless automation freezes it. pub(crate) clock: crate::automation::AutomationClock, + /// The cascade every frame starts from. + /// + /// It has to keep its identity between frames. The resolved-style cache + /// asks whether the cascade an element resolved under is still the current + /// one, and it compares pointers. A fresh root on every frame would answer + /// no for every element that reads an inherited value, and the whole tree + /// would resolve again on every frame. + root_cascade: RefCell>, } impl GpuixView { @@ -1663,7 +1654,28 @@ impl GpuixView { selection, virtual_lists: HashMap::new(), clock: crate::automation::AutomationClock::new(), + root_cascade: RefCell::new(None), + } + } + + /// The root cascade for `theme`, reusing the last one while the theme + /// holds still. + fn root_cascade(&self, theme: &Theme, rem_size: gpui::Pixels) -> crate::inheritance::Inherited { + let mut slot = self.root_cascade.borrow_mut(); + if let Some((built_for, built_rem, cascade)) = slot.as_ref() { + if built_for == theme && *built_rem == rem_size { + return cascade.clone(); + } } + // Inheritance takes plain values, so the theme is read here rather + // than inside it. + let cascade = crate::inheritance::Inherited::root( + crate::color::from_gpui(theme.accent), + theme.dark, + f32::from(rem_size), + ); + *slot = Some((theme.clone(), rem_size, cascade.clone())); + cascade } fn build_virtual_child( @@ -1671,7 +1683,7 @@ impl GpuixView { list_id: u64, index: usize, expected_child_id: u64, - inherited: Inherited, + cascade: crate::inheritance::Inherited, window: &mut gpui::Window, cx: &mut gpui::Context, ) -> gpui::AnyElement { @@ -1719,7 +1731,7 @@ impl GpuixView { now, motion_active: &mut motion_active, selection: self.selection.clone(), - inherited, + cascade, }; let child = build_element(expected_child_id, &mut build_ctx, window, cx); if motion_active { @@ -1806,328 +1818,6 @@ impl GpuixView { } } -/// Everything `build_element` threads through the tree. -/// -/// Split into a struct because the recursion needs eight-plus shared references -/// and adding one more to every call site is how this file rots. `window` and -/// `cx` stay separate parameters: they are `&mut` and gpui reborrows them. -pub(crate) struct BuildCtx<'a> { - pub tree: &'a RetainedTree, - pub event_callback: &'a Option, - pub focus_handles: &'a HashMap, - pub scroll_handles: &'a mut HashMap, - pub custom_registry: &'a mut CustomElementRegistry, - virtual_lists: &'a mut HashMap, - pub motion_states: &'a mut HashMap, - pub now: std::time::Instant, - pub motion_active: &'a mut bool, - pub selection: SharedSelection, - /// Inherited text state, resolved the way CSS inherits it. The renderer's - /// own theme only seeds the root selection wash; custom elements resolve - /// their own theme from their `theme` prop. - pub inherited: Inherited, -} - -/// Style properties that cascade into descendants. -#[derive(Clone, Copy)] -pub(crate) struct Inherited { - /// False once an ancestor sets `userSelect: "none"`. - pub selectable: bool, - /// Selection wash colour for this subtree. - pub selection_wash: gpui::Hsla, -} - -impl Inherited { - fn root(theme: &Theme) -> Self { - let mut wash = theme.accent; - wash.a = 0.35; - Self { - selectable: true, - selection_wash: wash, - } - } - - /// Apply the inheritable parts of `style` for the subtree below it. - fn descend(mut self, style: Option<&StyleDesc>) -> Self { - let Some(style) = style else { return self }; - match style.user_select.as_deref() { - Some("none") => self.selectable = false, - Some("text") | Some("auto") => self.selectable = true, - _ => {} - } - if let Some(color) = style - .selection_color - .as_deref() - .and_then(crate::color::parse_color_rgba) - { - self.selection_wash = color.into(); - } - self - } -} - -fn json_usize(value: &serde_json::Value) -> Option { - value - .as_u64() - .map(|n| n as usize) - .or_else(|| { - value - .as_f64() - .filter(|n| *n >= 0.0 && n.is_finite()) - .map(|n| n as usize) - }) - .or_else(|| value.as_i64().filter(|n| *n >= 0).map(|n| n as usize)) -} - -fn window_start_from_element(element: &crate::retained_tree::RetainedElement) -> usize { - element - .custom_props - .get("windowStart") - .and_then(json_usize) - .unwrap_or(0) -} - -#[derive(Clone, Copy, PartialEq)] -struct VirtualListConfig { - alignment: gpui::ListAlignment, - follow_tail: bool, - overdraw: f32, - estimated_item_height: Option, - item_count: Option, -} - -impl VirtualListConfig { - fn from_element(element: &crate::retained_tree::RetainedElement) -> Self { - let prop = |key: &str| element.custom_props.get(key); - let alignment = match prop("alignment").and_then(serde_json::Value::as_str) { - Some("bottom") => gpui::ListAlignment::Bottom, - _ => gpui::ListAlignment::Top, - }; - let follow_tail = prop("followTail") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let overdraw = prop("overdraw") - .and_then(serde_json::Value::as_f64) - .unwrap_or(512.0) - .max(0.0) as f32; - let estimated_item_height = prop("estimatedItemHeight") - .and_then(serde_json::Value::as_f64) - .filter(|height| *height > 0.0) - .map(|height| height as f32); - let item_count = prop("itemCount").and_then(json_usize); - Self { - alignment, - follow_tail, - overdraw, - estimated_item_height, - item_count, - } - } - - fn logical_count(self, child_len: usize) -> usize { - self.item_count.unwrap_or(child_len) - } - - fn make_state(self, item_count: usize, focus_handles: &[Option]) -> gpui::ListState { - let mut state = gpui::ListState::new(item_count, self.alignment, gpui::px(self.overdraw)); - if focus_handles.len() == item_count { - state.splice_focusable(0..item_count, focus_handles.iter().cloned()); - } else { - state.splice_focusable(0..item_count, (0..item_count).map(|_| None)); - } - if let Some(height) = self.estimated_item_height { - state = state.with_uniform_item_height(gpui::px(height)); - } - if self.follow_tail { - state.set_follow_mode(gpui::FollowMode::Tail); - } - state - } -} - -struct VirtualListEntry { - state: gpui::ListState, - config: VirtualListConfig, - window_start: usize, - child_ids: Vec, - child_revisions: Vec, - row_focus_handles: Vec>, - seen_rows: HashSet, -} - -impl VirtualListEntry { - fn new( - config: VirtualListConfig, - window_start: usize, - child_ids: Vec, - child_revisions: Vec, - row_focus_handles: Vec>, - ) -> Self { - let item_count = config.logical_count(child_ids.len()); - let state = config.make_state(item_count, &row_focus_handles); - if row_focus_handles.len() != item_count { - for (offset, handle) in row_focus_handles.iter().enumerate() { - if handle.is_some() { - let logical = window_start + offset; - if logical < item_count { - state.splice_focusable( - logical..logical + 1, - std::iter::once(handle.clone()), - ); - } - } - } - } - Self { - state, - config, - window_start, - child_ids, - child_revisions, - row_focus_handles, - seen_rows: HashSet::new(), - } - } - - fn child_at(&self, logical_index: usize) -> Option { - logical_index - .checked_sub(self.window_start) - .and_then(|offset| self.child_ids.get(offset).copied()) - } - - fn logical_index_of(&self, child_id: u64) -> Option { - self.child_ids - .iter() - .position(|id| *id == child_id) - .map(|offset| self.window_start + offset) - } - - fn sync( - &mut self, - config: VirtualListConfig, - window_start: usize, - child_ids: Vec, - child_revisions: Vec, - focusable_rows: &HashSet, - cx: &mut gpui::Context, - ) { - let focus_unchanged = self.child_ids == child_ids - && self.row_focus_handles.len() == child_ids.len() - && self - .child_ids - .iter() - .zip(&self.row_focus_handles) - .all(|(id, handle)| handle.is_some() == focusable_rows.contains(id)); - if self.config == config - && self.window_start == window_start - && focus_unchanged - && self.child_revisions == child_revisions - { - return; - } - - let old_rows: HashMap)> = self - .child_ids - .iter() - .copied() - .zip(self.child_revisions.iter().copied()) - .zip(self.row_focus_handles.iter().cloned()) - .map(|((id, revision), focus_handle)| (id, (revision, focus_handle))) - .collect(); - let row_focus_handles: Vec> = child_ids - .iter() - .map(|id| { - focusable_rows.contains(id).then(|| { - old_rows - .get(id) - .and_then(|(_, focus_handle)| focus_handle.clone()) - .unwrap_or_else(|| cx.focus_handle()) - }) - }) - .collect(); - if self.config != config { - let scroll_top = self.state.logical_scroll_top(); - let should_follow = - config.follow_tail && (!self.config.follow_tail || self.state.is_following_tail()); - let mut replacement = - Self::new(config, window_start, child_ids, child_revisions, row_focus_handles); - replacement.seen_rows = std::mem::take(&mut self.seen_rows); - replacement - .seen_rows - .retain(|id| replacement.child_ids.contains(id)); - if !should_follow { - replacement.state.scroll_to(scroll_top); - } - *self = replacement; - return; - } - - // A windowed list's children are a sliding viewport. Splicing by - // child position would treat a scroll as a rewrite of items 0..N. - if config.item_count.is_none() && self.child_ids != child_ids { - let prefix = self - .child_ids - .iter() - .zip(&child_ids) - .take_while(|(old, new)| old == new) - .count(); - let suffix = self.child_ids[prefix..] - .iter() - .rev() - .zip(child_ids[prefix..].iter().rev()) - .take_while(|(old, new)| old == new) - .count(); - self.state.splice_focusable( - prefix..self.child_ids.len().saturating_sub(suffix), - row_focus_handles[prefix..row_focus_handles.len().saturating_sub(suffix)] - .iter() - .cloned(), - ); - if let Some(height) = config.estimated_item_height { - self.state = self - .state - .clone() - .with_uniform_item_height(gpui::px(height)); - } - } - - for (offset, (&id, focus_handle)) in child_ids.iter().zip(&row_focus_handles).enumerate() { - let logical = window_start + offset; - let focusability_changed = old_rows - .get(&id) - .is_some_and(|(_, old_handle)| old_handle.is_some() != focus_handle.is_some()); - if focusability_changed { - self.state - .splice_focusable(logical..logical + 1, std::iter::once(focus_handle.clone())); - } - } - - let mut changed_start = None; - for (offset, (&id, &revision)) in child_ids.iter().zip(&child_revisions).enumerate() { - let logical = window_start + offset; - let changed = old_rows - .get(&id) - .is_some_and(|(old_revision, _)| *old_revision != revision); - match (changed_start, changed) { - (None, true) => changed_start = Some(logical), - (Some(start), false) => { - self.state.remeasure_items(start..logical); - changed_start = None; - } - _ => {} - } - } - if let Some(start) = changed_start { - self.state - .remeasure_items(start..window_start + child_ids.len()); - } - - self.window_start = window_start; - self.child_ids = child_ids; - self.child_revisions = child_revisions; - self.row_focus_handles = row_focus_handles; - } -} impl GpuixView { /// Sync focus handles with the current element tree. @@ -2253,6 +1943,7 @@ impl gpui::Render for GpuixView { // Build the element tree. custom_registry, focus_handles, and scroll_handles // are different fields of self, so Rust allows borrowing all simultaneously. let theme = Theme::dark(); + let root_cascade = self.root_cascade(&theme, window.rem_size()); let now = self.clock.now(); let mut motion_active = false; let result = match tree.root_id { @@ -2268,7 +1959,7 @@ impl gpui::Render for GpuixView { now, motion_active: &mut motion_active, selection: self.selection.clone(), - inherited: Inherited::root(&theme), + cascade: root_cascade, }; build_element(root_id, &mut ctx, window, cx) } @@ -2316,964 +2007,6 @@ impl gpui::Render for GpuixView { } } -// ── Element builders ───────────────────────────────────────────────── - -pub(crate) fn build_element( - id: u64, - ctx: &mut BuildCtx, - window: &mut gpui::Window, - cx: &mut gpui::Context, -) -> gpui::AnyElement { - use gpui::IntoElement; - - let Some(element) = ctx.tree.elements.get(&id) else { - return gpui::Empty.into_any_element(); - }; - - let animated_style = if let Some(source) = element.custom_props.get("motion") { - let state = match ctx.motion_states.entry(id) { - std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), - std::collections::hash_map::Entry::Vacant(entry) => { - match crate::motion::MotionState::new(source, ctx.now) { - Ok(state) => entry.insert(state), - Err(error) => { - log::warn!("Invalid motion description for element {id}: {error}"); - entry.insert(crate::motion::MotionState::invalid(source, ctx.now)) - } - } - } - }; - if let Err(error) = state.sync(source, ctx.now) { - log::warn!("Invalid motion update for element {id}: {error}"); - } - state.is_valid().then(|| { - let frame = state.frame(ctx.now); - *ctx.motion_active |= frame.active; - let mut resolved = element.style.clone().unwrap_or_default(); - frame.style.apply_to(&mut resolved); - resolved - }) - } else { - ctx.motion_states.remove(&id); - None - }; - let style = animated_style.as_ref().or(element.style.as_ref()); - - // Inheritable style resolves once here so both built-ins and custom - // elements see the same cascade. - let parent_inherited = ctx.inherited; - ctx.inherited = parent_inherited.descend(style); - - let built = match element.element_type.as_str() { - "div" => { - ctx.custom_registry.destroy(id); - build_div(element, style, ctx, window, cx) - } - "text" => { - ctx.custom_registry.destroy(id); - build_text(element, style, ctx, window, cx) - } - "virtual-list" => { - ctx.custom_registry.destroy(id); - build_virtual_list(element, ctx, window, cx) - } - - // Polymorphic dispatch for all custom elements. - custom_type => { - let custom_children: Vec = element - .children - .iter() - .copied() - .filter(|child_id| ctx.tree.elements.contains_key(child_id)) - .map(|child_id| build_element(child_id, ctx, window, cx)) - .collect(); - let inherited = ctx.inherited; - let render_ctx = CustomRenderContext { - id, - events: &element.events, - event_callback: ctx.event_callback, - focus_handle: ctx.focus_handles.get(&id), - style, - children: custom_children, - selection: ctx.selection.clone(), - selectable: inherited.selectable, - selection_wash: inherited.selection_wash, - }; - ctx.custom_registry.render( - custom_type, - &element.custom_props, - render_ctx, - window, - cx, - ) - } - }; - - ctx.inherited = parent_inherited; - built -} - -fn build_virtual_list( - element: &crate::retained_tree::RetainedElement, - ctx: &mut BuildCtx, - window: &mut gpui::Window, - cx: &mut gpui::Context, -) -> gpui::AnyElement { - use gpui::prelude::*; - - let child_ids: Vec = element - .children - .iter() - .copied() - .filter(|child_id| ctx.tree.elements.contains_key(child_id)) - .collect(); - let child_revisions: Vec = child_ids - .iter() - .filter_map(|child_id| { - ctx.tree - .elements - .get(child_id) - .map(|child| child.subtree_revision) - }) - .collect(); - let focusable_rows: HashSet = ctx - .focus_handles - .keys() - .filter_map(|element_id| virtual_row_ancestor(ctx.tree, element.id, *element_id)) - .collect(); - let focused_row = ctx - .focus_handles - .iter() - .find_map(|(element_id, handle)| { - handle - .is_focused(window) - .then(|| virtual_row_ancestor(ctx.tree, element.id, *element_id)) - .flatten() - }) - .or_else(|| { - ctx.focus_handles.keys().find_map(|element_id| { - ctx.tree - .elements - .get(element_id) - .is_some_and(|element| element.auto_focus) - .then(|| virtual_row_ancestor(ctx.tree, element.id, *element_id)) - .flatten() - }) - }); - let config = VirtualListConfig::from_element(element); - let window_start = window_start_from_element(element); - let list_state = match ctx.virtual_lists.entry(element.id) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - entry.get_mut().sync( - config, - window_start, - child_ids.clone(), - child_revisions, - &focusable_rows, - cx, - ); - let entry = entry.into_mut(); - if let Some(row_id) = focused_row.filter(|row_id| !entry.seen_rows.contains(row_id)) { - if let Some(index) = entry.logical_index_of(row_id) { - entry.state.scroll_to(gpui::ListOffset { - item_ix: index, - offset_in_item: gpui::px(0.0), - }); - } - } - entry.state.clone() - } - std::collections::hash_map::Entry::Vacant(entry) => { - let row_focus_handles = child_ids - .iter() - .map(|id| focusable_rows.contains(id).then(|| cx.focus_handle())) - .collect(); - let entry = entry.insert(VirtualListEntry::new( - config, - window_start, - child_ids.clone(), - child_revisions, - row_focus_handles, - )); - if let Some(row_id) = focused_row { - if let Some(index) = entry.logical_index_of(row_id) { - entry.state.scroll_to(gpui::ListOffset { - item_ix: index, - offset_in_item: gpui::px(0.0), - }); - } - } - entry.state.clone() - } - }; - - if element.events.contains("visibleRange") { - let callback = ctx.event_callback.clone(); - let list_id = element.id; - list_state.set_scroll_handler(move |event, _window, _cx| { - emit_event_full(&callback, list_id, "visibleRange", |payload| { - payload.start_index = Some(event.visible_range.start as f64); - payload.end_index = Some(event.visible_range.end as f64); - }); - }); - } - - let list_id = element.id; - let inherited = ctx.inherited; - let render_item = cx.processor(move |view, index: usize, window, cx| { - let Some(child_id) = view - .virtual_lists - .get(&list_id) - .and_then(|entry| entry.child_at(index)) - else { - return gpui::Empty.into_any_element(); - }; - view.build_virtual_child(list_id, index, child_id, inherited, window, cx) - }); - let mut list = - gpui::list(list_state, render_item).with_sizing_behavior(gpui::ListSizingBehavior::Auto); - if let Some(style) = element.style.as_ref() { - list = apply_styles(list, style); - } - list.into_any_element() -} - -fn virtual_row_ancestor(tree: &RetainedTree, list_id: u64, element_id: u64) -> Option { - let mut current = element_id; - loop { - let parent = tree.elements.get(¤t)?.parent?; - if parent == list_id { - return Some(current); - } - current = parent; - } -} - -pub(crate) fn build_div( - element: &crate::retained_tree::RetainedElement, - style: Option<&StyleDesc>, - ctx: &mut BuildCtx, - window: &mut gpui::Window, - cx: &mut gpui::Context, -) -> gpui::AnyElement { - use gpui::prelude::*; - - let element_id_str = format!("__gpuix_{}", element.id); - let mut el = gpui::div().id(gpui::SharedString::from(element_id_str)); - - if let Some(style) = style { - el = apply_styles(el, style); - - // ── Pseudo-selector styles (hover / active) ────────────────── - // GPUI's .hover() and .active() take a closure that receives a - // StyleRefinement and returns it with modifications. Since - // StyleRefinement implements Styled, we can reuse apply_styles(). - if let Some(ref hover_style) = style.hover { - el = el.hover(|refinement| apply_styles(refinement, hover_style)); - } - if let Some(ref active_style) = style.active { - el = el.active(|refinement| apply_styles(refinement, active_style)); - } - - if crate::style::should_occlude(style) { - // BlockMouse (occlude) stops the hit test. The parent scroller - // then never sees the wheel. In-flow fills must use - // BlockMouseExceptScroll. Keep occlude for overlays that steal - // the pointer: absolute, fixed, or pointerEvents: "auto". - let steal_scroll = - matches!(style.position.as_deref(), Some("absolute") | Some("fixed")) - || style.pointer_events.as_deref() == Some("auto"); - el = if steal_scroll { - el.occlude() - } else { - el.block_mouse_except_scroll() - }; - } - } - - // ── Overflow: scroll ───────────────────────────────────────────── - // overflow_scroll() requires StatefulInteractiveElement (only on Stateful
), - // so we handle it here rather than in apply_styles (which takes E: Styled). - // - // CSS precedence: axis-specific props (overflowX/Y) override the shorthand - // (overflow). E.g. { overflow: "scroll", overflowY: "hidden" } → scroll X only. - // - // overflow-x only works as a flex viewport. Default display is Block, so a - // wide child fills the parent instead of overflowing. Zed's code-block path: - // flex + min_w_0 on the scroller, flex_none on the child. - let mut overflow_x_only = false; - if let Some(style) = style { - // Resolve each axis: axis-specific overrides shorthand. - let resolved_x = style.overflow_x.as_deref().or(style.overflow.as_deref()); - let resolved_y = style.overflow_y.as_deref().or(style.overflow.as_deref()); - - let needs_scroll_x = resolved_x == Some("scroll"); - let needs_scroll_y = resolved_y == Some("scroll"); - - if needs_scroll_x && needs_scroll_y { - el = el.overflow_scroll(); - } else if needs_scroll_x { - overflow_x_only = true; - el = el - .flex() - .min_w_0() - .overflow_x_scroll() - .restrict_scroll_to_axis(); - } else if needs_scroll_y { - el = el.overflow_y_scroll(); - } - - // Attach a persistent ScrollHandle when scrolling is enabled. - // The handle persists across renders (stored in GpuixView::scroll_handles) - // so GPUI maintains the scroll offset between frames. - if needs_scroll_x || needs_scroll_y { - let handle = ctx - .scroll_handles - .entry(element.id) - .or_insert_with(gpui::ScrollHandle::new); - el = el.track_scroll(handle); - } else { - // Element is no longer scrollable — remove stale handle. - ctx.scroll_handles.remove(&element.id); - } - } else { - // No style at all — remove stale handle if it existed. - ctx.scroll_handles.remove(&element.id); - } - - // If a FocusHandle was pre-created for this element (by sync_focus_handles), - // attach it via track_focus. This makes the element focusable — clicking it - // or tabbing to it gives it keyboard focus. The handle persists across renders - // because it's stored in GpuixView::focus_handles. - if style.and_then(|style| style.position.as_deref()).is_none() { - el = el.relative(); - } - el = el.child(crate::automation::bounds_tracker( - element.id, - selection_start_flag(style), - )); - - if let Some(handle) = ctx.focus_handles.get(&element.id) { - el = el.track_focus(handle); - } - if let Some(tab_index) = element - .custom_props - .get("tabIndex") - .and_then(|value| value.as_i64()) - .and_then(|index| isize::try_from(index).ok()) - { - el = el.tab_index(tab_index).tab_stop(tab_index >= 0); - } - - // Wire up events. - // Some events (on_hover, on_click) require a stateful element (.id()), - // which we already set above. Others (on_mouse_down, on_key_down) work - // on any InteractiveElement. - for event_type in &element.events { - let id = element.id; - let callback = ctx.event_callback.clone(); - match event_type.as_str() { - // ── Click ──────────────────────────────────────────── - "click" => { - el = el.on_click(move |click_event, _window, _cx| { - emit_event_full(&callback, id, "click", |p| { - let (x, y) = point_to_xy(click_event.position()); - p.x = Some(x); - p.y = Some(y); - p.modifiers = Some(click_event.modifiers().into()); - p.click_count = Some(click_event.click_count() as u32); - p.is_right_click = Some(click_event.is_right_click()); - }); - }); - } - - // ── Mouse down (all buttons) ───────────────────────── - "mouseDown" => { - // Wire all three buttons so JS gets right-click, middle-click, etc. - for &button in &[ - gpui::MouseButton::Left, - gpui::MouseButton::Middle, - gpui::MouseButton::Right, - ] { - let callback = callback.clone(); - el = el.on_mouse_down(button, move |mouse_event, _window, _cx| { - emit_event_full(&callback, id, "mouseDown", |p| { - let (x, y) = point_to_xy(mouse_event.position); - p.x = Some(x); - p.y = Some(y); - p.button = Some(mouse_button_to_u32(mouse_event.button)); - p.click_count = Some(mouse_event.click_count as u32); - p.modifiers = Some(mouse_event.modifiers.into()); - }); - }); - } - } - - // ── Mouse up (all buttons) ─────────────────────────── - "mouseUp" => { - for &button in &[ - gpui::MouseButton::Left, - gpui::MouseButton::Middle, - gpui::MouseButton::Right, - ] { - let callback = callback.clone(); - el = el.on_mouse_up(button, move |mouse_event, _window, _cx| { - emit_event_full(&callback, id, "mouseUp", |p| { - let (x, y) = point_to_xy(mouse_event.position); - p.x = Some(x); - p.y = Some(y); - p.button = Some(mouse_button_to_u32(mouse_event.button)); - p.click_count = Some(mouse_event.click_count as u32); - p.modifiers = Some(mouse_event.modifiers.into()); - }); - }); - } - } - - // ── Mouse move ─────────────────────────────────────── - "mouseMove" => { - el = el.on_mouse_move(move |mouse_event, _window, _cx| { - emit_event_full(&callback, id, "mouseMove", |p| { - let (x, y) = point_to_xy(mouse_event.position); - p.x = Some(x); - p.y = Some(y); - p.modifiers = Some(mouse_event.modifiers.into()); - p.pressed_button = mouse_event.pressed_button.map(mouse_button_to_u32); - }); - }); - } - - // ── Hover (mouseEnter + mouseLeave) ────────────────── - // GPUI's on_hover fires with true on enter, false on leave. - // We split into two distinct event types for the React side. - "mouseEnter" | "mouseLeave" => { - // Only wire once even if both mouseEnter and mouseLeave are registered. - // Check if we already wired on_hover via the other event. - let has_enter = element.events.contains("mouseEnter"); - let has_leave = element.events.contains("mouseLeave"); - // Wire on first encounter (mouseEnter sorts before mouseLeave). - if event_type.as_str() == "mouseEnter" || !has_enter { - let callback_enter = if has_enter { - ctx.event_callback.clone() - } else { - None - }; - let callback_leave = if has_leave { - ctx.event_callback.clone() - } else { - None - }; - el = el.on_hover(move |&is_hovered, _window, _cx| { - if is_hovered { - emit_event_full(&callback_enter, id, "mouseEnter", |p| { - p.hovered = Some(true); - }); - } else { - emit_event_full(&callback_leave, id, "mouseLeave", |p| { - p.hovered = Some(false); - }); - } - }); - } - } - - // ── Mouse down outside ─────────────────────────────── - // Fires when the user clicks OUTSIDE this element. - // Critical for "click outside to close" pattern (dropdowns, modals). - "mouseDownOutside" => { - el = el.on_mouse_down_out(move |mouse_event, _window, _cx| { - emit_event_full(&callback, id, "mouseDownOutside", |p| { - let (x, y) = point_to_xy(mouse_event.position); - p.x = Some(x); - p.y = Some(y); - p.button = Some(mouse_button_to_u32(mouse_event.button)); - p.modifiers = Some(mouse_event.modifiers.into()); - }); - }); - } - - // ── Scroll wheel ───────────────────────────────────── - "scroll" => { - el = el.on_scroll_wheel(move |scroll_event, _window, _cx| { - emit_event_full(&callback, id, "scroll", |p| { - let (x, y) = point_to_xy(scroll_event.position); - p.x = Some(x); - p.y = Some(y); - p.modifiers = Some(scroll_event.modifiers.into()); - p.precise = Some(scroll_event.delta.precise()); - - // Convert ScrollDelta to pixel values. - // For Lines delta, we use a default line height of 20px. - let line_height = gpui::px(20.0); - let pixel_delta = scroll_event.delta.pixel_delta(line_height); - p.delta_x = Some(f64::from(f32::from(pixel_delta.x))); - p.delta_y = Some(f64::from(f32::from(pixel_delta.y))); - - p.touch_phase = Some(match scroll_event.touch_phase { - gpui::TouchPhase::Started => "started".to_string(), - gpui::TouchPhase::Moved => "moved".to_string(), - gpui::TouchPhase::Ended => "ended".to_string(), - gpui::TouchPhase::Cancelled => "cancelled".to_string(), - }); - }); - }); - } - - // ── Key down ───────────────────────────────────────── - // Requires .focusable() (set above). Element must be focused - // (clicked or tabbed to) for these to fire. - "keyDown" => { - el = el.on_key_down(move |key_event, _window, _cx| { - emit_event_full(&callback, id, "keyDown", |p| { - p.key = Some(key_event.keystroke.key.clone()); - p.key_char = key_event.keystroke.key_char.clone(); - p.is_held = Some(key_event.is_held); - p.modifiers = Some(key_event.keystroke.modifiers.into()); - }); - }); - } - - // ── Key up ─────────────────────────────────────────── - "keyUp" => { - el = el.on_key_up(move |key_event, _window, _cx| { - emit_event_full(&callback, id, "keyUp", |p| { - p.key = Some(key_event.keystroke.key.clone()); - p.key_char = key_event.keystroke.key_char.clone(); - p.modifiers = Some(key_event.keystroke.modifiers.into()); - }); - }); - } - - // ── Focus / Blur ───────────────────────────────────── - // Event emission is handled by FocusHandle subscriptions - // set up in GpuixView::sync_focus_handles(). The handle is - // attached to this element via .track_focus() above. - "focus" | "blur" => {} - - _ => {} - } - } - - // Text content — selectable, same as a leaf. - if let Some(ref content) = element.content { - el = el.child(text_content(element.id, content, ctx)); - } - - // Children - let child_ids: Vec = element.children.clone(); - for child_id in child_ids { - let child = build_element(child_id, ctx, window, cx); - el = if overflow_x_only { - el.child(gpui::div().flex_none().child(child)) - } else { - el.child(child) - }; - } - - el.into_any_element() -} - -/// A selectable text run owned by `element_id`. Runs are left to gpui so the -/// text keeps inheriting colour, weight and family from ancestor styles. -fn text_content(element_id: u64, content: &str, ctx: &BuildCtx) -> gpui::AnyElement { - if !ctx.inherited.selectable { - // Still logged: `getPaintedText()` promises every painted string, and a - // `userSelect: "none"` label is exactly the chrome tests want to assert. - return crate::text::chrome_text(gpui::SharedString::from(content.to_string()), None); - } - selectable_text(crate::text::SelectableText::new( - gpui::SharedString::from(content.to_string()), - None, - selection_key(element_id, 0), - ctx.selection.clone(), - ctx.inherited.selection_wash, - )) -} - -pub(crate) fn build_text( - element: &crate::retained_tree::RetainedElement, - style: Option<&StyleDesc>, - ctx: &mut BuildCtx, - window: &mut gpui::Window, - cx: &mut gpui::Context, -) -> gpui::AnyElement { - use gpui::prelude::*; - - // Fast path: plain text leaf without style. It still goes through - // `text_content` so the glyphs land in the selection registry — the old - // raw-string return was the reason text was not selectable. - if style.is_none() && element.children.is_empty() { - let content = element.content.clone().unwrap_or_default(); - return gpui::div() - .relative() - .child(crate::automation::bounds_tracker(element.id, None)) - .child(text_content(element.id, &content, ctx)) - .into_any_element(); - } - - // The full style set, exactly as `
` gets it. `` used to apply a - // text-only subset, so `padding`, `width` and every layout prop on a text - // node were silently dropped — a hole with no error and no warning. - let mut el = gpui::div(); - if let Some(style) = style { - el = apply_styles(el, style); - } - if style.and_then(|style| style.position.as_deref()).is_none() { - el = el.relative(); - } - el = el.child(crate::automation::bounds_tracker( - element.id, - selection_start_flag(style), - )); - - if let Some(ref content) = element.content { - el = el.child(text_content(element.id, content, ctx)); - } - - let child_ids: Vec = element.children.clone(); - for child_id in child_ids { - el = el.child(build_element(child_id, ctx, window, cx)); - } - - el.into_any_element() -} - -/// Explicit `userSelect` on this node. `None` means inherit; the ancestor -/// that set the value already owns the start region. -fn selection_start_flag(style: Option<&StyleDesc>) -> Option { - match style.and_then(|style| style.user_select.as_deref()) { - Some("none") => Some(false), - Some("text") | Some("auto") => Some(true), - _ => None, - } -} - -// ── Style application ──────────────────────────────────────────────── - -pub(crate) fn apply_width(el: E, dim: &crate::style::DimensionValue) -> E { - match dim { - crate::style::DimensionValue::Pixels(v) => el.w(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) if *v >= 0.999 => el.w_full(), - crate::style::DimensionValue::Percentage(v) => el.w(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => el, - } -} - -pub(crate) fn apply_height(el: E, dim: &crate::style::DimensionValue) -> E { - match dim { - crate::style::DimensionValue::Pixels(v) => el.h(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) if *v >= 0.999 => el.h_full(), - crate::style::DimensionValue::Percentage(v) => el.h(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => el, - } -} - -pub(crate) fn apply_styles(mut el: E, style: &StyleDesc) -> E { - match style.display.as_deref() { - Some("flex") => el = el.flex(), - Some("grid") => el = el.grid(), - _ => {} - } - if let Some(cols) = style.grid_template_columns { - let count = cols.round().clamp(1.0, 64.0) as u16; - el = match style.grid_column_min.as_deref() { - Some("min-content") => el.grid_cols_min_content(count), - Some("max-content") => el.grid_cols_max_content(count), - _ => el.grid_cols(count), - }; - } - if let Some(rows) = style.grid_template_rows { - let count = rows.round().clamp(1.0, 64.0) as u16; - el = match style.grid_row_min.as_deref() { - Some("min-content") => el.grid_rows_min_content(count), - Some("max-content") => el.grid_rows_max_content(count), - _ => el.grid_rows(count), - }; - } - if style.flex_direction.as_deref() == Some("column") { - el = el.flex_col(); - } - if style.flex_direction.as_deref() == Some("row") { - el = el.flex_row(); - } - match style.flex_wrap.as_deref() { - Some("wrap") => el = el.flex_wrap(), - Some("wrap-reverse") => el = el.flex_wrap_reverse(), - Some("nowrap") => el = el.flex_nowrap(), - _ => {} - } - if let Some(grow) = style.flex_grow { - el.style().flex_grow = Some(grow as f32); - } - if let Some(shrink) = style.flex_shrink { - el.style().flex_shrink = Some(shrink as f32); - } - if let Some(basis) = style.flex_basis { - el = el.flex_basis(gpui::px(basis as f32)); - } - match style.align_items.as_deref() { - Some("center") => el = el.items_center(), - Some("start") | Some("flex-start") => el = el.items_start(), - Some("end") | Some("flex-end") => el = el.items_end(), - _ => {} - } - match style.align_content.as_deref() { - Some("center") => el = el.content_center(), - Some("start") | Some("flex-start") => el = el.content_start(), - Some("end") | Some("flex-end") => el = el.content_end(), - Some("between") | Some("space-between") => el = el.content_between(), - Some("around") | Some("space-around") => el = el.content_around(), - Some("evenly") | Some("space-evenly") => el = el.content_evenly(), - Some("stretch") => el = el.content_stretch(), - Some("normal") => el = el.content_normal(), - _ => {} - } - match style.justify_content.as_deref() { - Some("center") => el = el.justify_center(), - Some("start") | Some("flex-start") => el = el.justify_start(), - Some("end") | Some("flex-end") => el = el.justify_end(), - Some("between") | Some("space-between") => el = el.justify_between(), - Some("around") | Some("space-around") => el = el.justify_around(), - _ => {} - } - match style.align_self.as_deref() { - Some("center") => { - el.style().align_self = Some(gpui::AlignItems::Center); - } - Some("start") | Some("flex-start") => { - el.style().align_self = Some(gpui::AlignItems::FlexStart); - } - Some("end") | Some("flex-end") => { - el.style().align_self = Some(gpui::AlignItems::FlexEnd); - } - Some("stretch") => { - el.style().align_self = Some(gpui::AlignItems::Stretch); - } - Some("baseline") => { - el.style().align_self = Some(gpui::AlignItems::Baseline); - } - _ => {} - } - if let Some(gap) = style.gap { - el = el.gap(gpui::px(gap as f32)); - } - // Per-axis gaps were in the style type and implemented nowhere. They come - // after `gap` so the axis value wins, matching CSS shorthand order. - if let Some(gap) = style.row_gap { - el = el.gap_y(gpui::px(gap as f32)); - } - if let Some(gap) = style.column_gap { - el = el.gap_x(gpui::px(gap as f32)); - } - if let Some(ref w) = style.width { - el = apply_width(el, w); - } - if let Some(ref h) = style.height { - el = apply_height(el, h); - } - if let Some(ref min_w) = style.min_width { - match min_w { - crate::style::DimensionValue::Pixels(v) => el = el.min_w(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.min_w(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(ref min_h) = style.min_height { - match min_h { - crate::style::DimensionValue::Pixels(v) => el = el.min_h(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.min_h(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(ref max_w) = style.max_width { - match max_w { - crate::style::DimensionValue::Pixels(v) => el = el.max_w(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.max_w(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(ref max_h) = style.max_height { - match max_h { - crate::style::DimensionValue::Pixels(v) => el = el.max_h(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.max_h(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(p) = style.padding { - el = el.p(gpui::px(p as f32)); - } - if let Some(pt) = style.padding_top { - el = el.pt(gpui::px(pt as f32)); - } - if let Some(pr) = style.padding_right { - el = el.pr(gpui::px(pr as f32)); - } - if let Some(pb) = style.padding_bottom { - el = el.pb(gpui::px(pb as f32)); - } - if let Some(pl) = style.padding_left { - el = el.pl(gpui::px(pl as f32)); - } - if let Some(m) = style.margin { - el = el.m(gpui::px(m as f32)); - } - if let Some(mt) = style.margin_top { - el = el.mt(gpui::px(mt as f32)); - } - if let Some(mr) = style.margin_right { - el = el.mr(gpui::px(mr as f32)); - } - if let Some(mb) = style.margin_bottom { - el = el.mb(gpui::px(mb as f32)); - } - if let Some(ml) = style.margin_left { - el = el.ml(gpui::px(ml as f32)); - } - match style.position.as_deref() { - Some("absolute") => el = el.absolute(), - Some("relative") => el = el.relative(), - _ => {} - } - if let Some(top) = style.top { - el = el.top(gpui::px(top as f32)); - } - if let Some(right) = style.right { - el = el.right(gpui::px(right as f32)); - } - if let Some(bottom) = style.bottom { - el = el.bottom(gpui::px(bottom as f32)); - } - if let Some(left) = style.left { - el = el.left(gpui::px(left as f32)); - } - if let Some(ref bg) = style - .background_color - .as_ref() - .or(style.background.as_ref()) - { - if let Some(color) = crate::color::parse_color_rgba(bg) { - el = el.bg(color); - } - } - if let Some(ref color) = style.color { - if let Some(color) = crate::color::parse_color_rgba(color) { - el = el.text_color(color); - } - } - if let Some(size) = style.font_size { - el = el.text_size(gpui::px(size as f32)); - } - if let Some(ref family) = style.font_family { - el = el.font_family(family.clone()); - } - if let Some(ref weight) = style.font_weight { - el = el.font_weight(parse_font_weight(weight)); - } - // `textAlign` was in the style type but implemented nowhere. - match style.text_align.as_deref() { - Some("center") => el = el.text_center(), - Some("right") => el = el.text_right(), - Some("left") | Some("start") => el = el.text_left(), - _ => {} - } - match style.white_space.as_deref() { - Some("nowrap") => el = el.whitespace_nowrap(), - Some("normal") => el = el.whitespace_normal(), - _ => {} - } - match style.text_overflow.as_deref() { - Some("ellipsis") => el = el.text_ellipsis(), - Some("ellipsis-start") => el = el.text_ellipsis_start(), - _ => {} - } - if let Some(clamp) = style.line_clamp { - if clamp >= 1.0 { - el = el.line_clamp(clamp as usize); - } - } - // `line_height` was accepted by the style type but never applied, so - // multi-line text always used gpui's default leading. - if let Some(line_height) = style.line_height { - if line_height > 0.0 { - el = el.line_height(gpui::px(line_height as f32)); - } - } - if let Some(radius) = style.border_radius { - el = el.rounded(gpui::px(radius as f32)); - } - // Apply corner longhands after the shorthand so the explicit corner wins. - if let Some(radius) = style.border_top_left_radius { - el = el.rounded_tl(gpui::px(radius as f32)); - } - if let Some(radius) = style.border_top_right_radius { - el = el.rounded_tr(gpui::px(radius as f32)); - } - if let Some(radius) = style.border_bottom_left_radius { - el = el.rounded_bl(gpui::px(radius as f32)); - } - if let Some(radius) = style.border_bottom_right_radius { - el = el.rounded_br(gpui::px(radius as f32)); - } - // `borderWidth: 0` must clear a border, not be ignored: an element that - // draws its own border needs a way for the caller to remove it. - if let Some(width) = style.border_width { - el = el.border(gpui::px(width.max(0.0) as f32)); - } - if let Some(width) = style.border_top_width { - el = el.border_t(gpui::px(width.max(0.0) as f32)); - } - if let Some(width) = style.border_right_width { - el = el.border_r(gpui::px(width.max(0.0) as f32)); - } - if let Some(width) = style.border_bottom_width { - el = el.border_b(gpui::px(width.max(0.0) as f32)); - } - if let Some(width) = style.border_left_width { - el = el.border_l(gpui::px(width.max(0.0) as f32)); - } - if let Some(ref color) = style.border_color { - if let Some(color) = crate::color::parse_color_rgba(color) { - el = el.border_color(color); - } - } - if let Some(ref shadow) = style.box_shadow { - if let Some(color) = crate::color::parse_color_rgba(&shadow.color) { - let shadow = gpui::BoxShadow::new( - gpui::px(shadow.offset_x as f32), - gpui::px(shadow.offset_y as f32), - color.into(), - ) - .blur_radius(gpui::px(shadow.blur_radius.max(0.0) as f32)) - .spread_radius(gpui::px(shadow.spread_radius as f32)); - el = el.shadow(vec![shadow]); - } - } - if let Some(opacity) = style.opacity { - el = el.opacity(opacity as f32); - } - match style.cursor.as_deref() { - Some("pointer") => el = el.cursor_pointer(), - Some("default") => el = el.cursor_default(), - _ => {} - } - // Overflow: hidden is on the Styled trait, so we handle it here. - // overflow: "scroll" requires StatefulInteractiveElement — handled in build_div(). - // CSS precedence: axis-specific (overflowX/Y) overrides the shorthand (overflow). - { - let resolved_x = style.overflow_x.as_deref().or(style.overflow.as_deref()); - let resolved_y = style.overflow_y.as_deref().or(style.overflow.as_deref()); - // Only apply hidden here — scroll is handled in build_div. - if resolved_x == Some("hidden") && resolved_y == Some("hidden") { - el = el.overflow_hidden(); - } else if resolved_x == Some("hidden") { - el = el.overflow_x_hidden(); - } else if resolved_y == Some("hidden") { - el = el.overflow_y_hidden(); - } - } - - el -} // ── Event emission ─────────────────────────────────────────────────── @@ -3313,271 +2046,6 @@ pub(crate) fn emit_event_full( } } -// ── Batch processing ───────────────────────────────────────────── - -/// Parsed batch operation — typed enum for atomic validation. -/// All ops are parsed and validated BEFORE any tree mutation occurs. -/// This prevents partial application on malformed batches. -enum BatchOp { - CreateElement { - id: u64, - element_type: String, - }, - DestroyElement { - id: u64, - }, - AppendChild { - parent_id: u64, - child_id: u64, - }, - RemoveChild { - parent_id: u64, - child_id: u64, - }, - InsertBefore { - parent_id: u64, - child_id: u64, - before_id: u64, - }, - SetStyle { - id: u64, - style: StyleDesc, - }, - SetText { - id: u64, - content: String, - }, - SetEventListener { - id: u64, - event_type: String, - has_handler: bool, - }, - SetRoot { - id: u64, - }, - SetCustomProp { - id: u64, - key: String, - value: serde_json::Value, - }, -} - -/// Parse all batch ops from JSON into typed enums. -/// Returns Err on the first invalid op — no tree mutation has occurred yet. -fn parse_batch_ops(ops: &[serde_json::Value]) -> Result> { - let mut parsed = Vec::with_capacity(ops.len()); - - for (i, op) in ops.iter().enumerate() { - let arr = op - .as_array() - .ok_or_else(|| Error::from_reason(format!("Batch op {} is not an array", i)))?; - let op_name = arr - .first() - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::from_reason(format!("Batch op {} missing op name string", i)))?; - - let batch_op = match op_name { - "createElement" => BatchOp::CreateElement { - id: batch_id(arr, 1, i)?, - element_type: batch_str(arr, 2, i)?, - }, - "destroyElement" => BatchOp::DestroyElement { - id: batch_id(arr, 1, i)?, - }, - "appendChild" => BatchOp::AppendChild { - parent_id: batch_id(arr, 1, i)?, - child_id: batch_id(arr, 2, i)?, - }, - "removeChild" => BatchOp::RemoveChild { - parent_id: batch_id(arr, 1, i)?, - child_id: batch_id(arr, 2, i)?, - }, - "insertBefore" => BatchOp::InsertBefore { - parent_id: batch_id(arr, 1, i)?, - child_id: batch_id(arr, 2, i)?, - before_id: batch_id(arr, 3, i)?, - }, - "setStyle" => { - let style: StyleDesc = batch_decode(arr, 2, i).map_err(|e| { - Error::from_reason(format!("Batch op {} setStyle parse error: {}", i, e)) - })?; - BatchOp::SetStyle { - id: batch_id(arr, 1, i)?, - style, - } - } - "setText" => BatchOp::SetText { - id: batch_id(arr, 1, i)?, - content: batch_str(arr, 2, i)?, - }, - "setEventListener" => { - let has_handler = arr - .get(3) - .and_then(|v| v.as_bool().or_else(|| v.as_u64().map(|n| n != 0))) - .ok_or_else(|| { - Error::from_reason(format!( - "Batch op {} setEventListener missing/invalid hasHandler at index 3", - i - )) - })?; - BatchOp::SetEventListener { - id: batch_id(arr, 1, i)?, - event_type: batch_str(arr, 2, i)?, - has_handler, - } - } - "setRoot" => BatchOp::SetRoot { - id: batch_id(arr, 1, i)?, - }, - "setCustomProp" => BatchOp::SetCustomProp { - id: batch_id(arr, 1, i)?, - key: batch_str(arr, 2, i)?, - value: batch_payload(arr, 3, i)?, - }, - "setCustomPropValue" => BatchOp::SetCustomProp { - id: batch_id(arr, 1, i)?, - key: batch_str(arr, 2, i)?, - value: arr.get(3).cloned().ok_or_else(|| { - Error::from_reason(format!("Batch op {} missing custom prop value", i)) - })?, - }, - _ => { - return Err(Error::from_reason(format!( - "Batch op {} unknown operation: {:?}", - i, op_name - ))); - } - }; - parsed.push(batch_op); - } - - Ok(parsed) -} - -/// Apply a batch of mutation tuples to a RetainedTree. -/// Shared between GpuixRenderer::apply_batch and TestGpuixRenderer::apply_batch. -/// Returns accumulated destroyed IDs (as f64) from all destroyElement ops. -/// -/// ATOMIC: all ops are parsed and validated first. If any op is malformed, -/// the tree is left unchanged and an error is returned. This prevents -/// partial application that could desync JS and Rust state. -/// -/// Batch format: JSON array of tuples [opcode, ...args]. -/// See GpuixRenderer::apply_batch for opcode documentation. -pub(crate) fn apply_batch_to_tree( - tree: &mut RetainedTree, - ops: &[serde_json::Value], -) -> Result> { - // Phase 1: parse and validate all ops (no mutation). - let parsed = parse_batch_ops(ops)?; - - // Phase 2: apply all validated ops to the tree. - let mut destroyed_ids: Vec = Vec::new(); - for batch_op in parsed { - match batch_op { - BatchOp::CreateElement { id, element_type } => { - tree.create_element(id, element_type); - } - BatchOp::DestroyElement { id } => { - let destroyed = tree.destroy_element(id); - destroyed_ids.extend(destroyed.iter().map(|&id| id as f64)); - } - BatchOp::AppendChild { - parent_id, - child_id, - } => { - tree.append_child(parent_id, child_id); - } - BatchOp::RemoveChild { - parent_id, - child_id, - } => { - tree.remove_child(parent_id, child_id); - } - BatchOp::InsertBefore { - parent_id, - child_id, - before_id, - } => { - tree.insert_before(parent_id, child_id, before_id); - } - BatchOp::SetStyle { id, style } => { - tree.set_style(id, style); - } - BatchOp::SetText { id, content } => { - tree.set_text(id, content); - } - BatchOp::SetEventListener { - id, - event_type, - has_handler, - } => { - tree.set_event_listener(id, event_type, has_handler); - } - BatchOp::SetRoot { id } => { - tree.root_id = Some(id); - } - BatchOp::SetCustomProp { id, key, value } => { - tree.set_custom_prop(id, key, value); - } - } - } - - Ok(destroyed_ids) -} - -/// Extract a u64 element ID from a batch tuple at the given index. -fn batch_id(arr: &[serde_json::Value], idx: usize, op_idx: usize) -> Result { - let v = arr.get(idx).and_then(|v| v.as_f64()).ok_or_else(|| { - Error::from_reason(format!("Batch op {} missing id at index {}", op_idx, idx)) - })?; - to_element_id(v) -} - -/// A style or custom-prop payload. Objects land as JSON. Legacy batches -/// still send a JSON string and get decoded here. -fn batch_payload( - arr: &[serde_json::Value], - idx: usize, - op_idx: usize, -) -> Result { - let value = arr.get(idx).ok_or_else(|| { - Error::from_reason(format!( - "Batch op {} missing value at index {}", - op_idx, idx - )) - })?; - if let Some(encoded) = value.as_str() { - match serde_json::from_str(encoded) { - Ok(parsed) => Ok(parsed), - Err(_) => Ok(serde_json::Value::String(encoded.to_string())), - } - } else { - Ok(value.clone()) - } -} - -fn batch_decode( - arr: &[serde_json::Value], - idx: usize, - op_idx: usize, -) -> Result { - let value = batch_payload(arr, idx, op_idx)?; - serde_json::from_value(value).map_err(|e| Error::from_reason(e.to_string())) -} - -/// Extract a String from a batch tuple at the given index. -fn batch_str(arr: &[serde_json::Value], idx: usize, op_idx: usize) -> Result { - arr.get(idx) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| { - Error::from_reason(format!( - "Batch op {} missing string at index {}", - op_idx, idx - )) - }) -} // ── Types ──────────────────────────────────────────────────────────── diff --git a/packages/native/src/renderer/batch.rs b/packages/native/src/renderer/batch.rs new file mode 100644 index 00000000..add62c1a --- /dev/null +++ b/packages/native/src/renderer/batch.rs @@ -0,0 +1,267 @@ +//! Turning one batch of React mutations into tree operations. +//! +//! Every op is parsed and checked before any of them touches the tree, so a +//! malformed batch changes nothing rather than applying half of itself. + +use napi::bindgen_prelude::*; + +use super::to_element_id; +use crate::retained_tree::RetainedTree; +use crate::style::StyleDesc; + +/// Parsed batch operation — typed enum for atomic validation. +/// All ops are parsed and validated BEFORE any tree mutation occurs. +/// This prevents partial application on malformed batches. +pub(super) enum BatchOp { + CreateElement { + id: u64, + element_type: String, + }, + DestroyElement { + id: u64, + }, + AppendChild { + parent_id: u64, + child_id: u64, + }, + RemoveChild { + parent_id: u64, + child_id: u64, + }, + InsertBefore { + parent_id: u64, + child_id: u64, + before_id: u64, + }, + SetStyle { + id: u64, + /// Boxed, or every op in the batch would be as wide as a `StyleDesc`. + style: Box, + }, + SetText { + id: u64, + content: String, + }, + SetEventListener { + id: u64, + event_type: String, + has_handler: bool, + }, + SetRoot { + id: u64, + }, + SetCustomProp { + id: u64, + key: String, + value: serde_json::Value, + }, +} + +/// Parse all batch ops from JSON into typed enums. +/// Returns Err on the first invalid op — no tree mutation has occurred yet. +pub(super) fn parse_batch_ops(ops: &[serde_json::Value]) -> Result> { + let mut parsed = Vec::with_capacity(ops.len()); + + for (i, op) in ops.iter().enumerate() { + let arr = op + .as_array() + .ok_or_else(|| Error::from_reason(format!("Batch op {} is not an array", i)))?; + let op_name = arr + .first() + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::from_reason(format!("Batch op {} missing op name string", i)))?; + + let batch_op = match op_name { + "createElement" => BatchOp::CreateElement { + id: batch_id(arr, 1, i)?, + element_type: batch_str(arr, 2, i)?, + }, + "destroyElement" => BatchOp::DestroyElement { + id: batch_id(arr, 1, i)?, + }, + "appendChild" => BatchOp::AppendChild { + parent_id: batch_id(arr, 1, i)?, + child_id: batch_id(arr, 2, i)?, + }, + "removeChild" => BatchOp::RemoveChild { + parent_id: batch_id(arr, 1, i)?, + child_id: batch_id(arr, 2, i)?, + }, + "insertBefore" => BatchOp::InsertBefore { + parent_id: batch_id(arr, 1, i)?, + child_id: batch_id(arr, 2, i)?, + before_id: batch_id(arr, 3, i)?, + }, + "setStyle" => { + let value = batch_payload(arr, 2, i)?; + let style = StyleDesc::deserialize_boxed(value).map_err(|e| { + Error::from_reason(format!("Batch op {} setStyle parse error: {}", i, e)) + })?; + BatchOp::SetStyle { + id: batch_id(arr, 1, i)?, + style, + } + } + "setText" => BatchOp::SetText { + id: batch_id(arr, 1, i)?, + content: batch_str(arr, 2, i)?, + }, + "setEventListener" => { + let has_handler = arr + .get(3) + .and_then(|v| v.as_bool().or_else(|| v.as_u64().map(|n| n != 0))) + .ok_or_else(|| { + Error::from_reason(format!( + "Batch op {} setEventListener missing/invalid hasHandler at index 3", + i + )) + })?; + BatchOp::SetEventListener { + id: batch_id(arr, 1, i)?, + event_type: batch_str(arr, 2, i)?, + has_handler, + } + } + "setRoot" => BatchOp::SetRoot { + id: batch_id(arr, 1, i)?, + }, + "setCustomProp" => BatchOp::SetCustomProp { + id: batch_id(arr, 1, i)?, + key: batch_str(arr, 2, i)?, + value: batch_payload(arr, 3, i)?, + }, + "setCustomPropValue" => BatchOp::SetCustomProp { + id: batch_id(arr, 1, i)?, + key: batch_str(arr, 2, i)?, + value: arr.get(3).cloned().ok_or_else(|| { + Error::from_reason(format!("Batch op {} missing custom prop value", i)) + })?, + }, + _ => { + return Err(Error::from_reason(format!( + "Batch op {} unknown operation: {:?}", + i, op_name + ))); + } + }; + parsed.push(batch_op); + } + + Ok(parsed) +} + +/// Apply a batch of mutation tuples to a RetainedTree. +/// Shared between GpuixRenderer::apply_batch and TestGpuixRenderer::apply_batch. +/// Returns accumulated destroyed IDs (as f64) from all destroyElement ops. +/// +/// ATOMIC: all ops are parsed and validated first. If any op is malformed, +/// the tree is left unchanged and an error is returned. This prevents +/// partial application that could desync JS and Rust state. +/// +/// Batch format: JSON array of tuples [opcode, ...args]. +/// See GpuixRenderer::apply_batch for opcode documentation. +pub(crate) fn apply_batch_to_tree( + tree: &mut RetainedTree, + ops: &[serde_json::Value], +) -> Result> { + // Phase 1: parse and validate all ops (no mutation). + let parsed = parse_batch_ops(ops)?; + + // Phase 2: apply all validated ops to the tree. + let mut destroyed_ids: Vec = Vec::new(); + for batch_op in parsed { + match batch_op { + BatchOp::CreateElement { id, element_type } => { + tree.create_element(id, element_type); + } + BatchOp::DestroyElement { id } => { + let destroyed = tree.destroy_element(id); + destroyed_ids.extend(destroyed.iter().map(|&id| id as f64)); + } + BatchOp::AppendChild { + parent_id, + child_id, + } => { + tree.append_child(parent_id, child_id); + } + BatchOp::RemoveChild { + parent_id, + child_id, + } => { + tree.remove_child(parent_id, child_id); + } + BatchOp::InsertBefore { + parent_id, + child_id, + before_id, + } => { + tree.insert_before(parent_id, child_id, before_id); + } + BatchOp::SetStyle { id, style } => { + tree.set_style(id, style); + } + BatchOp::SetText { id, content } => { + tree.set_text(id, content); + } + BatchOp::SetEventListener { + id, + event_type, + has_handler, + } => { + tree.set_event_listener(id, event_type, has_handler); + } + BatchOp::SetRoot { id } => { + tree.root_id = Some(id); + } + BatchOp::SetCustomProp { id, key, value } => { + tree.set_custom_prop(id, key, value); + } + } + } + + Ok(destroyed_ids) +} + +/// Extract a u64 element ID from a batch tuple at the given index. +fn batch_id(arr: &[serde_json::Value], idx: usize, op_idx: usize) -> Result { + let v = arr.get(idx).and_then(|v| v.as_f64()).ok_or_else(|| { + Error::from_reason(format!("Batch op {} missing id at index {}", op_idx, idx)) + })?; + to_element_id(v) +} + +/// A style or custom-prop payload. Objects land as JSON. Legacy batches +/// still send a JSON string and get decoded here. +fn batch_payload( + arr: &[serde_json::Value], + idx: usize, + op_idx: usize, +) -> Result { + let value = arr.get(idx).ok_or_else(|| { + Error::from_reason(format!( + "Batch op {} missing value at index {}", + op_idx, idx + )) + })?; + if let Some(encoded) = value.as_str() { + match serde_json::from_str(encoded) { + Ok(parsed) => Ok(parsed), + Err(_) => Ok(serde_json::Value::String(encoded.to_string())), + } + } else { + Ok(value.clone()) + } +} + +/// Extract a String from a batch tuple at the given index. +fn batch_str(arr: &[serde_json::Value], idx: usize, op_idx: usize) -> Result { + arr.get(idx) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + Error::from_reason(format!( + "Batch op {} missing string at index {}", + op_idx, idx + )) + }) +} diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs new file mode 100644 index 00000000..1b5c4530 --- /dev/null +++ b/packages/native/src/renderer/frame.rs @@ -0,0 +1,711 @@ +//! Building one frame of GPUI elements from the retained tree. +//! +//! GPUI is immediate mode, so every frame walks the retained tree and returns a +//! fresh element for each node. This module is that walk. It holds the context +//! the recursion threads through, the virtual list windowing that decides which +//! rows exist this frame, and the builder for each element type. + +use std::collections::{HashMap, HashSet}; + +use super::virtual_list::{window_start_from_element, VirtualListConfig, VirtualListEntry}; +use super::{emit_event_full, mouse_button_to_u32, point_to_xy, EventCallback, GpuixView}; +use crate::custom_elements::{CustomElementRegistry, CustomRenderContext}; +use crate::retained_tree::RetainedTree; +use crate::style::StyleDesc; +use crate::text::{selectable_text, selection_key, SharedSelection}; + +/// Everything `build_element` threads through the tree. +/// +/// Split into a struct because the recursion needs eight-plus shared references +/// and adding one more to every call site is how this file rots. `window` and +/// `cx` stay separate parameters: they are `&mut` and gpui reborrows them. +pub(super) struct BuildCtx<'a> { + pub tree: &'a RetainedTree, + pub event_callback: &'a Option, + pub focus_handles: &'a HashMap, + pub scroll_handles: &'a mut HashMap, + pub custom_registry: &'a mut CustomElementRegistry, + pub virtual_lists: &'a mut HashMap, + pub motion_states: &'a mut HashMap, + pub now: std::time::Instant, + pub motion_active: &'a mut bool, + pub selection: SharedSelection, + /// What this element inherits from its ancestors, resolved the way CSS + /// inherits it. The renderer's own theme only seeds the root selection + /// wash. Custom elements resolve their own theme from their `theme` prop. + pub cascade: crate::inheritance::Inherited, +} + +// ── Element builders ───────────────────────────────────────────────── + +pub(super) fn build_element( + id: u64, + ctx: &mut BuildCtx, + window: &mut gpui::Window, + cx: &mut gpui::Context, +) -> gpui::AnyElement { + use gpui::IntoElement; + + let Some(element) = ctx.tree.elements.get(&id) else { + return gpui::Empty.into_any_element(); + }; + + // The motion frame for this element, or `None` when it does not animate. + let motion = if let Some(source) = element.custom_props.get("motion") { + let state = match ctx.motion_states.entry(id) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => { + match crate::motion::MotionState::new(source, ctx.now) { + Ok(state) => entry.insert(state), + Err(error) => { + log::warn!("Invalid motion description for element {id}: {error}"); + entry.insert(crate::motion::MotionState::invalid(source, ctx.now)) + } + } + } + }; + if let Err(error) = state.sync(source, ctx.now) { + log::warn!("Invalid motion update for element {id}: {error}"); + } + state.is_valid().then(|| { + let frame = state.frame(ctx.now); + *ctx.motion_active |= frame.active; + frame.style + }) + } else { + ctx.motion_states.remove(&id); + None + }; + let style = element.style.as_deref(); + + // Inheritable style resolves before the element's own style, because a + // custom property declared here is in scope for the `var()` next to it. + let parent_cascade = ctx.cascade.clone(); + ctx.cascade = element.descend(&parent_cascade); + + // Resolve the style into a GPUI StyleRefinement. GPUI rebuilds its element + // tree every frame, so this is the work that used to repeat every frame for + // styles that had not changed. An animated element reads the same cache, + // because its motion frame lands on the sink rather than on the style it + // resolves from. + let resolved = element.resolved_style(&ctx.cascade); + + let built = match element.element_type.as_str() { + "div" => { + ctx.custom_registry.destroy(id); + build_div(element, style, resolved, motion, ctx, window, cx) + } + "text" => { + ctx.custom_registry.destroy(id); + build_text(element, style, resolved, motion, ctx, window, cx) + } + "virtual-list" => { + ctx.custom_registry.destroy(id); + build_virtual_list(element, ctx, window, cx) + } + + // Polymorphic dispatch for all custom elements. + custom_type => { + // Custom renderers take a `StyleDesc` and resolve it themselves, so + // a motion frame reaches them folded into one. They are the only + // callers that still pay for that fold. + let animated = motion.map(|motion| { + let mut declared = element.style.clone().unwrap_or_default(); + motion.apply_to(&mut declared); + declared + }); + let style = animated.as_deref().or(style); + let custom_children: Vec = element + .children + .iter() + .copied() + .filter(|child_id| ctx.tree.elements.contains_key(child_id)) + .map(|child_id| build_element(child_id, ctx, window, cx)) + .collect(); + let cascade = ctx.cascade.clone(); + let render_ctx = CustomRenderContext { + id, + events: &element.events, + event_callback: ctx.event_callback, + focus_handle: ctx.focus_handles.get(&id), + style, + children: custom_children, + selection: ctx.selection.clone(), + selectable: cascade.selectable(), + selection_wash: crate::color::to_hsla(cascade.selection_wash()), + cascade: cascade.clone(), + }; + ctx.custom_registry.render( + custom_type, + &element.custom_props, + render_ctx, + window, + cx, + ) + } + }; + + ctx.cascade = parent_cascade; + built +} + +fn build_virtual_list( + element: &crate::retained_tree::RetainedElement, + ctx: &mut BuildCtx, + window: &mut gpui::Window, + cx: &mut gpui::Context, +) -> gpui::AnyElement { + use gpui::prelude::*; + + let child_ids: Vec = element + .children + .iter() + .copied() + .filter(|child_id| ctx.tree.elements.contains_key(child_id)) + .collect(); + let child_revisions: Vec = child_ids + .iter() + .filter_map(|child_id| { + ctx.tree + .elements + .get(child_id) + .map(|child| child.subtree_revision) + }) + .collect(); + let focusable_rows: HashSet = ctx + .focus_handles + .keys() + .filter_map(|element_id| virtual_row_ancestor(ctx.tree, element.id, *element_id)) + .collect(); + let focused_row = ctx + .focus_handles + .iter() + .find_map(|(element_id, handle)| { + handle + .is_focused(window) + .then(|| virtual_row_ancestor(ctx.tree, element.id, *element_id)) + .flatten() + }) + .or_else(|| { + ctx.focus_handles.keys().find_map(|element_id| { + ctx.tree + .elements + .get(element_id) + .is_some_and(|element| element.auto_focus) + .then(|| virtual_row_ancestor(ctx.tree, element.id, *element_id)) + .flatten() + }) + }); + let config = VirtualListConfig::from_element(element); + let window_start = window_start_from_element(element); + let list_state = match ctx.virtual_lists.entry(element.id) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().sync( + config, + window_start, + child_ids.clone(), + child_revisions, + &focusable_rows, + cx, + ); + let entry = entry.into_mut(); + if let Some(row_id) = focused_row.filter(|row_id| !entry.seen_rows.contains(row_id)) { + if let Some(index) = entry.logical_index_of(row_id) { + entry.state.scroll_to(gpui::ListOffset { + item_ix: index, + offset_in_item: gpui::px(0.0), + }); + } + } + entry.state.clone() + } + std::collections::hash_map::Entry::Vacant(entry) => { + let row_focus_handles = child_ids + .iter() + .map(|id| focusable_rows.contains(id).then(|| cx.focus_handle())) + .collect(); + let entry = entry.insert(VirtualListEntry::new( + config, + window_start, + child_ids.clone(), + child_revisions, + row_focus_handles, + )); + if let Some(row_id) = focused_row { + if let Some(index) = entry.logical_index_of(row_id) { + entry.state.scroll_to(gpui::ListOffset { + item_ix: index, + offset_in_item: gpui::px(0.0), + }); + } + } + entry.state.clone() + } + }; + + if element.events.contains("visibleRange") { + let callback = ctx.event_callback.clone(); + let list_id = element.id; + list_state.set_scroll_handler(move |event, _window, _cx| { + emit_event_full(&callback, list_id, "visibleRange", |payload| { + payload.start_index = Some(event.visible_range.start as f64); + payload.end_index = Some(event.visible_range.end as f64); + }); + }); + } + + let list_id = element.id; + let cascade = ctx.cascade.clone(); + let render_item = cx.processor(move |view, index: usize, window, cx| { + let Some(child_id) = view + .virtual_lists + .get(&list_id) + .and_then(|entry| entry.child_at(index)) + else { + return gpui::Empty.into_any_element(); + }; + view.build_virtual_child(list_id, index, child_id, cascade.clone(), window, cx) + }); + let mut list = + gpui::list(list_state, render_item).with_sizing_behavior(gpui::ListSizingBehavior::Auto); + if let Some(resolved) = element.resolved_style(&ctx.cascade) { + list = crate::style::resolve::apply_resolved(list, &resolved.base); + } + list.into_any_element() +} + +fn virtual_row_ancestor(tree: &RetainedTree, list_id: u64, element_id: u64) -> Option { + let mut current = element_id; + loop { + let parent = tree.elements.get(¤t)?.parent?; + if parent == list_id { + return Some(current); + } + current = parent; + } +} + +pub(crate) fn build_div( + element: &crate::retained_tree::RetainedElement, + style: Option<&StyleDesc>, + resolved: Option>, + motion: Option, + ctx: &mut BuildCtx, + window: &mut gpui::Window, + cx: &mut gpui::Context, +) -> gpui::AnyElement { + use gpui::prelude::*; + + let element_id_str = format!("__gpuix_{}", element.id); + let mut el = gpui::div().id(gpui::SharedString::from(element_id_str)); + + if let Some(resolved) = resolved { + el = crate::style::resolve::apply_resolved(el, &resolved.base); + + // State pseudo-classes. GPUI evaluates these itself, so none of them + // waits for React. Each takes a closure that receives a + // StyleRefinement and returns it, and the closure has to be 'static, + // so each one holds a clone of the shared resolved style. + // + // This loop is the dispatcher for states. Adding one is a variant on + // `State` and an arm here. + use crate::style::resolve::State; + // Collect the tags first. Iterating `states` directly would borrow + // `resolved` across the closures, and cloning the pairs would copy a + // whole refinement per state to read a one-byte tag. + let states: Vec = resolved.states.iter().map(|(state, _)| *state).collect(); + for state in states { + let held = resolved.clone(); + let apply = move |refinement: gpui::StyleRefinement| match held.state(state) { + Some(declared) => crate::style::resolve::apply_resolved(refinement, declared), + None => refinement, + }; + el = match state { + State::Hover => el.hover(apply), + State::Active => el.active(apply), + }; + } + } + + if let Some(motion) = motion { + el = crate::style::resolve::apply_motion(el, motion, style); + } + + if let Some(style) = style { + if crate::style::should_occlude(style) { + // BlockMouse (occlude) stops the hit test. The parent scroller + // then never sees the wheel. In-flow fills must use + // BlockMouseExceptScroll. Keep occlude for overlays that steal + // the pointer: absolute, fixed, or pointerEvents: "auto". + let steal_scroll = + matches!(style.position.as_deref(), Some("absolute") | Some("fixed")) + || style.pointer_events.as_deref() == Some("auto"); + el = if steal_scroll { + el.occlude() + } else { + el.block_mouse_except_scroll() + }; + } + } + + // ── Overflow: scroll ───────────────────────────────────────────── + // overflow_scroll() requires StatefulInteractiveElement (only on Stateful
), + // so we handle it here rather than in apply_styles (which takes E: Styled). + // + // CSS precedence: axis-specific props (overflowX/Y) override the shorthand + // (overflow). E.g. { overflow: "scroll", overflowY: "hidden" } → scroll X only. + // + // overflow-x only works as a flex viewport. Default display is Block, so a + // wide child fills the parent instead of overflowing. Zed's code-block path: + // flex + min_w_0 on the scroller, flex_none on the child. + let mut overflow_x_only = false; + if let Some(style) = style { + // Resolve each axis: axis-specific overrides shorthand. + let resolved_x = style.overflow_x.as_deref().or(style.overflow.as_deref()); + let resolved_y = style.overflow_y.as_deref().or(style.overflow.as_deref()); + + let needs_scroll_x = resolved_x == Some("scroll"); + let needs_scroll_y = resolved_y == Some("scroll"); + + if needs_scroll_x && needs_scroll_y { + el = el.overflow_scroll(); + } else if needs_scroll_x { + overflow_x_only = true; + el = el + .flex() + .min_w_0() + .overflow_x_scroll() + .restrict_scroll_to_axis(); + } else if needs_scroll_y { + el = el.overflow_y_scroll(); + } + + // Attach a persistent ScrollHandle when scrolling is enabled. + // The handle persists across renders (stored in GpuixView::scroll_handles) + // so GPUI maintains the scroll offset between frames. + if needs_scroll_x || needs_scroll_y { + let handle = ctx + .scroll_handles + .entry(element.id) + .or_insert_with(gpui::ScrollHandle::new); + el = el.track_scroll(handle); + } else { + // Element is no longer scrollable — remove stale handle. + ctx.scroll_handles.remove(&element.id); + } + } else { + // No style at all — remove stale handle if it existed. + ctx.scroll_handles.remove(&element.id); + } + + // If a FocusHandle was pre-created for this element (by sync_focus_handles), + // attach it via track_focus. This makes the element focusable — clicking it + // or tabbing to it gives it keyboard focus. The handle persists across renders + // because it's stored in GpuixView::focus_handles. + if style.and_then(|style| style.position.as_deref()).is_none() { + el = el.relative(); + } + el = el.child(crate::automation::bounds_tracker( + element.id, + selection_start_flag(style), + )); + + if let Some(handle) = ctx.focus_handles.get(&element.id) { + el = el.track_focus(handle); + } + if let Some(tab_index) = element + .custom_props + .get("tabIndex") + .and_then(|value| value.as_i64()) + .and_then(|index| isize::try_from(index).ok()) + { + el = el.tab_index(tab_index).tab_stop(tab_index >= 0); + } + + // Wire up events. + // Some events (on_hover, on_click) require a stateful element (.id()), + // which we already set above. Others (on_mouse_down, on_key_down) work + // on any InteractiveElement. + for event_type in &element.events { + let id = element.id; + let callback = ctx.event_callback.clone(); + match event_type.as_str() { + // ── Click ──────────────────────────────────────────── + "click" => { + el = el.on_click(move |click_event, _window, _cx| { + emit_event_full(&callback, id, "click", |p| { + let (x, y) = point_to_xy(click_event.position()); + p.x = Some(x); + p.y = Some(y); + p.modifiers = Some(click_event.modifiers().into()); + p.click_count = Some(click_event.click_count() as u32); + p.is_right_click = Some(click_event.is_right_click()); + }); + }); + } + + // ── Mouse down (all buttons) ───────────────────────── + "mouseDown" => { + // Wire all three buttons so JS gets right-click, middle-click, etc. + for &button in &[ + gpui::MouseButton::Left, + gpui::MouseButton::Middle, + gpui::MouseButton::Right, + ] { + let callback = callback.clone(); + el = el.on_mouse_down(button, move |mouse_event, _window, _cx| { + emit_event_full(&callback, id, "mouseDown", |p| { + let (x, y) = point_to_xy(mouse_event.position); + p.x = Some(x); + p.y = Some(y); + p.button = Some(mouse_button_to_u32(mouse_event.button)); + p.click_count = Some(mouse_event.click_count as u32); + p.modifiers = Some(mouse_event.modifiers.into()); + }); + }); + } + } + + // ── Mouse up (all buttons) ─────────────────────────── + "mouseUp" => { + for &button in &[ + gpui::MouseButton::Left, + gpui::MouseButton::Middle, + gpui::MouseButton::Right, + ] { + let callback = callback.clone(); + el = el.on_mouse_up(button, move |mouse_event, _window, _cx| { + emit_event_full(&callback, id, "mouseUp", |p| { + let (x, y) = point_to_xy(mouse_event.position); + p.x = Some(x); + p.y = Some(y); + p.button = Some(mouse_button_to_u32(mouse_event.button)); + p.click_count = Some(mouse_event.click_count as u32); + p.modifiers = Some(mouse_event.modifiers.into()); + }); + }); + } + } + + // ── Mouse move ─────────────────────────────────────── + "mouseMove" => { + el = el.on_mouse_move(move |mouse_event, _window, _cx| { + emit_event_full(&callback, id, "mouseMove", |p| { + let (x, y) = point_to_xy(mouse_event.position); + p.x = Some(x); + p.y = Some(y); + p.modifiers = Some(mouse_event.modifiers.into()); + p.pressed_button = mouse_event.pressed_button.map(mouse_button_to_u32); + }); + }); + } + + // ── Hover (mouseEnter + mouseLeave) ────────────────── + // GPUI's on_hover fires with true on enter, false on leave. + // We split into two distinct event types for the React side. + "mouseEnter" | "mouseLeave" => { + // Only wire once even if both mouseEnter and mouseLeave are registered. + // Check if we already wired on_hover via the other event. + let has_enter = element.events.contains("mouseEnter"); + let has_leave = element.events.contains("mouseLeave"); + // Wire on first encounter (mouseEnter sorts before mouseLeave). + if event_type.as_str() == "mouseEnter" || !has_enter { + let callback_enter = if has_enter { + ctx.event_callback.clone() + } else { + None + }; + let callback_leave = if has_leave { + ctx.event_callback.clone() + } else { + None + }; + el = el.on_hover(move |&is_hovered, _window, _cx| { + if is_hovered { + emit_event_full(&callback_enter, id, "mouseEnter", |p| { + p.hovered = Some(true); + }); + } else { + emit_event_full(&callback_leave, id, "mouseLeave", |p| { + p.hovered = Some(false); + }); + } + }); + } + } + + // ── Mouse down outside ─────────────────────────────── + // Fires when the user clicks OUTSIDE this element. + // Critical for "click outside to close" pattern (dropdowns, modals). + "mouseDownOutside" => { + el = el.on_mouse_down_out(move |mouse_event, _window, _cx| { + emit_event_full(&callback, id, "mouseDownOutside", |p| { + let (x, y) = point_to_xy(mouse_event.position); + p.x = Some(x); + p.y = Some(y); + p.button = Some(mouse_button_to_u32(mouse_event.button)); + p.modifiers = Some(mouse_event.modifiers.into()); + }); + }); + } + + // ── Scroll wheel ───────────────────────────────────── + "scroll" => { + el = el.on_scroll_wheel(move |scroll_event, _window, _cx| { + emit_event_full(&callback, id, "scroll", |p| { + let (x, y) = point_to_xy(scroll_event.position); + p.x = Some(x); + p.y = Some(y); + p.modifiers = Some(scroll_event.modifiers.into()); + p.precise = Some(scroll_event.delta.precise()); + + // Convert ScrollDelta to pixel values. + // For Lines delta, we use a default line height of 20px. + let line_height = gpui::px(20.0); + let pixel_delta = scroll_event.delta.pixel_delta(line_height); + p.delta_x = Some(f64::from(f32::from(pixel_delta.x))); + p.delta_y = Some(f64::from(f32::from(pixel_delta.y))); + + p.touch_phase = Some(match scroll_event.touch_phase { + gpui::TouchPhase::Started => "started".to_string(), + gpui::TouchPhase::Moved => "moved".to_string(), + gpui::TouchPhase::Ended => "ended".to_string(), + gpui::TouchPhase::Cancelled => "cancelled".to_string(), + }); + }); + }); + } + + // ── Key down ───────────────────────────────────────── + // Requires .focusable() (set above). Element must be focused + // (clicked or tabbed to) for these to fire. + "keyDown" => { + el = el.on_key_down(move |key_event, _window, _cx| { + emit_event_full(&callback, id, "keyDown", |p| { + p.key = Some(key_event.keystroke.key.clone()); + p.key_char = key_event.keystroke.key_char.clone(); + p.is_held = Some(key_event.is_held); + p.modifiers = Some(key_event.keystroke.modifiers.into()); + }); + }); + } + + // ── Key up ─────────────────────────────────────────── + "keyUp" => { + el = el.on_key_up(move |key_event, _window, _cx| { + emit_event_full(&callback, id, "keyUp", |p| { + p.key = Some(key_event.keystroke.key.clone()); + p.key_char = key_event.keystroke.key_char.clone(); + p.modifiers = Some(key_event.keystroke.modifiers.into()); + }); + }); + } + + // ── Focus / Blur ───────────────────────────────────── + // Event emission is handled by FocusHandle subscriptions + // set up in GpuixView::sync_focus_handles(). The handle is + // attached to this element via .track_focus() above. + "focus" | "blur" => {} + + _ => {} + } + } + + // Text content — selectable, same as a leaf. + if let Some(ref content) = element.content { + el = el.child(text_content(element.id, content, ctx)); + } + + // Children + let child_ids: Vec = element.children.clone(); + for child_id in child_ids { + let child = build_element(child_id, ctx, window, cx); + el = if overflow_x_only { + el.child(gpui::div().flex_none().child(child)) + } else { + el.child(child) + }; + } + + el.into_any_element() +} + +/// A selectable text run owned by `element_id`. Runs are left to gpui so the +/// text keeps inheriting colour, weight and family from ancestor styles. +fn text_content(element_id: u64, content: &str, ctx: &BuildCtx) -> gpui::AnyElement { + if !ctx.cascade.selectable() { + // Still logged: `getPaintedText()` promises every painted string, and a + // `userSelect: "none"` label is exactly the chrome tests want to assert. + return crate::text::chrome_text(gpui::SharedString::from(content.to_string()), None); + } + selectable_text(crate::text::SelectableText::new( + gpui::SharedString::from(content.to_string()), + None, + selection_key(element_id, 0), + ctx.selection.clone(), + crate::color::to_hsla(ctx.cascade.selection_wash()), + )) +} + +pub(crate) fn build_text( + element: &crate::retained_tree::RetainedElement, + style: Option<&StyleDesc>, + resolved: Option>, + motion: Option, + ctx: &mut BuildCtx, + window: &mut gpui::Window, + cx: &mut gpui::Context, +) -> gpui::AnyElement { + use gpui::prelude::*; + + // Fast path: plain text leaf without style. It still goes through + // `text_content` so the glyphs land in the selection registry — the old + // raw-string return was the reason text was not selectable. + if style.is_none() && motion.is_none() && element.children.is_empty() { + let content = element.content.clone().unwrap_or_default(); + return gpui::div() + .relative() + .child(crate::automation::bounds_tracker(element.id, None)) + .child(text_content(element.id, &content, ctx)) + .into_any_element(); + } + + // The full style set, exactly as `
` gets it. `` used to apply a + // text-only subset, so `padding`, `width` and every layout prop on a text + // node were silently dropped — a hole with no error and no warning. + let mut el = gpui::div(); + if let Some(resolved) = resolved.as_ref() { + el = crate::style::resolve::apply_resolved(el, &resolved.base); + } + if let Some(motion) = motion { + el = crate::style::resolve::apply_motion(el, motion, style); + } + if style.and_then(|style| style.position.as_deref()).is_none() { + el = el.relative(); + } + el = el.child(crate::automation::bounds_tracker( + element.id, + selection_start_flag(style), + )); + + if let Some(ref content) = element.content { + el = el.child(text_content(element.id, content, ctx)); + } + + let child_ids: Vec = element.children.clone(); + for child_id in child_ids { + el = el.child(build_element(child_id, ctx, window, cx)); + } + + el.into_any_element() +} + +/// Explicit `userSelect` on this node. `None` means inherit; the ancestor +/// that set the value already owns the start region. +fn selection_start_flag(style: Option<&StyleDesc>) -> Option { + match style.and_then(|style| style.user_select.as_deref()) { + Some("none") => Some(false), + Some("text") | Some("auto") => Some(true), + _ => None, + } +} diff --git a/packages/native/src/renderer/virtual_list.rs b/packages/native/src/renderer/virtual_list.rs new file mode 100644 index 00000000..7ffc6afd --- /dev/null +++ b/packages/native/src/renderer/virtual_list.rs @@ -0,0 +1,273 @@ +//! The retained state of one virtual list. +//! +//! A virtual list keeps its scroll position, its measured rows and its focus +//! handles between frames. The frame walk reads this to decide which rows exist +//! this frame. The napi methods read it to answer scroll queries. + +use std::collections::{HashMap, HashSet}; + +use super::GpuixView; +use crate::retained_tree::RetainedElement; + +pub(super) fn json_usize(value: &serde_json::Value) -> Option { + value + .as_u64() + .map(|n| n as usize) + .or_else(|| { + value + .as_f64() + .filter(|n| *n >= 0.0 && n.is_finite()) + .map(|n| n as usize) + }) + .or_else(|| value.as_i64().filter(|n| *n >= 0).map(|n| n as usize)) +} + +pub(super) fn window_start_from_element(element: &RetainedElement) -> usize { + element + .custom_props + .get("windowStart") + .and_then(json_usize) + .unwrap_or(0) +} + +#[derive(Clone, Copy, PartialEq)] +pub(super) struct VirtualListConfig { + pub(super) alignment: gpui::ListAlignment, + pub(super) follow_tail: bool, + pub(super) overdraw: f32, + pub(super) estimated_item_height: Option, + pub(super) item_count: Option, +} + +impl VirtualListConfig { + pub(super) fn from_element(element: &RetainedElement) -> Self { + let prop = |key: &str| element.custom_props.get(key); + let alignment = match prop("alignment").and_then(serde_json::Value::as_str) { + Some("bottom") => gpui::ListAlignment::Bottom, + _ => gpui::ListAlignment::Top, + }; + let follow_tail = prop("followTail") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let overdraw = prop("overdraw") + .and_then(serde_json::Value::as_f64) + .unwrap_or(512.0) + .max(0.0) as f32; + let estimated_item_height = prop("estimatedItemHeight") + .and_then(serde_json::Value::as_f64) + .filter(|height| *height > 0.0) + .map(|height| height as f32); + let item_count = prop("itemCount").and_then(json_usize); + Self { + alignment, + follow_tail, + overdraw, + estimated_item_height, + item_count, + } + } + + pub(super) fn logical_count(self, child_len: usize) -> usize { + self.item_count.unwrap_or(child_len) + } + + pub(super) fn make_state(self, item_count: usize, focus_handles: &[Option]) -> gpui::ListState { + let mut state = gpui::ListState::new(item_count, self.alignment, gpui::px(self.overdraw)); + if focus_handles.len() == item_count { + state.splice_focusable(0..item_count, focus_handles.iter().cloned()); + } else { + state.splice_focusable(0..item_count, (0..item_count).map(|_| None)); + } + if let Some(height) = self.estimated_item_height { + state = state.with_uniform_item_height(gpui::px(height)); + } + if self.follow_tail { + state.set_follow_mode(gpui::FollowMode::Tail); + } + state + } +} + +pub(super) struct VirtualListEntry { + pub(super) state: gpui::ListState, + pub(super) config: VirtualListConfig, + pub(super) window_start: usize, + pub(super) child_ids: Vec, + pub(super) child_revisions: Vec, + pub(super) row_focus_handles: Vec>, + pub(super) seen_rows: HashSet, +} + +impl VirtualListEntry { + pub(super) fn new( + config: VirtualListConfig, + window_start: usize, + child_ids: Vec, + child_revisions: Vec, + row_focus_handles: Vec>, + ) -> Self { + let item_count = config.logical_count(child_ids.len()); + let state = config.make_state(item_count, &row_focus_handles); + if row_focus_handles.len() != item_count { + for (offset, handle) in row_focus_handles.iter().enumerate() { + if handle.is_some() { + let logical = window_start + offset; + if logical < item_count { + state.splice_focusable( + logical..logical + 1, + std::iter::once(handle.clone()), + ); + } + } + } + } + Self { + state, + config, + window_start, + child_ids, + child_revisions, + row_focus_handles, + seen_rows: HashSet::new(), + } + } + + pub(super) fn child_at(&self, logical_index: usize) -> Option { + logical_index + .checked_sub(self.window_start) + .and_then(|offset| self.child_ids.get(offset).copied()) + } + + pub(super) fn logical_index_of(&self, child_id: u64) -> Option { + self.child_ids + .iter() + .position(|id| *id == child_id) + .map(|offset| self.window_start + offset) + } + + pub(super) fn sync( + &mut self, + config: VirtualListConfig, + window_start: usize, + child_ids: Vec, + child_revisions: Vec, + focusable_rows: &HashSet, + cx: &mut gpui::Context, + ) { + let focus_unchanged = self.child_ids == child_ids + && self.row_focus_handles.len() == child_ids.len() + && self + .child_ids + .iter() + .zip(&self.row_focus_handles) + .all(|(id, handle)| handle.is_some() == focusable_rows.contains(id)); + if self.config == config + && self.window_start == window_start + && focus_unchanged + && self.child_revisions == child_revisions + { + return; + } + + let old_rows: HashMap)> = self + .child_ids + .iter() + .copied() + .zip(self.child_revisions.iter().copied()) + .zip(self.row_focus_handles.iter().cloned()) + .map(|((id, revision), focus_handle)| (id, (revision, focus_handle))) + .collect(); + let row_focus_handles: Vec> = child_ids + .iter() + .map(|id| { + focusable_rows.contains(id).then(|| { + old_rows + .get(id) + .and_then(|(_, focus_handle)| focus_handle.clone()) + .unwrap_or_else(|| cx.focus_handle()) + }) + }) + .collect(); + if self.config != config { + let scroll_top = self.state.logical_scroll_top(); + let should_follow = + config.follow_tail && (!self.config.follow_tail || self.state.is_following_tail()); + let mut replacement = + Self::new(config, window_start, child_ids, child_revisions, row_focus_handles); + replacement.seen_rows = std::mem::take(&mut self.seen_rows); + replacement + .seen_rows + .retain(|id| replacement.child_ids.contains(id)); + if !should_follow { + replacement.state.scroll_to(scroll_top); + } + *self = replacement; + return; + } + + // A windowed list's children are a sliding viewport. Splicing by + // child position would treat a scroll as a rewrite of items 0..N. + if config.item_count.is_none() && self.child_ids != child_ids { + let prefix = self + .child_ids + .iter() + .zip(&child_ids) + .take_while(|(old, new)| old == new) + .count(); + let suffix = self.child_ids[prefix..] + .iter() + .rev() + .zip(child_ids[prefix..].iter().rev()) + .take_while(|(old, new)| old == new) + .count(); + self.state.splice_focusable( + prefix..self.child_ids.len().saturating_sub(suffix), + row_focus_handles[prefix..row_focus_handles.len().saturating_sub(suffix)] + .iter() + .cloned(), + ); + if let Some(height) = config.estimated_item_height { + self.state = self + .state + .clone() + .with_uniform_item_height(gpui::px(height)); + } + } + + for (offset, (&id, focus_handle)) in child_ids.iter().zip(&row_focus_handles).enumerate() { + let logical = window_start + offset; + let focusability_changed = old_rows + .get(&id) + .is_some_and(|(_, old_handle)| old_handle.is_some() != focus_handle.is_some()); + if focusability_changed { + self.state + .splice_focusable(logical..logical + 1, std::iter::once(focus_handle.clone())); + } + } + + let mut changed_start = None; + for (offset, (&id, &revision)) in child_ids.iter().zip(&child_revisions).enumerate() { + let logical = window_start + offset; + let changed = old_rows + .get(&id) + .is_some_and(|(old_revision, _)| *old_revision != revision); + match (changed_start, changed) { + (None, true) => changed_start = Some(logical), + (Some(start), false) => { + self.state.remeasure_items(start..logical); + changed_start = None; + } + _ => {} + } + } + if let Some(start) = changed_start { + self.state + .remeasure_items(start..window_start + child_ids.len()); + } + + self.window_start = window_start; + self.child_ids = child_ids; + self.child_revisions = child_revisions; + self.row_focus_handles = row_focus_handles; + } +} diff --git a/packages/native/src/retained_tree.rs b/packages/native/src/retained_tree.rs index 055c1992..c3e2854c 100644 --- a/packages/native/src/retained_tree.rs +++ b/packages/native/src/retained_tree.rs @@ -6,6 +6,8 @@ /// /// All IDs are u64 — JS generates them with an incrementing counter, /// passes them as numbers across napi (no string allocation). +use std::cell::RefCell; +use std::sync::Arc; use std::collections::{HashMap, HashSet}; use crate::style::StyleDesc; @@ -13,7 +15,12 @@ use crate::style::StyleDesc; pub struct RetainedElement { pub id: u64, pub element_type: String, - pub style: Option, + /// The declarations on this element, or `None` when it declares nothing. + /// + /// Boxed because `StyleDesc` is over 1,700 bytes and most elements in a + /// tree declare nothing. Inline, every one of them carried the whole + /// struct. + pub style: Option>, pub content: Option, pub events: HashSet, pub children: Vec, @@ -28,6 +35,22 @@ pub struct RetainedElement { pub subtree_revision: u64, /// Stable locator id from the React `testId` prop. pub test_id: Option, + /// The style of this element after resolution, kept until the style changes. + /// + /// GPUI rebuilds its element tree every frame, so without this the renderer + /// resolves the same unchanged style again on every frame. The render walk + /// holds a shared borrow of the tree, so the cell fills the cache in place. + pub resolved: RefCell>>, + /// The cascade this element hands its children, with the cascade it came + /// from. + /// + /// `Inherited::descend` builds a fresh `Arc` whenever the element declares + /// something inheritable, and the render walk calls it on every frame. Two + /// equal cascades built on two frames are different pointers, and the + /// resolved-style cache compares pointers, so without this the whole + /// subtree below a declaration re-resolves on every frame. Keeping the + /// result turns that back into one pointer test. + pub descended: RefCell>, } impl RetainedElement { @@ -44,8 +67,47 @@ impl RetainedElement { subtree_revision: revision, test_id: None, custom_props: HashMap::new(), + resolved: RefCell::new(None), + descended: RefCell::new(None), } } + + /// The resolved style for this element, computed on first use and kept + /// until `set_style` replaces the style. + /// + /// Returns `None` when the element has no style of its own. Callers that + /// build a style for one frame, such as motion, must not use this. + pub fn resolved_style( + &self, + cascade: &crate::inheritance::Inherited, + ) -> Option> { + let style = self.style.as_ref()?; + let mut slot = self.resolved.borrow_mut(); + if let Some(cached) = slot.as_ref() { + // A resolution that read nothing inherited holds under every + // cascade, so most elements never fail this test. + if cached.valid_under(cascade) { + return Some(cached.clone()); + } + } + let built = Arc::new(crate::style::resolve::Resolved::build(style, cascade)); + *slot = Some(built.clone()); + Some(built) + } + + /// The cascade for this element's children, reusing the last one when the + /// parent cascade has not changed. + pub fn descend(&self, parent: &crate::inheritance::Inherited) -> crate::inheritance::Inherited { + let mut slot = self.descended.borrow_mut(); + if let Some((from, child)) = slot.as_ref() { + if from.same(parent) { + return child.clone(); + } + } + let child = parent.descend(self.style.as_deref()); + *slot = Some((parent.clone(), child.clone())); + child + } } pub struct RetainedTree { @@ -167,11 +229,24 @@ impl RetainedTree { self.mark_changed(parent_id); } - pub fn set_style(&mut self, id: u64, style: StyleDesc) { + pub fn set_style(&mut self, id: u64, style: Box) { + // An element that declares nothing and an element with no `style` prop + // are the same element, so both hold `None`. React skips the call at + // mount for an empty style but sends `{}` on every update, and without + // this the first update on an unstyled element would read as a change + // and resolve a style with nothing in it. + // + // The empty style is built once. Building one per call cost more than + // reading the style did. + static EMPTY: std::sync::LazyLock = std::sync::LazyLock::new(StyleDesc::default); + let style = (*style != *EMPTY).then_some(style); let mut changed = false; if let Some(element) = self.elements.get_mut(&id) { - if element.style.as_ref() != Some(&style) { - element.style = Some(style); + if element.style != style { + element.style = style; + // Both caches belong to the old style. Drop them. + *element.resolved.get_mut() = None; + *element.descended.get_mut() = None; changed = true; } } @@ -352,3 +427,4 @@ fn element_to_json( serde_json::Value::Object(obj) } + diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 09d0a3f3..2391ba23 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -1,14 +1,139 @@ +pub(crate) mod resolve; +pub(crate) mod vars; + +/// A style value that resolves to a number. +/// +/// A bare number is the common case and stays a number. Text goes through +/// `var()` first, then reads as a plain number or a `px` length, which are the +/// two forms the `style` prop already takes. +/// +/// Every numeric field uses this, including the unitless ones such as `opacity` +/// and `flexGrow`, because `var()` is legal in any property and a field that +/// stayed `f64` would reject it. +/// +/// `Deserialize` is hand written for the same reason as `StyleDesc`. An +/// `#[serde(untagged)]` enum buffers every value it reads before it picks a +/// variant, and 36 fields use this one. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Numeric { + Number(f64), + Text(String), +} + +impl<'de> Deserialize<'de> for Numeric { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, Visitor}; + + struct NumericVisitor; + + impl Visitor<'_> for NumericVisitor { + type Value = Numeric; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a number or a CSS value such as \"8px\" or \"var(--pad)\"") + } + + fn visit_f64(self, value: f64) -> Result { + Ok(Numeric::Number(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Numeric::Number(value as f64)) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Numeric::Number(value as f64)) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Numeric::Text(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Numeric::Text(value)) + } + } + + deserializer.deserialize_any(NumericVisitor) + } +} + +impl Numeric { + /// The number this holds, without resolving anything. + /// + /// Text always reads as `None` here, even plain `"8px"`. Only + /// `Scope::number` reads text, so a caller that skips the scope cannot + /// half-resolve a value. + pub fn as_number(&self) -> Option { + match self { + Self::Number(value) => Some(*value), + Self::Text(_) => None, + } + } +} + +impl From for Numeric { + fn from(value: f64) -> Self { + Self::Number(value) + } +} + use serde::{Deserialize, Deserializer, Serialize}; /// Font weight value — accepts both CSS strings ("bold", "700") and numbers (700). /// JS style objects commonly use both `fontWeight: "bold"` and `fontWeight: 700`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(untagged)] pub enum FontWeightValue { Num(f64), Str(String), } +impl<'de> Deserialize<'de> for FontWeightValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, Visitor}; + + struct WeightVisitor; + + impl Visitor<'_> for WeightVisitor { + type Value = FontWeightValue; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a number or a weight name such as \"bold\"") + } + + fn visit_f64(self, value: f64) -> Result { + Ok(FontWeightValue::Num(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(FontWeightValue::Num(value as f64)) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(FontWeightValue::Num(value as f64)) + } + + fn visit_str(self, value: &str) -> Result { + Ok(FontWeightValue::Str(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(FontWeightValue::Str(value)) + } + } + + deserializer.deserialize_any(WeightVisitor) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BoxShadowValue { @@ -97,119 +222,323 @@ impl<'de> Deserialize<'de> for DimensionValue { } } -/// Style description that can be serialized from JS -/// Note: This is only used for JSON deserialization, not direct napi binding -#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct StyleDesc { +/// Declares `StyleDesc` and its `Deserialize` from one field list. +/// +/// The wire name beside each field drives both directions, so what JS writes +/// and what Rust reads cannot drift apart. +/// +/// The `Deserialize` is hand written to keep `#[serde(flatten)]` off the read +/// path. Flatten makes serde buffer the whole object into an intermediate value +/// before it reads one field, and every `setStyle` call pays for that. Measured +/// on a small style, buffering was 246 ns of a 320 ns parse. +macro_rules! style_desc { + ($( $(#[$meta:meta])* $field:ident : $ty:ty = $name:literal ),* $(,)?) => { + /// Style description that can be serialized from JS + /// Note: This is only used for JSON deserialization, not direct napi binding + #[derive(Debug, Clone, Default, PartialEq, Serialize)] + pub struct StyleDesc { + $( + $(#[$meta])* + #[serde(rename = $name)] + pub $field: $ty, + )* + + /// Custom property declarations on this element, such as `--pad: 8px`. + /// + /// Only the keys starting with `--` land here. Anything else is a + /// typo or a field a newer client knows about, and both are + /// ignored, which is what happened to them before this field + /// existed. + #[serde(flatten)] + pub custom: std::collections::HashMap, + } + + /// Every wire name the reader knows, in declaration order. + /// + /// One test reads this against what `Serialize` writes, which proves + /// the two halves of the macro agree. + #[cfg(test)] + const FIELDS: &[&str] = &[$( $name, )*]; + + impl StyleDesc { + /// Reads a style from JSON straight into a box. + /// + /// `StyleDesc` is over 1,700 bytes and every element in the tree + /// holds one, so the tree keeps a pointer rather than the struct. + /// Reading into the box means the value is never built on the stack + /// and then copied there. + pub fn from_json_boxed(text: &str) -> serde_json::Result> { + let mut json = serde_json::Deserializer::from_str(text); + let style = Self::deserialize_boxed(&mut json)?; + json.end()?; + Ok(style) + } + + /// The same read, from any deserializer. + /// + /// The batch path already holds a `serde_json::Value`, so it needs + /// this rather than the text above. + pub fn deserialize_boxed<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(read::Boxed) + } + } + + impl<'de> Deserialize<'de> for StyleDesc { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(read::Owned) + } + } + + /// The reader for `StyleDesc`. + /// + /// Two visitors over one `fill`, so a style read into a box is never + /// built on the stack first, and a nested `hover` still reads through + /// the ordinary `Deserialize`. + mod read { + use super::StyleDesc; + use serde::de::{Deserializer, Error, IgnoredAny, MapAccess, Visitor}; + + /// One key of a style object, matched without allocating. + /// + /// Only a custom property keeps its name, because that name is the + /// map key. Every other key is either a known field, which the + /// variant already names, or ignored. + #[allow(non_camel_case_types)] + enum Key { + $( $field, )* + Custom(String), + Ignore, + } + + struct KeyVisitor; + + impl Visitor<'_> for KeyVisitor { + type Value = Key; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a style property name") + } + + fn visit_str(self, value: &str) -> Result { + Ok(match value { + $( $name => Key::$field, )* + name if name.starts_with("--") => Key::Custom(name.to_owned()), + _ => Key::Ignore, + }) + } + } + + impl<'de> serde::Deserialize<'de> for Key { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(KeyVisitor) + } + } + + /// Writes every key of `map` into `style`. + /// + /// A repeated key takes the later value, the way a repeated + /// declaration does in a CSS rule. + fn fill<'de, M>(style: &mut StyleDesc, map: &mut M) -> Result<(), M::Error> + where + M: MapAccess<'de>, + { + while let Some(key) = map.next_key::()? { + match key { + $( Key::$field => style.$field = map.next_value()?, )* + Key::Custom(name) => { + let value: serde_json::Value = map.next_value()?; + style.custom.insert(name, value); + } + Key::Ignore => { + map.next_value::()?; + } + } + } + Ok(()) + } + + pub struct Owned; + + impl<'de> Visitor<'de> for Owned { + type Value = StyleDesc; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a style object") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut style = StyleDesc::default(); + fill(&mut style, &mut map)?; + Ok(style) + } + } + + pub struct Boxed; + + impl<'de> Visitor<'de> for Boxed { + type Value = Box; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a style object") + } + + fn visit_map(self, mut map: M) -> Result, M::Error> + where + M: MapAccess<'de>, + { + let mut style = Box::::default(); + fill(&mut style, &mut map)?; + Ok(style) + } + } + } + }; +} + +style_desc! { // Display - pub display: Option, - pub visibility: Option, + display: Option = "display", + visibility: Option = "visibility", // Flexbox - pub flex_direction: Option, - pub flex_wrap: Option, - pub flex_grow: Option, - pub flex_shrink: Option, - pub flex_basis: Option, - pub align_items: Option, - pub align_self: Option, - pub align_content: Option, - pub justify_content: Option, - pub gap: Option, - pub row_gap: Option, - pub column_gap: Option, - pub grid_template_columns: Option, - pub grid_template_rows: Option, - pub grid_column_min: Option, - pub grid_row_min: Option, + flex_direction: Option = "flexDirection", + flex_wrap: Option = "flexWrap", + flex_grow: Option = "flexGrow", + flex_shrink: Option = "flexShrink", + flex_basis: Option = "flexBasis", + align_items: Option = "alignItems", + align_self: Option = "alignSelf", + align_content: Option = "alignContent", + justify_content: Option = "justifyContent", + gap: Option = "gap", + row_gap: Option = "rowGap", + column_gap: Option = "columnGap", + grid_template_columns: Option = "gridTemplateColumns", + grid_template_rows: Option = "gridTemplateRows", + grid_column_min: Option = "gridColumnMin", + grid_row_min: Option = "gridRowMin", // Sizing - now supports both numbers and strings like "100%" or "auto" - pub width: Option, - pub height: Option, - pub min_width: Option, - pub min_height: Option, - pub max_width: Option, - pub max_height: Option, + width: Option = "width", + height: Option = "height", + min_width: Option = "minWidth", + min_height: Option = "minHeight", + max_width: Option = "maxWidth", + max_height: Option = "maxHeight", // Spacing (padding) - pub padding: Option, - pub padding_top: Option, - pub padding_right: Option, - pub padding_bottom: Option, - pub padding_left: Option, + padding: Option = "padding", + padding_top: Option = "paddingTop", + padding_right: Option = "paddingRight", + padding_bottom: Option = "paddingBottom", + padding_left: Option = "paddingLeft", // Spacing (margin) - pub margin: Option, - pub margin_top: Option, - pub margin_right: Option, - pub margin_bottom: Option, - pub margin_left: Option, + margin: Option = "margin", + margin_top: Option = "marginTop", + margin_right: Option = "marginRight", + margin_bottom: Option = "marginBottom", + margin_left: Option = "marginLeft", // Position - pub position: Option, - pub top: Option, - pub right: Option, - pub bottom: Option, - pub left: Option, + position: Option = "position", + top: Option = "top", + right: Option = "right", + bottom: Option = "bottom", + left: Option = "left", // Background & Colors - pub background: Option, - pub background_color: Option, - pub color: Option, - pub opacity: Option, + background: Option = "background", + background_color: Option = "backgroundColor", + color: Option = "color", + opacity: Option = "opacity", // Border - pub border_width: Option, - pub border_top_width: Option, - pub border_right_width: Option, - pub border_bottom_width: Option, - pub border_left_width: Option, - pub border_color: Option, - pub border_radius: Option, - pub border_top_left_radius: Option, - pub border_top_right_radius: Option, - pub border_bottom_left_radius: Option, - pub border_bottom_right_radius: Option, - pub box_shadow: Option, + border_width: Option = "borderWidth", + border_top_width: Option = "borderTopWidth", + border_right_width: Option = "borderRightWidth", + border_bottom_width: Option = "borderBottomWidth", + border_left_width: Option = "borderLeftWidth", + border_color: Option = "borderColor", + border_radius: Option = "borderRadius", + border_top_left_radius: Option = "borderTopLeftRadius", + border_top_right_radius: Option = "borderTopRightRadius", + border_bottom_left_radius: Option = "borderBottomLeftRadius", + border_bottom_right_radius: Option = "borderBottomRightRadius", + box_shadow: Option = "boxShadow", // Text - pub font_size: Option, - pub font_family: Option, - pub font_weight: Option, - pub text_align: Option, - pub line_height: Option, - pub white_space: Option, - pub text_overflow: Option, - pub line_clamp: Option, + font_size: Option = "fontSize", + font_family: Option = "fontFamily", + font_weight: Option = "fontWeight", + text_align: Option = "textAlign", + line_height: Option = "lineHeight", + white_space: Option = "whiteSpace", + text_overflow: Option = "textOverflow", + line_clamp: Option = "lineClamp", // Overflow - pub overflow: Option, - pub overflow_x: Option, - pub overflow_y: Option, + overflow: Option = "overflow", + overflow_x: Option = "overflowX", + overflow_y: Option = "overflowY", // Cursor - pub cursor: Option, + cursor: Option = "cursor", /// `"auto"` blocks mouse hits behind this element. `"none"` never does. /// Unset: block when this element paints a fill or is absolutely positioned. - pub pointer_events: Option, + pointer_events: Option = "pointerEvents", // Text selection. "none" opts an element and its subtree out of the // selection registry, so buttons and toolbars never start a drag. // Inherited down the tree like the CSS property of the same name. - pub user_select: Option, + user_select: Option = "userSelect", /// Selection wash colour for this subtree. Defaults to the theme accent at /// 35% opacity, the same tone Comet uses. - pub selection_color: Option, + selection_color: Option = "selectionColor", - // Pseudo-selector styles — applied by GPUI natively (no JS round-trip). + // Pseudo-selector styles, applied by GPUI natively (no JS round-trip). // Uses Box to avoid infinite-size struct (StyleDesc contains StyleDesc). - pub hover: Option>, - pub active: Option>, + // + // These two are the only conditions `style` carries, and they are here for + // history. A CSS `style` attribute holds declarations, not selectors. Any + // further condition belongs in a class, not here. + hover: Option> = "hover", + active: Option> = "active", } pub use crate::color::{parse_color, parse_color_hex}; +impl StyleDesc { + /// The state blocks this style declares, in specification order. + /// + /// This is the one place that knows the `style` prop spells its states as + /// named fields. When the class channel lands, states arrive as parsed + /// selectors instead, and only this function changes. + pub(crate) fn states( + &self, + ) -> impl Iterator { + use crate::style::resolve::State; + [ + (State::Hover, self.hover.as_deref()), + (State::Active, self.active.as_deref()), + ] + .into_iter() + .filter_map(|(state, declared)| declared.map(|declared| (state, declared))) + } +} + /// Whether this style should insert a mouse hitbox. /// /// GPUI only hit-tests elements that own a hitbox. A painted overlay without @@ -262,4 +591,121 @@ mod tests { fn invalid_fill_keeps_conservative_occlusion() { assert!(should_occlude(&with_fill("not-a-color"))); } + + #[test] + fn every_name_the_writer_uses_is_a_name_the_reader_knows() { + let written = serde_json::to_value(StyleDesc::default()).unwrap(); + let written = written.as_object().unwrap(); + for name in written.keys() { + assert!(FIELDS.contains(&name.as_str()), "`{name}` is written but never read"); + } + assert_eq!(written.len(), FIELDS.len()); + } + + #[test] + fn a_style_reads_the_same_shapes_it_always_did() { + let style: StyleDesc = serde_json::from_str( + r#"{ + "paddingTop": 8, + "gap": "var(--gap)", + "width": "100%", + "height": "auto", + "fontWeight": "bold", + "lineClamp": null, + "hover": { "color": "red" } + }"#, + ) + .unwrap(); + assert_eq!(style.padding_top, Some(Numeric::Number(8.0))); + assert_eq!(style.gap, Some(Numeric::Text("var(--gap)".to_owned()))); + assert_eq!(style.width, Some(DimensionValue::Percentage(1.0))); + assert_eq!(style.height, Some(DimensionValue::Auto)); + assert_eq!(style.font_weight, Some(FontWeightValue::Str("bold".to_owned()))); + assert_eq!(style.line_clamp, None); + assert_eq!(style.hover.unwrap().color.as_deref(), Some("red")); + } + + #[test] + fn a_custom_property_is_kept_and_any_other_unknown_key_is_dropped() { + let style: StyleDesc = serde_json::from_str( + r#"{ "--pad": "8px", "--depth": 3, "paddingg": 8, "somethingNew": true }"#, + ) + .unwrap(); + assert_eq!( + declared_variables(&style), + vec![ + ("--depth".to_owned(), "3".to_owned()), + ("--pad".to_owned(), "8px".to_owned()), + ] + ); + assert_eq!(style.custom.len(), 2); + } + + #[test] + fn a_repeated_key_takes_the_later_value() { + let style: StyleDesc = + serde_json::from_str(r#"{ "gap": 4, "gap": 8, "--pad": 1, "--pad": 2 }"#).unwrap(); + assert_eq!(style.gap, Some(Numeric::Number(8.0))); + assert_eq!(declared_variables(&style), vec![("--pad".to_owned(), "2".to_owned())]); + } + + #[test] + fn the_boxed_read_and_the_ordinary_read_agree() { + let json = r#"{ "gap": 8, "color": "red", "--pad": "4px", "hover": { "gap": 2 }, "nope": 1 }"#; + assert_eq!( + *StyleDesc::from_json_boxed(json).unwrap(), + serde_json::from_str::(json).unwrap() + ); + } + + #[test] + fn the_boxed_read_rejects_trailing_text() { + assert!(StyleDesc::from_json_boxed(r#"{ "gap": 8 } and then some"#).is_err()); + } + + #[test] + fn a_style_survives_a_round_trip_through_json() { + let style = StyleDesc { + gap: Some(Numeric::Text("calc(1rem + 2px)".to_owned())), + font_size: Some(Numeric::Number(14.0)), + max_width: Some(DimensionValue::Pixels(320.0)), + user_select: Some("none".to_owned()), + custom: [("--pad".to_owned(), serde_json::json!("8px"))].into_iter().collect(), + hover: Some(Box::new(StyleDesc { + background_color: Some("#fff".to_owned()), + ..Default::default() + })), + ..Default::default() + }; + let text = serde_json::to_string(&style).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), style); + } +} + +/// The custom properties an element declares, as declared text. +/// +/// A number becomes a plain string, so `{ "--pad": 8 }` and `{ "--pad": "8" }` +/// mean the same thing. That matches the `style` prop, where a bare number is +/// already how a length is written. +pub fn declared_variables(style: &StyleDesc) -> Vec<(String, String)> { + let mut declared: Vec<(String, String)> = style + .custom + .iter() + .filter(|(name, _)| name.starts_with("--")) + .filter_map(|(name, value)| { + let text = match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Number(number) => number.to_string(), + // `undefined` reaches Rust as null. CSS has no way to write an + // undeclared value, so treat it as absent. + _ => return None, + }; + Some((name.clone(), text)) + }) + .collect(); + // A HashMap has no order, and the cascade compares these to decide whether + // a subtree re-resolves. Without a sort the same declarations could compare + // unequal from one frame to the next. + declared.sort_by(|a, b| a.0.cmp(&b.0)); + declared } diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs new file mode 100644 index 00000000..786ab8ac --- /dev/null +++ b/packages/native/src/style/resolve.rs @@ -0,0 +1,784 @@ +//! One place that turns a `StyleDesc` into a GPUI `StyleRefinement`. +//! +//! GPUI is immediate mode. It rebuilds the element tree every frame. Before +//! this module the renderer ran 52 `if let Some` branches for every element on +//! every frame, and it ran them again for styles that had not changed since the +//! last mutation from React. +//! +//! `StyleRefinement` is the type the whole GPUI style API already speaks. +//! `Styled::style()` returns `&mut StyleRefinement`, and `hover`, `active`, +//! `group_hover` and `group_active` all take +//! `impl FnOnce(StyleRefinement) -> StyleRefinement`. `Refineable` also merges a +//! refinement into another refinement, so one resolved value covers the base +//! style and every variant. That makes the resolved refinement a cache the +//! renderer can hold and reuse until the style changes. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use gpui::{Refineable, StyleRefinement}; + +use crate::inheritance::Inherited; +use crate::style::vars::Scope; +use crate::style::StyleDesc; + +/// How many times `resolve` ran since the last reset. +/// +/// The performance tests assert on this counter instead of on wall-clock time. +/// A steady-state frame must add zero. One `setStyle` must add one. A wall-clock +/// budget flakes on a loaded machine and then someone mutes it. A counter does +/// not flake, and it fails loudly when a cache stops working. +static RESOLUTIONS: AtomicU64 = AtomicU64::new(0); + +/// Read the resolve counter. +pub(crate) fn resolutions() -> u64 { + RESOLUTIONS.load(Ordering::Relaxed) +} + +/// Set the resolve counter back to zero. +pub(crate) fn reset_resolutions() { + RESOLUTIONS.store(0, Ordering::Relaxed); +} + +/// One state pseudo-class, which is one kind of condition. +/// +/// GPUI evaluates these itself at paint, with no re-render and no second +/// resolve, so a pointer moving over an element costs nothing in this crate. +/// That is why states live beside the resolved style rather than inside it. +/// +/// Conditions are an open set. Adding `:focus` is one variant here and one arm +/// at the paint site, not a new field on every resolution in the tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum State { + Hover, + Active, +} + +/// A `StyleDesc` with every value turned into a GPUI value. +/// +/// The renderer stores this on the retained element and drops it when the style +/// changes. Applying it to an element costs one `refine` call per state. +#[derive(Debug, Clone)] +pub(crate) struct Resolved { + pub base: StyleRefinement, + /// The states this style declares, in the order `StyleDesc::states` lists + /// them. + /// + /// Almost every element declares none. An empty `Vec` allocates nothing, + /// where the two inline `Option` fields this replaced + /// carried the full size of a refinement each whether or not anything used + /// them. + pub states: Vec<(State, StyleRefinement)>, + /// The cascade this resolution read, or `None` when it read nothing + /// inherited. + /// + /// A style with no `var()` and no `currentColor` computes the same value + /// under every cascade, so `None` means the cached resolution stays valid + /// however the cascade changes above it. That is almost every element, and + /// it keeps the cost of custom properties on the elements that use them. + pub cascade: Option, +} + +impl Resolved { + /// Resolve a style and every state it declares against a cascade. + pub fn build(style: &StyleDesc, cascade: &Inherited) -> Self { + let scope = cascade.scope(); + let base = resolve(style, &scope); + let states = style + .states() + .map(|(state, declared)| (state, resolve(declared, &scope))) + .collect(); + Self { + base, + states, + cascade: scope.used_a_variable().then(|| cascade.clone()), + } + } + + /// The refinement for one state, or `None` when the style does not declare + /// it. + pub fn state(&self, state: State) -> Option<&StyleRefinement> { + self.states + .iter() + .find(|(declared, _)| *declared == state) + .map(|(_, refinement)| refinement) + } + + /// Whether this resolution still holds under `cascade`. + pub fn valid_under(&self, cascade: &Inherited) -> bool { + match &self.cascade { + None => true, + Some(read) => read.same(cascade), + } + } +} + +/// Turn one `StyleDesc` into a `StyleRefinement`. +/// +/// `apply_styles` is generic over `E: Styled`, so the compiler proves it only +/// calls style setters. That makes this wrapper the same work the renderer did +/// before, moved off the frame path. +pub(crate) fn resolve(style: &StyleDesc, scope: &Scope) -> StyleRefinement { + RESOLUTIONS.fetch_add(1, Ordering::Relaxed); + apply_styles(StyleRefinement::default(), style, scope) +} + +/// Merge a resolved refinement into any styled element. +/// +/// This is the whole per-frame cost of styling one element. +pub(crate) fn apply_resolved(mut el: E, resolved: &StyleRefinement) -> E { + el.style().refine(resolved); + el +} + +/// Apply a motion frame on top of a resolved style. +/// +/// Motion drives eight numbers, and none of them reads a variable, +/// `currentColor` or the font size. Every one of them lands on the element +/// here, so an animated element keeps the cached resolution of everything it +/// declared. Folding the numbers into a `StyleDesc` and resolving that instead +/// reparsed every declaration the element made, on every frame, to change one +/// value. +pub(crate) fn apply_motion( + mut el: E, + motion: crate::motion::MotionStyle, + declared: Option<&StyleDesc>, +) -> E { + if let Some(width) = motion.width { + el = el.w(gpui::px(width as f32)); + } + if let Some(height) = motion.height { + el = el.h(gpui::px(height as f32)); + } + if let Some(top) = motion.top { + el = el.top(gpui::px(top as f32)); + } + if let Some(right) = motion.right { + el = el.right(gpui::px(right as f32)); + } + if let Some(bottom) = motion.bottom { + el = el.bottom(gpui::px(bottom as f32)); + } + if let Some(left) = motion.left { + el = el.left(gpui::px(left as f32)); + } + if let Some(radius) = motion.border_radius { + // A declared corner longhand beats the shorthand, which is the order + // `apply_styles` reads the two in, so motion leaves that corner alone. + let radius = gpui::px(radius as f32); + let free = |declares: fn(&StyleDesc) -> bool| !declared.is_some_and(declares); + if free(|style| style.border_top_left_radius.is_some()) { + el = el.rounded_tl(radius); + } + if free(|style| style.border_top_right_radius.is_some()) { + el = el.rounded_tr(radius); + } + if free(|style| style.border_bottom_left_radius.is_some()) { + el = el.rounded_bl(radius); + } + if free(|style| style.border_bottom_right_radius.is_some()) { + el = el.rounded_br(radius); + } + } + if let Some(opacity) = motion.opacity { + el = el.opacity(opacity as f32); + } + el +} + +// ── Style application ──────────────────────────────────────────────── + +pub(crate) fn apply_width(el: E, dim: &crate::style::DimensionValue) -> E { + match dim { + crate::style::DimensionValue::Pixels(v) => el.w(gpui::px(*v as f32)), + crate::style::DimensionValue::Percentage(v) if *v >= 0.999 => el.w_full(), + crate::style::DimensionValue::Percentage(v) => el.w(gpui::relative(*v as f32)), + crate::style::DimensionValue::Auto => el, + } +} + +pub(crate) fn apply_height(el: E, dim: &crate::style::DimensionValue) -> E { + match dim { + crate::style::DimensionValue::Pixels(v) => el.h(gpui::px(*v as f32)), + crate::style::DimensionValue::Percentage(v) if *v >= 0.999 => el.h_full(), + crate::style::DimensionValue::Percentage(v) => el.h(gpui::relative(*v as f32)), + crate::style::DimensionValue::Auto => el, + } +} + +pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: &Scope) -> E { + // `visibility` reached StyleDesc but nothing read it, so `hideInstance` + // hid nothing. GPUI's Visibility::Hidden has the CSS meaning: skip the + // paint, keep the layout box. + match style.visibility.as_deref() { + Some("hidden") => el.style().visibility = Some(gpui::Visibility::Hidden), + Some("visible") => el.style().visibility = Some(gpui::Visibility::Visible), + _ => {} + } + match style.display.as_deref() { + Some("flex") => el = el.flex(), + Some("grid") => el = el.grid(), + _ => {} + } + if let Some(cols) = scope.number(&style.grid_template_columns) { + let count = cols.round().clamp(1.0, 64.0) as u16; + el = match style.grid_column_min.as_deref() { + Some("min-content") => el.grid_cols_min_content(count), + Some("max-content") => el.grid_cols_max_content(count), + _ => el.grid_cols(count), + }; + } + if let Some(rows) = scope.number(&style.grid_template_rows) { + let count = rows.round().clamp(1.0, 64.0) as u16; + el = match style.grid_row_min.as_deref() { + Some("min-content") => el.grid_rows_min_content(count), + Some("max-content") => el.grid_rows_max_content(count), + _ => el.grid_rows(count), + }; + } + if style.flex_direction.as_deref() == Some("column") { + el = el.flex_col(); + } + if style.flex_direction.as_deref() == Some("row") { + el = el.flex_row(); + } + match style.flex_wrap.as_deref() { + Some("wrap") => el = el.flex_wrap(), + Some("wrap-reverse") => el = el.flex_wrap_reverse(), + Some("nowrap") => el = el.flex_nowrap(), + _ => {} + } + if let Some(grow) = scope.number(&style.flex_grow) { + el.style().flex_grow = Some(grow as f32); + } + if let Some(shrink) = scope.number(&style.flex_shrink) { + el.style().flex_shrink = Some(shrink as f32); + } + if let Some(basis) = scope.number(&style.flex_basis) { + el = el.flex_basis(gpui::px(basis as f32)); + } + match style.align_items.as_deref() { + Some("center") => el = el.items_center(), + Some("start") | Some("flex-start") => el = el.items_start(), + Some("end") | Some("flex-end") => el = el.items_end(), + _ => {} + } + match style.align_content.as_deref() { + Some("center") => el = el.content_center(), + Some("start") | Some("flex-start") => el = el.content_start(), + Some("end") | Some("flex-end") => el = el.content_end(), + Some("between") | Some("space-between") => el = el.content_between(), + Some("around") | Some("space-around") => el = el.content_around(), + Some("evenly") | Some("space-evenly") => el = el.content_evenly(), + Some("stretch") => el = el.content_stretch(), + Some("normal") => el = el.content_normal(), + _ => {} + } + match style.justify_content.as_deref() { + Some("center") => el = el.justify_center(), + Some("start") | Some("flex-start") => el = el.justify_start(), + Some("end") | Some("flex-end") => el = el.justify_end(), + Some("between") | Some("space-between") => el = el.justify_between(), + Some("around") | Some("space-around") => el = el.justify_around(), + _ => {} + } + match style.align_self.as_deref() { + Some("center") => { + el.style().align_self = Some(gpui::AlignItems::Center); + } + Some("start") | Some("flex-start") => { + el.style().align_self = Some(gpui::AlignItems::FlexStart); + } + Some("end") | Some("flex-end") => { + el.style().align_self = Some(gpui::AlignItems::FlexEnd); + } + Some("stretch") => { + el.style().align_self = Some(gpui::AlignItems::Stretch); + } + Some("baseline") => { + el.style().align_self = Some(gpui::AlignItems::Baseline); + } + _ => {} + } + if let Some(gap) = scope.number(&style.gap) { + el = el.gap(gpui::px(gap as f32)); + } + // Per-axis gaps were in the style type and implemented nowhere. They come + // after `gap` so the axis value wins, matching CSS shorthand order. + if let Some(gap) = scope.number(&style.row_gap) { + el = el.gap_y(gpui::px(gap as f32)); + } + if let Some(gap) = scope.number(&style.column_gap) { + el = el.gap_x(gpui::px(gap as f32)); + } + if let Some(ref w) = style.width { + el = apply_width(el, w); + } + if let Some(ref h) = style.height { + el = apply_height(el, h); + } + if let Some(ref min_w) = style.min_width { + match min_w { + crate::style::DimensionValue::Pixels(v) => el = el.min_w(gpui::px(*v as f32)), + crate::style::DimensionValue::Percentage(v) => el = el.min_w(gpui::relative(*v as f32)), + crate::style::DimensionValue::Auto => {} + } + } + if let Some(ref min_h) = style.min_height { + match min_h { + crate::style::DimensionValue::Pixels(v) => el = el.min_h(gpui::px(*v as f32)), + crate::style::DimensionValue::Percentage(v) => el = el.min_h(gpui::relative(*v as f32)), + crate::style::DimensionValue::Auto => {} + } + } + if let Some(ref max_w) = style.max_width { + match max_w { + crate::style::DimensionValue::Pixels(v) => el = el.max_w(gpui::px(*v as f32)), + crate::style::DimensionValue::Percentage(v) => el = el.max_w(gpui::relative(*v as f32)), + crate::style::DimensionValue::Auto => {} + } + } + if let Some(ref max_h) = style.max_height { + match max_h { + crate::style::DimensionValue::Pixels(v) => el = el.max_h(gpui::px(*v as f32)), + crate::style::DimensionValue::Percentage(v) => el = el.max_h(gpui::relative(*v as f32)), + crate::style::DimensionValue::Auto => {} + } + } + if let Some(p) = scope.number(&style.padding) { + el = el.p(gpui::px(p as f32)); + } + if let Some(pt) = scope.number(&style.padding_top) { + el = el.pt(gpui::px(pt as f32)); + } + if let Some(pr) = scope.number(&style.padding_right) { + el = el.pr(gpui::px(pr as f32)); + } + if let Some(pb) = scope.number(&style.padding_bottom) { + el = el.pb(gpui::px(pb as f32)); + } + if let Some(pl) = scope.number(&style.padding_left) { + el = el.pl(gpui::px(pl as f32)); + } + if let Some(m) = scope.number(&style.margin) { + el = el.m(gpui::px(m as f32)); + } + if let Some(mt) = scope.number(&style.margin_top) { + el = el.mt(gpui::px(mt as f32)); + } + if let Some(mr) = scope.number(&style.margin_right) { + el = el.mr(gpui::px(mr as f32)); + } + if let Some(mb) = scope.number(&style.margin_bottom) { + el = el.mb(gpui::px(mb as f32)); + } + if let Some(ml) = scope.number(&style.margin_left) { + el = el.ml(gpui::px(ml as f32)); + } + match style.position.as_deref() { + Some("absolute") => el = el.absolute(), + Some("relative") => el = el.relative(), + _ => {} + } + if let Some(top) = scope.number(&style.top) { + el = el.top(gpui::px(top as f32)); + } + if let Some(right) = scope.number(&style.right) { + el = el.right(gpui::px(right as f32)); + } + if let Some(bottom) = scope.number(&style.bottom) { + el = el.bottom(gpui::px(bottom as f32)); + } + if let Some(left) = scope.number(&style.left) { + el = el.left(gpui::px(left as f32)); + } + if let Some(color) = style + .background_color + .as_deref() + .or(style.background.as_deref()) + .and_then(|bg| scope.color(bg)) + { + el = el.bg(crate::color::to_hsla(color)); + } + if let Some(color) = style.color.as_deref().and_then(|c| scope.color(c)) { + el = el.text_color(crate::color::to_hsla(color)); + } + if let Some(size) = scope.number(&style.font_size) { + el = el.text_size(gpui::px(size as f32)); + } + if let Some(ref family) = style.font_family { + el = el.font_family(family.clone()); + } + if let Some(ref weight) = style.font_weight { + el = el.font_weight(parse_font_weight(weight)); + } + // `textAlign` was in the style type but implemented nowhere. + match style.text_align.as_deref() { + Some("center") => el = el.text_center(), + Some("right") => el = el.text_right(), + Some("left") | Some("start") => el = el.text_left(), + _ => {} + } + match style.white_space.as_deref() { + Some("nowrap") => el = el.whitespace_nowrap(), + Some("normal") => el = el.whitespace_normal(), + _ => {} + } + match style.text_overflow.as_deref() { + Some("ellipsis") => el = el.text_ellipsis(), + Some("ellipsis-start") => el = el.text_ellipsis_start(), + _ => {} + } + if let Some(clamp) = scope.number(&style.line_clamp) { + if clamp >= 1.0 { + el = el.line_clamp(clamp as usize); + } + } + // `lineHeight` follows CSS: a bare number is a multiple of the font size, + // and a length is that length. GPUI's `DefiniteLength` carries both, so + // neither form needs the font size here. + if let Some(line_height) = scope.length(&style.line_height) { + match line_height { + gpuix_css::length::Length::Number(multiple) + | gpuix_css::length::Length::Fraction(multiple) + if multiple > 0.0 => + { + el = el.line_height(gpui::relative(multiple)); + } + gpuix_css::length::Length::Pixels(pixels) if pixels > 0.0 => { + el = el.line_height(gpui::px(pixels)); + } + _ => {} + } + } + if let Some(radius) = scope.number(&style.border_radius) { + el = el.rounded(gpui::px(radius as f32)); + } + // Apply corner longhands after the shorthand so the explicit corner wins. + if let Some(radius) = scope.number(&style.border_top_left_radius) { + el = el.rounded_tl(gpui::px(radius as f32)); + } + if let Some(radius) = scope.number(&style.border_top_right_radius) { + el = el.rounded_tr(gpui::px(radius as f32)); + } + if let Some(radius) = scope.number(&style.border_bottom_left_radius) { + el = el.rounded_bl(gpui::px(radius as f32)); + } + if let Some(radius) = scope.number(&style.border_bottom_right_radius) { + el = el.rounded_br(gpui::px(radius as f32)); + } + // `borderWidth: 0` must clear a border, not be ignored: an element that + // draws its own border needs a way for the caller to remove it. + if let Some(width) = scope.number(&style.border_width) { + el = el.border(gpui::px(width.max(0.0) as f32)); + } + if let Some(width) = scope.number(&style.border_top_width) { + el = el.border_t(gpui::px(width.max(0.0) as f32)); + } + if let Some(width) = scope.number(&style.border_right_width) { + el = el.border_r(gpui::px(width.max(0.0) as f32)); + } + if let Some(width) = scope.number(&style.border_bottom_width) { + el = el.border_b(gpui::px(width.max(0.0) as f32)); + } + if let Some(width) = scope.number(&style.border_left_width) { + el = el.border_l(gpui::px(width.max(0.0) as f32)); + } + if let Some(color) = style.border_color.as_deref().and_then(|c| scope.color(c)) { + el = el.border_color(crate::color::to_hsla(color)); + } + if let Some(ref shadow) = style.box_shadow { + if let Some(color) = scope.color(&shadow.color) { + let shadow = gpui::BoxShadow::new( + gpui::px(shadow.offset_x as f32), + gpui::px(shadow.offset_y as f32), + crate::color::to_hsla(color), + ) + .blur_radius(gpui::px(shadow.blur_radius.max(0.0) as f32)) + .spread_radius(gpui::px(shadow.spread_radius as f32)); + el = el.shadow(vec![shadow]); + } + } + if let Some(opacity) = scope.number(&style.opacity) { + el = el.opacity(opacity as f32); + } + match style.cursor.as_deref() { + Some("pointer") => el = el.cursor_pointer(), + Some("default") => el = el.cursor_default(), + _ => {} + } + // Overflow: hidden is on the Styled trait, so we handle it here. + // overflow: "scroll" requires StatefulInteractiveElement — handled in build_div(). + // CSS precedence: axis-specific (overflowX/Y) overrides the shorthand (overflow). + { + let resolved_x = style.overflow_x.as_deref().or(style.overflow.as_deref()); + let resolved_y = style.overflow_y.as_deref().or(style.overflow.as_deref()); + // Only apply hidden here — scroll is handled in build_div. + if resolved_x == Some("hidden") && resolved_y == Some("hidden") { + el = el.overflow_hidden(); + } else if resolved_x == Some("hidden") { + el = el.overflow_x_hidden(); + } else if resolved_y == Some("hidden") { + el = el.overflow_y_hidden(); + } + } + + el +} + +/// Parse a CSS font-weight value (string or number) into a GPUI FontWeight. +/// Accepts named keywords ("bold", "semibold"), numeric strings ("700"), +/// and raw numbers (700). Falls back to 400 (normal) for unrecognized values. +fn parse_font_weight(value: &crate::style::FontWeightValue) -> gpui::FontWeight { + match value { + crate::style::FontWeightValue::Num(n) => gpui::FontWeight((*n as f32).clamp(1.0, 1000.0)), + crate::style::FontWeightValue::Str(s) => { + let lower = s.trim().to_ascii_lowercase(); + match lower.as_str() { + "100" | "thin" => gpui::FontWeight(100.0), + "200" | "extralight" | "extra-light" => gpui::FontWeight(200.0), + "300" | "light" => gpui::FontWeight(300.0), + "400" | "normal" => gpui::FontWeight(400.0), + "500" | "medium" => gpui::FontWeight(500.0), + "600" | "semibold" | "semi-bold" => gpui::FontWeight(600.0), + "700" | "bold" => gpui::FontWeight(700.0), + "800" | "extrabold" | "extra-bold" => gpui::FontWeight(800.0), + "900" | "black" => gpui::FontWeight(900.0), + _ => lower + .parse::() + .map(|n| gpui::FontWeight(n.clamp(1.0, 1000.0))) + .unwrap_or(gpui::FontWeight(400.0)), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styled(color: &str) -> Box { + Box::new(StyleDesc { + background_color: Some(color.to_string()), + ..Default::default() + }) + } + + /// A cascade with nothing declared above it. + fn no_variables() -> Inherited { + let theme = crate::theme::Theme::default(); + Inherited::root(crate::color::from_gpui(theme.accent), theme.dark, 16.0) + } + + /// A cascade with `pairs` declared one level down. + fn variables(pairs: &[(&str, &str)]) -> Inherited { + let custom = pairs + .iter() + .map(|(name, value)| (name.to_string(), serde_json::json!(value))) + .collect(); + let style = StyleDesc { + custom, + ..Default::default() + }; + no_variables().descend(Some(&style)) + } + + fn background_of(style: &StyleDesc, cascade: &Inherited) -> Option { + Resolved::build(style, cascade).base.background + } + + fn fill(color: &str) -> Option { + Some(crate::color::parse_color_rgba(color).unwrap().into()) + } + + #[test] + fn resolves_the_base_style_and_each_state() { + let style = StyleDesc { + background_color: Some("#111111".to_string()), + hover: Some(styled("#ff0000")), + active: Some(styled("#00ff00")), + ..Default::default() + }; + let cascade = no_variables(); + let resolved = Resolved::build(&style, &cascade); + let plain = cascade.scope(); + assert_eq!( + resolved.state(State::Hover), + Some(&resolve(&styled("#ff0000"), &plain)) + ); + assert_eq!( + resolved.state(State::Active), + Some(&resolve(&styled("#00ff00"), &plain)) + ); + // The list keeps the order `StyleDesc::states` declares, which is the + // order the paint dispatcher walks. + assert_eq!( + resolved.states.iter().map(|(s, _)| *s).collect::>(), + vec![State::Hover, State::Active] + ); + } + + #[test] + fn a_style_with_no_states_resolves_to_none() { + let resolved = Resolved::build(&styled("#111111"), &no_variables()); + assert!(resolved.states.is_empty()); + assert!(resolved.state(State::Hover).is_none()); + assert!(resolved.state(State::Active).is_none()); + } + + #[test] + fn an_unknown_style_field_does_not_fail_the_whole_style() { + // A newer client must lose one declaration, not its element. + let json = r##"{ "backgroundColor": "#111111", "someFutureThing": 4 }"##; + let style: StyleDesc = serde_json::from_str(json).expect("style should still parse"); + assert_eq!(style.background_color.as_deref(), Some("#111111")); + } + + #[test] + fn a_variable_reaches_a_colour() { + let scope = variables(&[("--brand", "#ff0000")]); + assert_eq!( + background_of(&styled("var(--brand)"), &scope), + fill("#ff0000") + ); + } + + #[test] + fn a_variable_reaches_a_state_colour() { + let style = StyleDesc { + hover: Some(styled("var(--brand)")), + ..Default::default() + }; + let scope = variables(&[("--brand", "#ff0000")]); + let resolved = Resolved::build(&style, &scope); + assert_eq!( + resolved.state(State::Hover).and_then(|h| h.background.clone()), + fill("#ff0000") + ); + } + + #[test] + fn a_missing_variable_leaves_the_colour_unset() { + // CSS calls this invalid at computed-value time. The property takes the + // value it would have had, which here is no background at all. + assert_eq!(background_of(&styled("var(--nope)"), &no_variables()), None); + } + + #[test] + fn a_fallback_paints_when_the_variable_is_missing() { + assert_eq!( + background_of(&styled("var(--nope, #00ff00)"), &no_variables()), + fill("#00ff00") + ); + } + + #[test] + fn a_style_that_reads_nothing_holds_under_every_cascade() { + // This is what keeps custom properties off the cost of every other + // element. A resolution that read nothing is never invalidated. + let resolved = Resolved::build(&styled("#111111"), &no_variables()); + assert!(resolved.cascade.is_none()); + assert!(resolved.valid_under(&variables(&[("--brand", "#ff0000")]))); + } + + #[test] + fn a_style_that_read_a_variable_only_holds_under_that_cascade() { + let cascade = variables(&[("--brand", "#ff0000")]); + let resolved = Resolved::build(&styled("var(--brand)"), &cascade); + assert!(resolved.valid_under(&cascade)); + assert!(!resolved.valid_under(&variables(&[("--brand", "#ff0000")]))); + } + + #[test] + fn a_var_that_falls_back_still_counts_as_reading_the_cascade() { + // The fallback won because nothing declared the variable. A different + // cascade could declare one, so the resolution has to be bound to it. + let resolved = Resolved::build(&styled("var(--brand, #00ff00)"), &no_variables()); + assert!(resolved.cascade.is_some()); + } + + #[test] + fn current_color_reads_the_inherited_colour() { + let cascade = no_variables().descend(Some(&StyleDesc { + color: Some("#ff0000".to_string()), + ..Default::default() + })); + let style = StyleDesc { + border_color: Some("currentColor".to_string()), + ..Default::default() + }; + let resolved = Resolved::build(&style, &cascade); + assert_eq!( + resolved.base.border_color, + crate::color::parse_color_rgba("#ff0000").map(Into::into) + ); + // It read the cascade, so it must not survive a cascade change. + assert!(resolved.cascade.is_some()); + } + + #[test] + fn current_color_takes_the_declaration_on_the_element_itself() { + let style = StyleDesc { + color: Some("#00ff00".to_string()), + border_color: Some("currentColor".to_string()), + ..Default::default() + }; + // The walk descends before it resolves, so the element's own colour is + // already in the cascade by the time `currentColor` reads it. + let cascade = no_variables().descend(Some(&style)); + let resolved = Resolved::build(&style, &cascade); + assert_eq!( + resolved.base.border_color, + crate::color::parse_color_rgba("#00ff00").map(Into::into) + ); + } + + fn line_height_of(text: &str) -> Option { + let style = StyleDesc { + line_height: Some(crate::style::Numeric::Text(text.to_string())), + ..Default::default() + }; + Resolved::build(&style, &no_variables()).base.text.line_height + } + + #[test] + fn a_bare_line_height_is_a_multiple_of_the_font_size() { + // CSS reads `line-height: 1.5` as one and a half times the font size. + // Reading it as 1.5 pixels would collapse every line onto the last. + assert_eq!(line_height_of("1.5"), Some(gpui::relative(1.5))); + let numeric = StyleDesc { + line_height: Some(crate::style::Numeric::Number(1.5)), + ..Default::default() + }; + assert_eq!( + Resolved::build(&numeric, &no_variables()).base.text.line_height, + Some(gpui::relative(1.5)) + ); + } + + #[test] + fn a_line_height_with_a_unit_is_that_length() { + assert_eq!(line_height_of("24px"), Some(gpui::px(24.0).into())); + assert_eq!(line_height_of("1.5rem"), Some(gpui::px(24.0).into())); + assert_eq!(line_height_of("150%"), Some(gpui::relative(1.5))); + } + + #[test] + fn a_line_height_of_zero_or_less_declares_nothing() { + assert_eq!(line_height_of("0"), None); + assert_eq!(line_height_of("-1"), None); + assert_eq!(line_height_of("-4px"), None); + } + + #[test] + fn calc_reaches_a_length() { + let style = StyleDesc { + padding: Some(crate::style::Numeric::Text( + "calc(var(--spacing) * 6)".to_string(), + )), + ..Default::default() + }; + let cascade = variables(&[("--spacing", "0.25rem")]); + let resolved = Resolved::build(&style, &cascade); + assert_eq!(resolved.base.padding.top, Some(gpui::px(24.0).into())); + } +} diff --git a/packages/native/src/style/vars.rs b/packages/native/src/style/vars.rs new file mode 100644 index 00000000..83c26b4a --- /dev/null +++ b/packages/native/src/style/vars.rs @@ -0,0 +1,471 @@ +//! `var()` substitution. +//! +//! A custom property holds text, not a typed value. CSS calls this a +//! "guaranteed-invalid value" until something reads it through `var()`, and the +//! text is only parsed once it lands in a property that knows what it means. +//! So substitution here is textual, and the existing value parsers see the +//! result as if the author had written it in place. +//! +//! # What happens when a variable is missing +//! +//! CSS calls a `var()` with no declaration and no fallback "invalid at +//! computed-value time", and the property takes its inherited or initial value. +//! `value` returns `None` for that, and every caller drops the declaration, +//! which lands on the same place: the element keeps whatever it would have had. + +use std::borrow::Cow; +use std::cell::Cell; + +use gpuix_css::color::{ColorContext, Rgba}; +use gpuix_css::length::Length; + +use crate::inheritance::Variables; + +/// How deep one `var()` may reach through other variables. +/// +/// `--a: var(--b)` with `--b: var(--a)` is a cycle. CSS says a cycle makes every +/// variable in it invalid, and a depth limit reaches the same answer without +/// tracking the chain. +const MAX_DEPTH: usize = 16; + +/// The variables in scope while one style resolves. +/// +/// `used` records whether any `var()` actually read one. A style that reads none +/// resolves to the same value under every scope, so its cached resolution +/// survives a cascade change. That is most elements. +pub(crate) struct Scope<'a> { + variables: &'a Variables, + /// The computed `color` here, which is what `currentColor` names. + current_color: Rgba, + /// Whether the window is in the dark appearance, which `light-dark()` + /// reads. + dark: bool, + /// The root font size in pixels, which is what `rem` is a multiple of. + rem_size: f32, + used: Cell, +} + +impl<'a> Scope<'a> { + pub fn new( + variables: &'a Variables, + current_color: Rgba, + dark: bool, + rem_size: f32, + ) -> Self { + Self { + variables, + current_color, + dark, + rem_size, + used: Cell::new(false), + } + } + + /// The length a declaration means, or `None` when it means none. + /// + /// A bare number needs no work, which is the shape almost every declaration + /// arrives in. Text goes through `var()` and then through `gpuix-css`, + /// which folds `calc()`, `min()`, `max()` and `clamp()` and converts every + /// absolute unit and `rem`. + pub fn length(&self, value: &Option) -> Option { + match value.as_ref()? { + crate::style::Numeric::Number(number) => Some(Length::Number(*number as f32)), + crate::style::Numeric::Text(text) => { + let text = self.value(text)?; + gpuix_css::length::length(&text, self.rem_size) + } + } + } + + /// The pixels a declaration means. + /// + /// This is what most properties want. A bare number is pixels, which is how + /// the `style` prop has always written a length. A percentage reads as + /// nothing, because the properties that take one have their own type. + pub fn number(&self, value: &Option) -> Option { + match self.length(value)? { + Length::Number(number) | Length::Pixels(number) => Some(number as f64), + Length::Fraction(_) => None, + } + } + + /// The colour a declaration names, or `None` when it names none. + /// + /// `currentColor` resolves wherever it sits, including nested inside + /// `light-dark()`. `gpuix-css` reports whether the value read it, which is + /// what marks the resolution as depending on an ancestor. + pub fn color(&self, text: &str) -> Option { + let text = self.value(text)?; + let context = ColorContext { + current_color: self.current_color, + dark: self.dark, + }; + let reading = gpuix_css::color::read(&text, &context).ok()?; + if reading.read_current_color { + self.used.set(true); + } + Some(reading.color) + } + + /// Whether resolving read a variable. + pub fn used_a_variable(&self) -> bool { + self.used.get() + } + + /// `text` with every `var()` in it replaced. + /// + /// Borrows when there is nothing to replace, which is the common case. The + /// `var(` test is a substring scan over bytes, so a style with no variables + /// pays close to nothing for going through here. + pub fn value<'t>(&self, text: &'t str) -> Option> { + if !text.contains("var(") { + return Some(Cow::Borrowed(text)); + } + self.used.set(true); + self.expand(text, 0).map(Cow::Owned) + } + + fn expand(&self, text: &str, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = find_var(rest) { + out.push_str(&rest[..start]); + let open = start + "var(".len(); + let close = closing_paren(rest, open)?; + out.push_str(&self.one(&rest[open..close], depth)?); + rest = &rest[close + 1..]; + } + out.push_str(rest); + Some(out) + } + + /// The replacement for the inside of one `var(...)`. + fn one(&self, inner: &str, depth: usize) -> Option { + let (name, fallback) = match top_level_comma(inner) { + Some(comma) => (&inner[..comma], Some(&inner[comma + 1..])), + None => (inner, None), + }; + let name = name.trim(); + if !name.starts_with("--") { + return None; + } + if let Some(declared) = self.variables.get(name) { + return self.expand(declared, depth + 1); + } + // `var(--x,)` declares an empty fallback, which is legal and stands for + // no value at all. Tailwind writes it, so the empty case has to survive + // the trim below rather than count as a missing fallback. + let fallback = fallback?; + self.expand(fallback.trim(), depth + 1) + } +} + +/// Where the next `var(` starts, if there is one. +/// +/// A match has to begin a token. Without that check the `var(` inside a name +/// such as `--myvar(` would count. +fn find_var(text: &str) -> Option { + let mut from = 0; + while let Some(offset) = text[from..].find("var(") { + let at = from + offset; + let before = text[..at].chars().next_back(); + match before { + Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => from = at + 1, + _ => return Some(at), + } + } + None +} + +/// The index of the `)` that closes the paren opened before `from`. +fn closing_paren(text: &str, from: usize) -> Option { + let mut depth = 0usize; + let mut quote: Option = None; + for (at, c) in text[from..].char_indices() { + match quote { + Some(open) => { + if c == open { + quote = None; + } + } + None => match c { + '"' | '\'' => quote = Some(c), + '(' => depth += 1, + ')' if depth == 0 => return Some(from + at), + ')' => depth -= 1, + _ => {} + }, + } + } + None +} + +/// The index of the comma that splits the name from the fallback. +/// +/// Only a comma outside every nested paren counts. A fallback such as +/// `rgb(0, 0, 0)` holds two commas of its own. +fn top_level_comma(text: &str) -> Option { + let mut depth = 0usize; + let mut quote: Option = None; + for (at, c) in text.char_indices() { + match quote { + Some(open) => { + if c == open { + quote = None; + } + } + None => match c { + '"' | '\'' => quote = Some(c), + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => return Some(at), + _ => {} + }, + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope_of(pairs: &[(&str, &str)]) -> Variables { + let declared: Vec<(String, String)> = pairs + .iter() + .map(|(n, v)| (n.to_string(), v.to_string())) + .collect(); + Variables::default().layer(&declared) + } + + fn value(text: &str, pairs: &[(&str, &str)]) -> Option { + let variables = scope_of(pairs); + Scope::new(&variables, Rgba::BLACK, false, 16.0) + .value(text) + .map(|v| v.into_owned()) + } + + #[test] + fn text_with_no_var_comes_back_untouched() { + let variables = scope_of(&[]); + let scope = Scope::new(&variables, Rgba::BLACK, false, 16.0); + assert!(matches!(scope.value("#ff0000"), Some(Cow::Borrowed(_)))); + assert!(!scope.used_a_variable()); + } + + #[test] + fn a_declared_variable_replaces_the_reference() { + assert_eq!( + value("var(--brand)", &[("--brand", "#ff0000")]), + Some("#ff0000".to_string()) + ); + } + + #[test] + fn reading_a_variable_marks_the_scope_as_used() { + let variables = scope_of(&[("--brand", "#ff0000")]); + let scope = Scope::new(&variables, Rgba::BLACK, false, 16.0); + scope.value("var(--brand)"); + assert!(scope.used_a_variable()); + } + + #[test] + fn a_reference_inside_other_text_keeps_that_text() { + assert_eq!( + value("rgb(var(--channels))", &[("--channels", "1 2 3")]), + Some("rgb(1 2 3)".to_string()) + ); + } + + #[test] + fn two_references_both_replace() { + assert_eq!( + value("var(--a) var(--b)", &[("--a", "1px"), ("--b", "solid")]), + Some("1px solid".to_string()) + ); + } + + #[test] + fn a_missing_variable_falls_back() { + assert_eq!( + value("var(--nope, #00ff00)", &[]), + Some("#00ff00".to_string()) + ); + } + + #[test] + fn a_declared_variable_beats_its_fallback() { + assert_eq!( + value("var(--brand, #00ff00)", &[("--brand", "#ff0000")]), + Some("#ff0000".to_string()) + ); + } + + #[test] + fn a_fallback_keeps_its_own_commas() { + assert_eq!( + value("var(--nope, rgb(1, 2, 3))", &[]), + Some("rgb(1, 2, 3)".to_string()) + ); + } + + #[test] + fn an_empty_fallback_stands_for_no_value() { + // Tailwind writes `var(--tw-ring-inset,)` to mean "nothing unless the + // inset variable is set". An empty fallback is legal CSS and must not + // read as a missing one. + assert_eq!(value("var(--nope,)", &[]), Some(String::new())); + assert_eq!( + value("var(--nope,) #ff0000", &[]), + Some(" #ff0000".to_string()) + ); + } + + #[test] + fn a_missing_variable_with_no_fallback_drops_the_declaration() { + assert_eq!(value("var(--nope)", &[]), None); + } + + #[test] + fn a_variable_may_point_at_another_variable() { + assert_eq!( + value( + "var(--outer)", + &[("--outer", "var(--inner)"), ("--inner", "#ff0000")] + ), + Some("#ff0000".to_string()) + ); + } + + #[test] + fn a_fallback_may_hold_a_reference() { + assert_eq!( + value("var(--nope, var(--other))", &[("--other", "#ff0000")]), + Some("#ff0000".to_string()) + ); + } + + #[test] + fn a_cycle_drops_the_declaration_instead_of_hanging() { + assert_eq!( + value("var(--a)", &[("--a", "var(--b)"), ("--b", "var(--a)")]), + None + ); + } + + #[test] + fn a_name_without_the_two_dashes_is_not_a_variable() { + assert_eq!(value("var(brand)", &[("brand", "#ff0000")]), None); + } + + #[test] + fn an_unclosed_paren_drops_the_declaration() { + assert_eq!(value("var(--a", &[("--a", "#ff0000")]), None); + } + + #[test] + fn var_inside_a_name_is_not_a_reference() { + assert_eq!( + value("var(--myvar(x), #00ff00)", &[]), + Some("#00ff00".to_string()) + ); + } + + #[test] + fn current_color_names_the_computed_colour() { + let variables = scope_of(&[]); + let scope = Scope::new(&variables, Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, 16.0); + assert_eq!(scope.color("currentColor"), Some(Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 })); + assert_eq!(scope.color("CURRENTCOLOR"), Some(Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 })); + assert!(scope.used_a_variable()); + } + + #[test] + fn a_variable_may_hold_the_current_colour_keyword() { + let variables = scope_of(&[("--edge", "currentColor")]); + let scope = Scope::new(&variables, Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, 16.0); + assert_eq!(scope.color("var(--edge)"), Some(Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 })); + } + + #[test] + fn an_ordinary_colour_does_not_read_the_scope() { + let variables = scope_of(&[]); + let scope = Scope::new(&variables, Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, 16.0); + assert_eq!( + scope.color("#00ff00"), + gpuix_css::color::color("#00ff00", &Default::default()).ok() + ); + assert!(!scope.used_a_variable()); + } + + #[test] + fn a_bare_number_needs_no_resolving() { + let variables = scope_of(&[]); + let scope = Scope::new(&variables, Rgba::BLACK, false, 16.0); + assert_eq!(scope.number(&Some(crate::style::Numeric::Number(8.0))), Some(8.0)); + assert!(!scope.used_a_variable()); + } + + fn number(text: &str, pairs: &[(&str, &str)]) -> Option { + let variables = scope_of(pairs); + Scope::new(&variables, Rgba::BLACK, false, 16.0) + .number(&Some(crate::style::Numeric::Text(text.to_string()))) + } + + #[test] + fn text_reads_as_a_number_or_a_length() { + assert_eq!(number("8", &[]), Some(8.0)); + assert_eq!(number("8px", &[]), Some(8.0)); + assert_eq!(number("-1.5px", &[]), Some(-1.5)); + assert_eq!(number("2rem", &[]), Some(32.0)); + assert_eq!(number("calc(8px + 2px)", &[]), Some(10.0)); + } + + #[test] + fn a_percentage_is_not_a_number_of_pixels() { + // The properties that take a percentage have their own type. Reading + // `50%` as 50 pixels here would be worse than reading nothing. + assert_eq!(number("50%", &[]), None); + } + + #[test] + fn a_variable_reaches_a_number() { + assert_eq!(number("var(--pad)", &[("--pad", "8px")]), Some(8.0)); + assert_eq!(number("var(--pad)", &[("--pad", "8")]), Some(8.0)); + assert_eq!(number("var(--nope, 4px)", &[]), Some(4.0)); + assert_eq!(number("var(--nope)", &[]), None); + } + + #[test] + fn calc_reads_a_variable_before_it_folds() { + // This is Tailwind's whole spacing scale. `p-6` compiles to + // `padding: calc(var(--spacing) * 6)` with `--spacing: 0.25rem`. + assert_eq!( + number("calc(var(--spacing) * 6)", &[("--spacing", "0.25rem")]), + Some(24.0) + ); + assert_eq!( + number("calc(var(--spacing) * -1)", &[("--spacing", "0.25rem")]), + Some(-4.0) + ); + } + + #[test] + fn a_bare_number_and_a_percentage_read_as_themselves() { + let variables = scope_of(&[]); + let scope = Scope::new(&variables, Rgba::BLACK, false, 16.0); + let text = |t: &str| Some(crate::style::Numeric::Text(t.to_string())); + assert_eq!(scope.length(&text("1.5")), Some(Length::Number(1.5))); + assert_eq!(scope.length(&text("150%")), Some(Length::Fraction(1.5))); + assert_eq!(scope.length(&text("8px")), Some(Length::Pixels(8.0))); + } + + #[test] + fn an_absent_declaration_stays_absent() { + let variables = scope_of(&[]); + assert_eq!(Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None), None); + } +} diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs index 173cabe0..185af383 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -19,7 +19,7 @@ use napi_derive::napi; use gpui::AppContext as _; -use crate::element_tree::EventPayload; +use crate::events::EventPayload; use crate::renderer::{ apply_batch_to_tree, debug_frame_overlay_mode_name, debug_frame_overlay_stats_js, parse_debug_frame_overlay_mode, DebugFrameOverlayStats, @@ -208,7 +208,7 @@ impl TestGpuixRenderer { #[napi] pub fn set_style(&self, id: f64, style_json: String) -> Result<()> { let id = to_element_id(id)?; - let style: StyleDesc = serde_json::from_str(&style_json) + let style = StyleDesc::from_json_boxed(&style_json) .map_err(|e| Error::from_reason(format!("Failed to parse style: {}", e)))?; self.tree.lock().unwrap().set_style(id, style); Ok(()) @@ -266,6 +266,25 @@ impl TestGpuixRenderer { Ok(()) } + /// How many styles the renderer has resolved since the last reset. + /// + /// The performance tests read this instead of measuring wall-clock time. + /// GPUI rebuilds its element tree every frame, so the number that matters + /// is how much of that rebuild repeats work the renderer already did. A + /// frame that changes nothing must add nothing here. A wall-clock budget + /// flakes on a loaded machine, and a flaky gate gets muted. + #[napi] + pub fn style_resolutions(&self) -> f64 { + crate::style::resolve::resolutions() as f64 + } + + /// Set the style resolution counter back to zero. + #[napi] + pub fn reset_style_resolutions(&self) -> Result<()> { + crate::style::resolve::reset_resolutions(); + Ok(()) + } + /// Apply a batch of mutations in a single FFI call. /// Same format as GpuixRenderer::apply_batch (string op names). /// Returns accumulated destroyed IDs from all destroyElement ops. diff --git a/packages/native/src/theme.rs b/packages/native/src/theme.rs index 6728555f..0e064843 100644 --- a/packages/native/src/theme.rs +++ b/packages/native/src/theme.rs @@ -96,7 +96,7 @@ fn graph_tone(mut color: Hsla) -> Hsla { // ── Syntax palette ─────────────────────────────────────────────────── /// Paint-only colours for one Tree-sitter capture kind each. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct SyntaxPalette { pub comment: Hsla, pub keyword: Hsla, @@ -339,47 +339,6 @@ impl Metrics { feed(value); } } - - pub fn hash_into(&self, hasher: &mut impl std::hash::Hasher) { - let mut feed = |value: f32| hasher.write_u32(value.to_bits()); - for value in [ - self.code_text_size, - self.code_line_height, - self.code_padding_x, - self.code_padding_y, - self.code_radius, - self.code_header_padding_y, - self.code_header_text_size, - self.code_gutter_digit_width, - self.code_gutter_padding_right, - self.code_gutter_min_width, - self.diff_text_size, - self.diff_line_height, - self.diff_file_header_height, - self.diff_hunk_header_height, - self.diff_notice_height, - self.diff_body_bottom_pad, - self.diff_gutter_width, - self.diff_marker_width, - self.diff_accent_bar_width, - self.diff_row_padding_x, - self.md_text_size, - self.md_line_height, - self.md_block_gap, - self.md_table_cell_padding, - self.md_table_min_column_width, - self.md_table_min_column_content, - self.md_inline_code_radius, - ] { - feed(value); - } - for value in self.md_heading_sizes { - feed(value); - } - for value in self.md_heading_line_heights { - feed(value); - } - } } impl Default for Metrics { @@ -429,8 +388,10 @@ impl Default for Metrics { /// This is a trimmed Comet theme: only tokens read by the native editors and /// document elements remain. Surfaces, buttons and chrome tokens are the host /// app's business and stay in JS as ordinary `style` props. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Theme { + /// Whether this is a dark theme, which is what CSS `light-dark()` reads. + pub dark: bool, /// Content plane behind code and diff bodies. pub bg: Hsla, /// Hairline border. @@ -472,6 +433,7 @@ impl Theme { /// Comet's dark theme, token for token. pub fn dark() -> Self { Self { + dark: true, bg: grey(6), border: hsla(0.0, 0.0, 1.0, 0.08), text: neutral(0.922), @@ -495,6 +457,7 @@ impl Theme { /// Comet's light theme. pub fn light() -> Self { Self { + dark: false, bg: grey(0xff), border: hsla(0.0, 0.0, 0.0, 0.10), text: neutral(0.25), @@ -809,22 +772,30 @@ mod tests { } #[test] - fn metrics_hash_changes_with_any_number() { + fn diff_layout_hash_tracks_row_heights_and_nothing_else() { let hash = |m: &Metrics| { use std::hash::Hasher; let mut h = std::collections::hash_map::DefaultHasher::new(); - m.hash_into(&mut h); + m.hash_diff_layout_into(&mut h); h.finish() }; let base = Metrics::default(); - let mut changed = base; - changed.diff_line_height += 1.0; - assert_ne!(hash(&base), hash(&changed)); + assert_eq!(hash(&base), hash(&Metrics::default())); + + // A row height moves, so the measured-height cache has to drop. + let mut taller = base; + taller.diff_line_height += 1.0; + assert_ne!(hash(&base), hash(&taller)); + let mut header = base; + header.diff_hunk_header_height += 1.0; + assert_ne!(hash(&base), hash(&header)); + + // A markdown tweak leaves diff rows where they are. Hashing it would + // drop the scroll anchor and jump every diff on screen back to the top. let mut heading = base; heading.md_heading_sizes[2] += 1.0; - assert_ne!(hash(&base), hash(&heading)); - assert_eq!(hash(&base), hash(&Metrics::default())); + assert_eq!(hash(&base), hash(&heading)); } #[test] diff --git a/packages/react/package.json b/packages/react/package.json index 0fe7c705..a57e91b2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -66,6 +66,7 @@ "build": "rm -rf dist *.tsbuildinfo && tsc", "dev": "tsc --watch", "test": "vitest run", + "typecheck": "tsc -p tsconfig.typecheck.json", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/react/src/__tests__/class-names.test.tsx b/packages/react/src/__tests__/class-names.test.tsx new file mode 100644 index 00000000..9816aef7 --- /dev/null +++ b/packages/react/src/__tests__/class-names.test.tsx @@ -0,0 +1,99 @@ +/// `className` painting the same pixels as the style it stands for. +/// +/// The merge rules are covered without a GPU in `host-config-style.test.tsx`. +/// What these add is that the style a class declares reaches the renderer at +/// mount and on an update, through the same path a real application uses. + +import fs from "fs" +import path from "path" +import React from "react" +import { beforeAll, describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import { expectScreenshotsEqual, SHOTS_DIR } from "./test-utils.js" +import type { ClassNameResolver } from "../types/host.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +beforeAll(() => { + fs.mkdirSync(SHOTS_DIR, { recursive: true }) +}) + +const shot = (name: string) => path.join(SHOTS_DIR, `class-${name}.png`) + +const BOX = { width: 200, height: 120 } as const + +const TABLE: Record> = { + box: BOX, + "bg-red": { backgroundColor: "#ff0000" }, + "bg-blue": { backgroundColor: "#0000ff" }, + "p-5": { padding: 20 }, + "child-green": { width: 40, height: 40, backgroundColor: "#00ff00" }, +} + +const resolveClassName: ClassNameResolver = (token) => TABLE[token] ?? null + +function paint(name: string, tree: React.ReactElement, withResolver = true) { + const root = createTestRoot(withResolver ? { resolveClassName } : {}) + root.render(tree) + root.renderer.captureScreenshot(shot(name)) + root.unmount() +} + +describeNative("className", () => { + it("paints a class the same as the style it stands for", () => { + paint("through",
) + paint("direct",
, false) + expectScreenshotsEqual(shot("through"), shot("direct")) + }) + + it("paints a class and a style prop together", () => { + paint("mixed",
+
+
) + paint( + "mixed-direct", +
+
+
, + false + ) + expectScreenshotsEqual(shot("mixed"), shot("mixed-direct")) + }) + + it("lets the style prop beat the class", () => { + paint("override",
) + paint("override-direct",
, false) + expectScreenshotsEqual(shot("override"), shot("override-direct")) + }) + + it("repaints when the class string changes", () => { + const root = createTestRoot({ resolveClassName }) + root.render(
) + root.render(
) + root.renderer.captureScreenshot(shot("changed")) + root.unmount() + + paint("changed-expected",
, false) + expectScreenshotsEqual(shot("changed"), shot("changed-expected")) + }) + + it("paints nothing from a class when the root has no resolver", () => { + const warn = console.warn + console.warn = () => {} + try { + paint("no-resolver",
, false) + } finally { + console.warn = warn + } + paint("no-resolver-direct",
, false) + expectScreenshotsEqual(shot("no-resolver"), shot("no-resolver-direct")) + }) + + it("resolves nothing again when the same class string comes back", () => { + const { renderer, render } = createTestRoot({ resolveClassName }) + render(
) + renderer.resetStyleResolutions() + render(
) + expect(renderer.styleResolutions()).toBe(0) + }) +}) diff --git a/packages/react/src/__tests__/color-functions.test.tsx b/packages/react/src/__tests__/color-functions.test.tsx index 014f28b6..ffb8eb31 100644 --- a/packages/react/src/__tests__/color-functions.test.tsx +++ b/packages/react/src/__tests__/color-functions.test.tsx @@ -11,22 +11,25 @@ import { const describeNative = hasNativeTestRenderer ? describe : describe.skip +// Every case here is a colour some CSS specification defines. `hsv()`, +// `hsva()`, `hwba()` and bare hex with no `#` used to sit in these lists. +// No CSS specification defines any of them. They came from csscolorparser, +// which GPUIX no longer uses. const absoluteCases = [ ["hex4", "#f00f", "#ff0000"], - ["hex-no-hash", "ff0000ff", "#ff0000"], ["named", "rebeccapurple", "#663399"], ["rgb", "rgb(255 0 0)", "#ff0000"], ["rgba", "rgba(255, 0, 0, 1)", "#ff0000"], ["hsl", "hsl(0 100% 50%)", "#ff0000"], ["hsla", "hsla(0, 100%, 50%, 1)", "#ff0000"], ["hwb", "hwb(0 0% 0%)", "#ff0000"], - ["hwba", "hwba(0, 0%, 0%, 1)", "#ff0000"], - ["hsv", "hsv(0 100% 100%)", "#ff0000"], - ["hsva", "hsva(0, 100%, 100%, 1)", "#ff0000"], ["lab", "lab(100% 0 0)", "#ffffff"], ["lch", "lch(100% 0 0)", "#ffffff"], ["oklab", "oklab(0 0 0)", "#000000"], ["oklch", "oklch(0 0 0)", "#000000"], + // Both of these came back as invalid before the move to lightningcss. + ["color-mix", "color-mix(in srgb, #ff0000 100%, #0000ff 0%)", "#ff0000"], + ["light-dark", "light-dark(#ff0000, #ff0000)", "#ff0000"], ] as const const alphaCases = [ @@ -35,9 +38,6 @@ const alphaCases = [ ["hsl", "hsl(0 0% 0% / 50%)"], ["hsla", "hsla(0, 0%, 0%, 0.5)"], ["hwb", "hwb(0 0% 100% / 50%)"], - ["hwba", "hwba(0, 0%, 100%, 0.5)"], - ["hsv", "hsv(0 0% 0% / 50%)"], - ["hsva", "hsva(0, 0%, 0%, 0.5)"], ["lab", "lab(0% 0 0 / 50%)"], ["lch", "lch(0% 0 0 / 50%)"], ["oklab", "oklab(0 0 0 / 50%)"], @@ -48,7 +48,6 @@ const relativeCases = [ ["rgb", "rgb(from #bad455 b r g / alpha)", "#55bad4"], ["hsl", "hsl(from #bad455 h s l / alpha)", "#bad455"], ["hwb", "hwb(from #bad455 h w b / alpha)", "#bad455"], - ["hsv", "hsv(from #bad455 h s v / alpha)", "#bad455"], ["lab", "lab(from #bad455 l a b / alpha)", "#bad455"], ["lch", "lch(from #bad455 l c h / alpha)", "#bad455"], ["oklab", "oklab(from #bad455 calc(l * 0.7) a b)", "#708500"], @@ -109,6 +108,17 @@ describeNative("native color functions", () => { expectScreenshotsDiffer(validPath, unsetPath) }) + it.each([ + ["hex with no hash", "ff0000ff"], + ["hwba", "hwba(0, 0%, 0%, 1)"], + ["hsv", "hsv(0 100% 100%)"], + ["hsva", "hsva(0, 100%, 100%, 1)"], + ])("ignores %s, which no CSS specification defines", (_name, input) => { + const paintedPath = captureColor("color-nonstandard-actual", input) + const unsetPath = captureColor("color-nonstandard-unset") + expectScreenshotsEqual(paintedPath, unsetPath) + }) + it("uses the same parser for compound consumers and pseudo-states", () => { const basePath = path.join(SHOTS_DIR, "color-consumers-base.png") const hoverPath = path.join(SHOTS_DIR, "color-consumers-hover.png") @@ -132,7 +142,7 @@ describeNative("native color functions", () => { spreadRadius: 4, color: "oklab(45% 0.1 0.05 / 45%)", }, - hover: { backgroundColor: "hsv(210 80% 70%)" }, + hover: { backgroundColor: "hwb(210 20% 30%)" }, active: { backgroundColor: "lch(60% 80 40)" }, }} > diff --git a/packages/react/src/__tests__/css-lengths.test.tsx b/packages/react/src/__tests__/css-lengths.test.tsx new file mode 100644 index 00000000..d77d7d97 --- /dev/null +++ b/packages/react/src/__tests__/css-lengths.test.tsx @@ -0,0 +1,121 @@ +/// `lineHeight` the way CSS reads it, and `calc()` in a length. +/// +/// Both cases paint twice and compare. A bare `lineHeight` is a multiple of the +/// font size, so `1.5` and `"150%"` and `24` pixels at a 16 px font all have to +/// land on the same pixels. A `calc()` has to land on the same pixels as the +/// number it folds to. + +import fs from "fs" +import path from "path" +import React from "react" +import { beforeAll, describe, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import { + expectScreenshotsDiffer, + expectScreenshotsEqual, + SHOTS_DIR, +} from "./test-utils.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +beforeAll(() => { + fs.mkdirSync(SHOTS_DIR, { recursive: true }) +}) + +const shot = (name: string) => path.join(SHOTS_DIR, `css-length-${name}.png`) + +// White on black, or the glyphs are invisible and every line height paints +// the same nothing. +const TEXT_BOX = { + width: 200, + height: 140, + backgroundColor: "#ffffff", + color: "#000000", +} as const + +const BOX = { width: 200, height: 140 } as const + +function paint(name: string, tree: React.ReactElement) { + const root = createTestRoot() + root.render(tree) + root.renderer.captureScreenshot(shot(name)) + root.unmount() +} + +/// Text that wraps, so the space between two lines is on screen. +const lines = (declaration: Record) => ( +
+ one two three four five six seven eight nine ten +
+) + +describeNative("line height", () => { + it("reads a bare number as a multiple of the font size", () => { + // 2.5 used to mean 2.5 pixels. In CSS it means 40 pixels at a 16 px font. + paint("multiple", lines({ lineHeight: 2.5 })) + paint("pixels", lines({ lineHeight: "40px" })) + paint("unset", lines({})) + // The declaration has to do something, or the comparison below is empty. + expectScreenshotsDiffer(shot("multiple"), shot("unset")) + expectScreenshotsEqual(shot("multiple"), shot("pixels")) + }) + + it("reads a percentage as the same multiple", () => { + paint("percent", lines({ lineHeight: "250%" })) + paint("percent-direct", lines({ lineHeight: "40px" })) + expectScreenshotsEqual(shot("percent"), shot("percent-direct")) + }) + + it("reads a multiple written as text the same as a number", () => { + paint("multiple-text", lines({ lineHeight: "2.5" })) + paint("multiple-number", lines({ lineHeight: 2.5 })) + expectScreenshotsEqual(shot("multiple-text"), shot("multiple-number")) + }) + + it("reads rem against the root font size", () => { + paint("rem", lines({ lineHeight: "2.5rem" })) + paint("rem-direct", lines({ lineHeight: "40px" })) + expectScreenshotsEqual(shot("rem"), shot("rem-direct")) + }) + + it("declares nothing for a line height of zero", () => { + paint("zero", lines({ lineHeight: 0 })) + paint("zero-unset", lines({})) + expectScreenshotsEqual(shot("zero"), shot("zero-unset")) + }) +}) + +describeNative("calc", () => { + it("folds arithmetic to the same length as the number", () => { + paint("calc-sum",
+
+
) + paint("calc-sum-direct",
+
+
) + expectScreenshotsEqual(shot("calc-sum"), shot("calc-sum-direct")) + }) + + it("adds a rem to a pixel length", () => { + paint("calc-rem",
) + paint("calc-rem-direct",
) + expectScreenshotsEqual(shot("calc-rem"), shot("calc-rem-direct")) + }) + + it("folds a variable inside the arithmetic", () => { + // This is the shape every step of the Tailwind spacing scale takes. + paint("calc-var",
+
+
) + paint("calc-var-direct",
+
+
) + expectScreenshotsEqual(shot("calc-var"), shot("calc-var-direct")) + }) + + it("takes min, max and clamp", () => { + paint("clamp",
) + paint("clamp-direct",
) + expectScreenshotsEqual(shot("clamp"), shot("clamp-direct")) + }) +}) diff --git a/packages/react/src/__tests__/custom-properties.test.tsx b/packages/react/src/__tests__/custom-properties.test.tsx new file mode 100644 index 00000000..311e5c49 --- /dev/null +++ b/packages/react/src/__tests__/custom-properties.test.tsx @@ -0,0 +1,265 @@ +/// Custom properties in the `style` prop, and `var()` reading them. +/// +/// Each case paints twice. Once through a variable, once with the value +/// written in place. The two screenshots have to be byte-identical, because a +/// variable is a name for text that the property parser then reads as if the +/// author had written it there. +/// +/// The counter tests at the end matter as much as the pixels. A variable makes +/// an element's resolved style depend on its ancestors, so the cache has to +/// stay correct without giving up on elements that use no variable at all. + +import fs from "fs" +import path from "path" +import React from "react" +import { beforeAll, describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import { + expectScreenshotsDiffer, + expectScreenshotsEqual, + SHOTS_DIR, +} from "./test-utils.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +beforeAll(() => { + fs.mkdirSync(SHOTS_DIR, { recursive: true }) +}) + +const shot = (name: string) => path.join(SHOTS_DIR, `var-${name}.png`) + +const BOX = { width: 200, height: 120 } as const + +/// Paint `tree`, save it under `name`, and tear the root down. +function paint(name: string, tree: React.ReactElement) { + const root = createTestRoot() + root.render(tree) + root.renderer.captureScreenshot(shot(name)) + root.unmount() +} + +describeNative("custom properties", () => { + it("paints a colour read through a variable", () => { + paint( + "own-through", +
+ ) + paint("own-direct",
) + expectScreenshotsEqual(shot("own-through"), shot("own-direct")) + }) + + it("reads a variable an ancestor declared", () => { + paint( + "ancestor-through", +
+
+
+ ) + paint("ancestor-direct",
) + expectScreenshotsEqual(shot("ancestor-through"), shot("ancestor-direct")) + }) + + it("takes the nearest declaration when two ancestors disagree", () => { + paint( + "nearest-through", +
+
+
+
+
+ ) + paint("nearest-direct",
) + expectScreenshotsEqual(shot("nearest-through"), shot("nearest-direct")) + }) + + it("uses the fallback when nothing declared the variable", () => { + paint( + "fallback-through", +
+ ) + paint("fallback-direct",
) + expectScreenshotsEqual(shot("fallback-through"), shot("fallback-direct")) + }) + + it("leaves the property unset when the variable is missing", () => { + // CSS calls this invalid at computed-value time. The element keeps the + // value it would have had, which here is no background of its own. + paint("missing-through",
) + paint("missing-direct",
) + expectScreenshotsEqual(shot("missing-through"), shot("missing-direct")) + }) + + it("repaints a subtree when a declaration above it changes", () => { + const root = createTestRoot() + const tree = (brand: string) => ( +
+
+
+ ) + + root.render(tree("#ff0000")) + root.renderer.captureScreenshot(shot("change-before")) + root.render(tree("#00ff00")) + root.renderer.captureScreenshot(shot("change-after")) + root.unmount() + + paint("change-expected",
) + expectScreenshotsEqual(shot("change-after"), shot("change-expected")) + }) + + it("paints a border with the colour currentColor names", () => { + paint( + "current-through", +
+ ) + paint( + "current-direct", +
+ ) + expectScreenshotsEqual(shot("current-through"), shot("current-direct")) + }) + + it("takes currentColor from an ancestor when the element declares none", () => { + paint( + "current-inherited", +
+
+
+ ) + paint( + "current-inherited-direct", +
+ ) + expectScreenshotsEqual(shot("current-inherited"), shot("current-inherited-direct")) + }) + + it("paints a length read through a variable", () => { + paint( + "length-through", +
+
+
+ ) + paint( + "length-direct", +
+
+
+ ) + expectScreenshotsEqual(shot("length-through"), shot("length-direct")) + }) + + it("takes a length written with its unit", () => { + paint("unit-px",
) + paint("unit-bare",
) + paint("unit-none",
) + // The border has to be visible, or the comparison below is empty. + expectScreenshotsDiffer(shot("unit-bare"), shot("unit-none")) + expectScreenshotsEqual(shot("unit-px"), shot("unit-bare")) + }) + + it("reads rem against the root font size", () => { + paint("unit-rem",
) + paint("unit-rem-direct",
) + expectScreenshotsEqual(shot("unit-rem"), shot("unit-rem-direct")) + }) + + it("drops a length it cannot read", () => { + // Painting something arbitrary would be worse than painting nothing, so a + // value the parser rejects leaves the property alone. + paint("unit-bad",
) + paint("unit-bare-again",
) + expectScreenshotsEqual(shot("unit-bad"), shot("unit-bare-again")) + }) + + it("resolves a variable inside hover", () => { + // The state resolves against the element's own scope, so a declaration on + // the element is in scope for the `var()` in its hover style. + const { renderer, render } = createTestRoot() + render( +
+ ) + expect(renderer.styleResolutions()).toBeGreaterThan(0) + }) +}) + +describeNative("custom properties and the resolve cache", () => { + it("resolves nothing on a repeat frame under a declaration", () => { + // A variable makes an element depend on its ancestors. If the cascade were + // rebuilt every frame the whole subtree below a declaration would resolve + // again every frame, which is what this catches. + const { renderer, render } = createTestRoot() + render( +
+ {Array.from({ length: 20 }, (_, i) => ( +
+ ))} +
+ ) + + renderer.resetStyleResolutions() + for (let i = 0; i < 10; i++) { + renderer.flush() + } + + expect(renderer.styleResolutions()).toBe(0) + }) + + it("re-resolves only the readers when a declaration changes", () => { + const { renderer, render } = createTestRoot() + const tree = (brand: string) => ( +
+
+
+
+
+ ) + + render(tree("#ff0000")) + renderer.resetStyleResolutions() + render(tree("#00ff00")) + + // The declaring div re-resolves because its own style changed, and the two + // readers re-resolve because their scope did. The third child reads no + // variable, so its cached resolution still holds. + expect(renderer.styleResolutions()).toBe(3) + }) + + it("leaves a sibling subtree alone when a declaration changes", () => { + const { renderer, render } = createTestRoot() + const tree = (brand: string) => ( +
+
+
+
+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ ))} +
+
+ ) + + render(tree("#ff0000")) + renderer.resetStyleResolutions() + render(tree("#00ff00")) + + // The declaring div and its one reader. The ten elements under the other + // declaration never see a changed scope. + expect(renderer.styleResolutions()).toBe(2) + }) +}) diff --git a/packages/react/src/__tests__/host-config-style.test.tsx b/packages/react/src/__tests__/host-config-style.test.tsx new file mode 100644 index 00000000..548321f7 --- /dev/null +++ b/packages/react/src/__tests__/host-config-style.test.tsx @@ -0,0 +1,286 @@ +/// Style routing in the reconciler host config. +/// +/// These tests drive the host config directly with a recording renderer, so +/// they run on any machine with no GPU and no Metal toolchain. What they check +/// is which style the reconciler sends, which is JavaScript logic and does not +/// need a real frame. +/// +/// Driving `hostConfig` by hand is deliberate. React calls `hideInstance` for +/// hidden Activity trees and for Suspense retries, and the pinned +/// react-reconciler is older than the React that exports `Activity`, so there +/// is no way to reach those paths from a normal render here. + +import { describe, it, expect } from "vitest" +import { hostConfig } from "../reconciler/host-config" +import { createClassNameCache } from "../reconciler/class-names" +import type { + ClassNameResolver, + Container, + HostContext, + NativeRenderer, + Props, +} from "../types/host" + +interface StyleCall { + id: number + style: Record +} + +/** Records every style the reconciler sends. */ +function recordingRenderer(): NativeRenderer & { styles: StyleCall[] } { + const styles: StyleCall[] = [] + return { + styles, + createElement() {}, + destroyElement: () => [], + appendChild() {}, + removeChild() {}, + insertBefore() {}, + setStyle(id: number, styleJson: string | object) { + const style = typeof styleJson === "string" ? JSON.parse(styleJson) : styleJson + styles.push({ id, style: style as Record }) + }, + setText() {}, + setEventListener() {}, + setRoot() {}, + commitMutations() {}, + setCustomProp() {}, + } +} + +function setup(props: Props, resolve?: ClassNameResolver) { + const renderer = recordingRenderer() + const container: Container = { + renderer, + ids: { nextElementId: 0 }, + eventHandlers: new Map(), + classNames: resolve ? createClassNameCache(resolve) : null, + warnedAboutClassName: false, + } + const instance = hostConfig.createInstance( + "div", + props, + container, + null as unknown as HostContext + ) + renderer.styles.length = 0 + return { renderer, instance, container } +} + +/** The style of the last setStyle call. */ +function lastStyle(renderer: { styles: StyleCall[] }): Record | null { + return renderer.styles.at(-1)?.style ?? null +} + +describe("host config style routing", () => { + const props: Props = { + style: { width: 100, height: 40, backgroundColor: "#ff0000" }, + } + + it("keeps the element style when React hides the element", () => { + const { renderer, instance } = setup(props) + + hostConfig.hideInstance(instance) + + // `visibility: hidden` skips the paint and keeps the layout box. Sending + // only the visibility would drop the box and every other style source. + expect(lastStyle(renderer)).toMatchObject({ + visibility: "hidden", + width: 100, + height: 40, + backgroundColor: "#ff0000", + }) + }) + + it("puts the style back when React shows the element again", () => { + const { renderer, instance } = setup(props) + + hostConfig.hideInstance(instance) + hostConfig.unhideInstance(instance, props) + + const last = lastStyle(renderer) + expect(last).toMatchObject({ width: 100, height: 40, backgroundColor: "#ff0000" }) + expect(last?.visibility).toBeUndefined() + }) + + it("resends the full style on update", () => { + const { renderer, instance } = setup(props) + + hostConfig.commitUpdate( + instance, + "div", + props, + { style: { width: 200 } }, + null + ) + + expect(lastStyle(renderer)).toEqual({ width: 200 }) + }) +}) + +/// A resolver over a fixed table, counting what it was asked. +function tableResolver(table: Record) { + const asked: string[] = [] + const resolve: ClassNameResolver = (token) => { + asked.push(token) + return table[token] ?? null + } + return { resolve, asked } +} + +describe("className", () => { + it("sends the style a class declares", () => { + const { resolve } = tableResolver({ "p-4": { padding: 16 } }) + const { renderer, instance } = setup({ className: "p-4" }, resolve) + + hostConfig.commitUpdate(instance, "div", {}, { className: "p-4" }, null) + + expect(lastStyle(renderer)).toEqual({ padding: 16 }) + }) + + it("takes the later token when two write the same key", () => { + const { resolve } = tableResolver({ + "bg-red": { backgroundColor: "#ff0000" }, + "bg-blue": { backgroundColor: "#0000ff" }, + }) + const props = { className: "bg-red bg-blue" } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ backgroundColor: "#0000ff" }) + }) + + it("drops a token the resolver does not know", () => { + const { resolve } = tableResolver({ "p-4": { padding: 16 } }) + const props = { className: "p-4 not-a-real-class" } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ padding: 16 }) + }) + + it("lets the style prop beat a class key by key", () => { + const { resolve } = tableResolver({ + "p-4": { padding: 16, backgroundColor: "#ff0000" }, + }) + const props = { className: "p-4", style: { backgroundColor: "#0000ff" } } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ padding: 16, backgroundColor: "#0000ff" }) + }) + + it("lets the style prop beat a class in the hover state too", () => { + // The style attribute outranks any selector, so an element declaring a + // background inline keeps it while hovered. Only the key the style prop + // set goes: the rest of the hover style stays. + const { resolve } = tableResolver({ + "hover-blue": { hover: { backgroundColor: "#0000ff", padding: 8 } }, + }) + const props = { className: "hover-blue", style: { backgroundColor: "#ff0000" } } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ + backgroundColor: "#ff0000", + hover: { padding: 8 }, + }) + }) + + it("merges the hover style of the prop over the hover style of a class", () => { + const { resolve } = tableResolver({ + "hover-blue": { hover: { backgroundColor: "#0000ff", padding: 8 } }, + }) + const props = { className: "hover-blue", style: { hover: { backgroundColor: "#00ff00" } } } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ + hover: { backgroundColor: "#00ff00", padding: 8 }, + }) + }) + + it("keeps the class style when React hides and shows the element", () => { + // This is the pair React drives for Suspense. Before `computeStyle` knew + // about `className`, hiding an element dropped every class it had and + // showing it again brought back only the inline prop. + const { resolve } = tableResolver({ + "p-4": { padding: 16 }, + "bg-red": { backgroundColor: "#ff0000" }, + }) + const props = { className: "p-4 bg-red" } + const { renderer, instance } = setup(props, resolve) + + hostConfig.hideInstance(instance) + expect(lastStyle(renderer)).toMatchObject({ + visibility: "hidden", + padding: 16, + backgroundColor: "#ff0000", + }) + + hostConfig.unhideInstance(instance, props) + const last = lastStyle(renderer) + expect(last).toEqual({ padding: 16, backgroundColor: "#ff0000" }) + expect(last?.visibility).toBeUndefined() + }) + + it("drops the hover style of a class while the element is hidden", () => { + // A hover style that sets `visibility` would otherwise paint an element + // React asked to hide. + const { resolve } = tableResolver({ + "peek-on-hover": { padding: 16, hover: { visibility: "visible" } }, + }) + const { renderer, instance } = setup({ className: "peek-on-hover" }, resolve) + + hostConfig.hideInstance(instance) + + expect(lastStyle(renderer)).toEqual({ padding: 16, visibility: "hidden" }) + }) + + it("asks the resolver once per token, not once per string", () => { + // `clsx` writes a new string every time a flag flips, and the tokens in it + // are the same. Asking per string would resolve `p-4` four times below. + const { resolve, asked } = tableResolver({ + "p-4": { padding: 16 }, + "bg-red": { backgroundColor: "#ff0000" }, + "text-lg": { fontSize: 18 }, + }) + const { instance } = setup({}, resolve) + + for (const className of [ + "p-4", + "p-4 bg-red", + "p-4 text-lg", + "p-4 bg-red text-lg", + "p-4 bg-red", + ]) { + hostConfig.commitUpdate(instance, "div", {}, { className }, null) + } + + expect(asked).toEqual(["p-4", "bg-red", "text-lg"]) + }) + + it("warns once and sends the inline style when the root has no resolver", () => { + const warnings: unknown[] = [] + const warn = console.warn + console.warn = (...args: unknown[]) => warnings.push(args[0]) + try { + const props = { className: "p-4", style: { padding: 8 } } + const { renderer, instance } = setup(props) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ padding: 8 }) + expect(warnings).toHaveLength(1) + expect(String(warnings[0])).toContain("resolveClassName") + } finally { + console.warn = warn + } + }) +}) diff --git a/packages/react/src/__tests__/inheritance.test.tsx b/packages/react/src/__tests__/inheritance.test.tsx new file mode 100644 index 00000000..5bca66d4 --- /dev/null +++ b/packages/react/src/__tests__/inheritance.test.tsx @@ -0,0 +1,105 @@ +/// Text properties inherit from an ancestor, the way CSS inherits them. +/// +/// GPUI does this itself. A `div` pushes its text style onto a window stack, +/// and `window.text_style()` composes the whole stack, so a `` with no +/// style of its own paints with the nearest ancestor declaration. +/// +/// These tests pin that behaviour. It comes from the pinned zed fork rather +/// than from this repository, so a fork bump could remove it without any +/// change here. Each case asserts the strong form: declaring a property on the +/// ancestor paints byte-identically to declaring it on the text itself. + +import fs from "fs" +import path from "path" +import React from "react" +import { beforeAll, describe, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import { expectScreenshotsDiffer, expectScreenshotsEqual, SHOTS_DIR } from "./test-utils.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +beforeAll(() => { + fs.mkdirSync(SHOTS_DIR, { recursive: true }) +}) + +const shot = (name: string) => path.join(SHOTS_DIR, `inherit-${name}.png`) + +// White background and black text, so the glyphs are visible before the case +// changes anything. Without this only `color` shows up, because the default +// text colour is invisible against the default background. +const BOX = { + width: 300, + height: 100, + backgroundColor: "#ffffff", + color: "#000000", +} as const + +const INHERITED: Array<[string, Record]> = [ + ["color", { color: "#ff0000" }], + ["fontSize", { fontSize: 30 }], + ["fontWeight", { fontWeight: "bold" }], + ["fontFamily", { fontFamily: "Courier New" }], + ["lineHeight", { lineHeight: 2.5 }], + ["textAlign", { textAlign: "right" }], +] + +describeNative("text inheritance", () => { + for (const [name, declaration] of INHERITED) { + it(`${name} on an ancestor paints the same as ${name} on the text`, () => { + const onAncestor = createTestRoot() + onAncestor.render( +
+ Hello there world +
+ ) + onAncestor.renderer.captureScreenshot(shot(`${name}-ancestor`)) + onAncestor.unmount() + + const undeclared = createTestRoot() + undeclared.render( +
+ Hello there world +
+ ) + undeclared.renderer.captureScreenshot(shot(`${name}-undeclared`)) + undeclared.unmount() + + const onText = createTestRoot() + onText.render( +
+ Hello there world +
+ ) + onText.renderer.captureScreenshot(shot(`${name}-text`)) + + // The declaration has to do something, or the comparison below is empty. + expectScreenshotsDiffer(shot(`${name}-ancestor`), shot(`${name}-undeclared`)) + expectScreenshotsEqual(shot(`${name}-ancestor`), shot(`${name}-text`)) + }) + } + + it("takes the nearest declaration when two ancestors disagree", () => { + const nested = createTestRoot() + nested.render( +
+
+ Hello there world +
+
+ ) + nested.renderer.captureScreenshot(shot("nearest-nested")) + nested.unmount() + + const direct = createTestRoot() + direct.render( +
+
+ Hello there world +
+
+ ) + direct.renderer.captureScreenshot(shot("nearest-direct")) + + expectScreenshotsEqual(shot("nearest-nested"), shot("nearest-direct")) + }) +}) diff --git a/packages/react/src/__tests__/selection.test.tsx b/packages/react/src/__tests__/selection.test.tsx index 12e801e4..99c3b8c4 100644 --- a/packages/react/src/__tests__/selection.test.tsx +++ b/packages/react/src/__tests__/selection.test.tsx @@ -178,7 +178,7 @@ describe("text selection", () => { const a = createTestRoot() a.render(
- + one two three four five six seven eight nine ten
@@ -190,7 +190,7 @@ describe("text selection", () => { const b = createTestRoot() b.render(
- + one two three four five six seven eight nine ten
diff --git a/packages/react/src/__tests__/style-resolution-cache.test.tsx b/packages/react/src/__tests__/style-resolution-cache.test.tsx new file mode 100644 index 00000000..645aa041 --- /dev/null +++ b/packages/react/src/__tests__/style-resolution-cache.test.tsx @@ -0,0 +1,143 @@ +/// The renderer must not resolve a style it already resolved. +/// +/// GPUI is immediate mode. It rebuilds the element tree every frame. Without a +/// cache the renderer turns the same unchanged StyleDesc into the same +/// StyleRefinement on every frame, for every element on screen. +/// +/// These tests count resolutions instead of measuring wall-clock time. A time +/// budget flakes on a loaded machine, and a flaky gate gets muted. A counter +/// gives an exact number, and it fails loudly when the cache stops working. + +import React from "react" +import { describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +describeNative("style resolution cache", () => { + it("resolves nothing on a frame that changed nothing", () => { + const { renderer, render } = createTestRoot() + render( +
+
+
+
+ ) + + renderer.resetStyleResolutions() + renderer.flush() + renderer.flush() + renderer.flush() + + expect(renderer.styleResolutions()).toBe(0) + }) + + it("resolves nothing on a frame that only advances an animation", () => { + const { renderer, render } = createTestRoot() + + renderer.clockPause() + render( +
+
+
+ ) + + renderer.resetStyleResolutions() + renderer.clockFastForward(200) + renderer.flush() + renderer.clockFastForward(200) + renderer.flush() + renderer.clockResume() + + // A motion frame drives eight numbers onto the element. It used to drive + // them onto a copy of the whole style and resolve that, which reparsed + // every declaration the element made on every frame of the animation. + expect(renderer.styleResolutions()).toBe(0) + }) + + it("resolves one style when one element changes", () => { + const { renderer, render } = createTestRoot() + const tree = (color: string) => ( +
+
+
+
+ ) + + render(tree("#ff0000")) + renderer.resetStyleResolutions() + render(tree("#00ff00")) + + expect(renderer.styleResolutions()).toBe(1) + }) + + it("resolves nothing when a re-render sends the same style", () => { + const { renderer, render } = createTestRoot() + const tree = ( +
+
+
+ ) + + render(tree) + renderer.resetStyleResolutions() + render( +
+
+
+ ) + + expect(renderer.styleResolutions()).toBe(0) + }) + + it("resolves the base style and each variant once per element", () => { + const { renderer, render } = createTestRoot() + + renderer.resetStyleResolutions() + render( +
+ ) + + // One base, one hover, one active. + expect(renderer.styleResolutions()).toBe(3) + + renderer.resetStyleResolutions() + renderer.flush() + expect(renderer.styleResolutions()).toBe(0) + }) + + it("keeps the count flat as frames repeat", () => { + const { renderer, render } = createTestRoot() + render( +
+ {Array.from({ length: 20 }, (_, i) => ( +
+ ))} +
+ ) + + renderer.resetStyleResolutions() + for (let i = 0; i < 10; i++) { + renderer.flush() + } + + // Ten frames over 21 styled elements. Every one of those 210 resolutions + // was work the renderer used to repeat. + expect(renderer.styleResolutions()).toBe(0) + }) +}) diff --git a/packages/react/src/__tests__/style-types.check.ts b/packages/react/src/__tests__/style-types.check.ts new file mode 100644 index 00000000..295a619f --- /dev/null +++ b/packages/react/src/__tests__/style-types.check.ts @@ -0,0 +1,35 @@ +// Type-only checks for the `style` prop. `tsc --noEmit` runs them, and nothing +// imports this at runtime. +// +// A `@ts-expect-error` that stops being an error fails the build, so these +// pin the rejections as firmly as the acceptances. + +import type { StyleDesc } from "../types/host.js" + +const declares: StyleDesc = { + "--brand": "#ff0000", + "--pad": 8, + color: "var(--brand)", +} + +// @ts-expect-error one dash is not a custom property +const oneDash: StyleDesc = { "-pad": 8 } + +// @ts-expect-error a custom property holds text, not an object +const notText: StyleDesc = { "--brand": { hue: 1 } } + +// @ts-expect-error the index signature must not loosen the known fields +const wrongFieldType: StyleDesc = { color: 42 } + +// @ts-expect-error a state has no cascade of its own to declare into +const declaredInHover: StyleDesc = { hover: { "--brand": "#ff0000" } } + +// @ts-expect-error states do not nest +const nestedState: StyleDesc = { hover: { hover: {} } } + +export type Checked = typeof declares & + typeof oneDash & + typeof notText & + typeof wrongFieldType & + typeof declaredInHover & + typeof nestedState diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index 8e7bee92..3b111389 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1748,6 +1748,35 @@ describeNative("motion", () => { `) }) + it("paints the animated width, not the declared one", () => { + const { render, renderer } = createTestRoot() + + renderer.clockPause() + render( + + ) + + const id = renderer.findByType("div")[0]!.id + const width = () => renderer.getElementBounds(id)?.[2] ?? 0 + + const start = width() + renderer.clockFastForward(500) + const middle = width() + renderer.clockFastForward(1000) + const end = width() + renderer.clockResume() + + expect(start).toBeCloseTo(40, 0) + expect(middle).toBeGreaterThan(start) + expect(middle).toBeLessThan(240) + expect(end).toBeCloseTo(240, 0) + }) + it("renders the normal element when an internal motion payload is invalid", () => { const { render, renderer } = createTestRoot() diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ae5cb16c..48b02c6d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,6 @@ // GPUIX React - React bindings for GPUI export { createRoot, flushSync } from "./reconciler/index.js" +export type { ClassNameResolver, RootOptions } from "./types/host.js" export { createRenderer, enableAutomation, diff --git a/packages/react/src/reconciler/class-names.ts b/packages/react/src/reconciler/class-names.ts new file mode 100644 index 00000000..dc3984e3 --- /dev/null +++ b/packages/react/src/reconciler/class-names.ts @@ -0,0 +1,134 @@ +/// Turning a `className` into a style. +/// +/// GPUIX ships no resolver. A root takes one through +/// `createRoot(renderer, { resolveClassName })`, and `@gpuix/tailwind` is the +/// one this repository plans to publish. Without a resolver a `className` does +/// nothing and warns once. +/// +/// The resolver reads one token, such as `p-4`, and never a whole string. That +/// is what makes the cache work. `clsx("p-4", a && "bg-blue-500", b && +/// "text-lg")` writes up to eight strings from three tokens, and five toggles +/// write thirty-two. A bounded cache over whole strings sits in front, because +/// the same string usually repeats between two frames and then neither the +/// split nor the merge runs. + +import type { + ClassNameCache, + ClassNameResolver, + StyleDeclarations, + StyleDesc, +} from "../types/host.js" + +/// How many whole class strings a root remembers. +/// +/// The token cache below it is unbounded, because the set of tokens an +/// application uses is fixed by its source code. The set of strings is not: it +/// grows with every combination of conditional classes. +const STRING_LIMIT = 256 + +export function createClassNameCache(resolve: ClassNameResolver): ClassNameCache { + return { resolve, tokens: new Map(), strings: new Map() } +} + +/// The style a class string declares, or `null` when it declares nothing. +/// +/// The result is the cached object, shared by every element with this class +/// string. Callers read it and copy from it. None of them write to it. +export function styleForClassName( + className: string | undefined, + cache: ClassNameCache | null +): StyleDesc | null { + if (!className) return null + if (!cache) return null + + const hit = cache.strings.get(className) + if (hit !== undefined) { + // Least recently used goes out first, so a hit moves to the back. + cache.strings.delete(className) + cache.strings.set(className, hit) + return hit + } + + const merged: Mutable = {} + let declared = false + for (const token of className.split(/\s+/)) { + if (!token) continue + const style = tokenStyle(token, cache) + if (!style) continue + mergeInto(merged, style) + declared = true + } + + const style = declared ? (merged as StyleDesc) : null + if (style) { + if (cache.strings.size >= STRING_LIMIT) { + const oldest = cache.strings.keys().next() + if (!oldest.done) cache.strings.delete(oldest.value) + } + cache.strings.set(className, style) + } + return style +} + +function tokenStyle(token: string, cache: ClassNameCache): StyleDesc | null { + const cached = cache.tokens.get(token) + if (cached !== undefined) return cached + const style = cache.resolve(token) ?? null + cache.tokens.set(token, style) + return style +} + +/// A style being built. `StyleDesc` has no index signature for its own keys, so +/// the merges below write through this instead of casting at each line. +type Mutable = Record + +function mergeInto(target: Mutable, source: StyleDesc): void { + for (const [key, value] of Object.entries(source)) { + if (key === "hover" || key === "active") continue + target[key] = value + } + mergeState(target, "hover", source.hover) + mergeState(target, "active", source.active) +} + +function mergeState( + target: Mutable, + state: "hover" | "active", + source: StyleDeclarations | undefined +): void { + if (!source) return + target[state] = { ...(target[state] as StyleDeclarations | undefined), ...source } +} + +/// The style prop laid over the style a class string declared. +/// +/// [CSS Style Attributes][spec] gives the attribute "a specificity higher than +/// any selector", so an inline declaration wins over a class in every state. A +/// key the style prop sets is therefore removed from `hover` and `active` as +/// well, or an element with `style={{ backgroundColor: "red" }}` would turn +/// blue under a `hover:bg-blue-500` class, where a browser keeps it red. +/// +/// [spec]: https://www.w3.org/TR/css-style-attr/#cascading +export function withInlineStyle( + fromClass: StyleDesc | null, + inline: StyleDesc | undefined +): StyleDesc { + if (!fromClass) return inline ?? {} + if (!inline) return fromClass + + const merged: Mutable = { ...fromClass } + const hover = fromClass.hover ? { ...(fromClass.hover as Mutable) } : undefined + const active = fromClass.active ? { ...(fromClass.active as Mutable) } : undefined + if (hover) merged.hover = hover + if (active) merged.active = active + + for (const [key, value] of Object.entries(inline)) { + if (key === "hover" || key === "active") continue + merged[key] = value + if (hover) delete hover[key] + if (active) delete active[key] + } + mergeState(merged, "hover", inline.hover) + mergeState(merged, "active", inline.active) + return merged as StyleDesc +} diff --git a/packages/react/src/reconciler/host-config.ts b/packages/react/src/reconciler/host-config.ts index 9de3bcb7..ed0c0454 100644 --- a/packages/react/src/reconciler/host-config.ts +++ b/packages/react/src/reconciler/host-config.ts @@ -17,8 +17,10 @@ import type { NativeRenderer, Props, PublicInstance, + StyleDesc, TextInstance, } from "../types/host.js" +import { styleForClassName, withInlineStyle } from "./class-names.js" import { registerEventHandler, unregisterEventHandler, @@ -122,10 +124,37 @@ function diffEventListeners( // ── Style helper ───────────────────────────────────────────────────── -function sendStyle(renderer: NativeRenderer, id: number, props: Props): void { - const style = props.style - if (style == null || Object.keys(style).length === 0) return - renderer.setStyle(id, style) +/** + * The style an element should have, from all of its style sources. + * + * Every place that sends a style to the renderer goes through here. When a + * source is added, one edit covers all of them. The previous code repeated + * `props.style` at each call site, and `hideInstance` did not repeat it, so + * hiding an element dropped its style. + */ +function computeStyle(props: Props, container: Container): StyleDesc { + if (props.className && !container.classNames) { + warnAboutMissingResolver(container) + return props.style ?? {} + } + return withInlineStyle(styleForClassName(props.className, container.classNames), props.style) +} + +/// One warning per root. A `className` with no resolver is a setup mistake, and +/// repeating it once per element per commit would bury everything else. +function warnAboutMissingResolver(container: Container): void { + if (container.warnedAboutClassName) return + container.warnedAboutClassName = true + console.warn( + "GPUIX: an element has a `className` but this root has no resolver. " + + "Pass one to createRoot, such as createRoot(renderer, { resolveClassName })." + ) +} + +function sendStyle(container: Container, id: number, props: Props): void { + const style = computeStyle(props, container) + if (Object.keys(style).length === 0) return + container.renderer.setStyle(id, style) } // ── Custom prop forwarding ─────────────────────────────────────────── @@ -210,7 +239,7 @@ function materialize(node: HostNode): HostNodeState { const renderer = state.container.renderer if ("type" in node) { renderer.createElement(node.id, node.type) - sendStyle(renderer, node.id, node.props) + sendStyle(state.container, node.id, node.props) syncEventListeners(state.container, node.id, node.props) syncCustomProps(renderer, node.id, node.type, node.props) } else { @@ -366,7 +395,7 @@ export const hostConfig = { const container = containerFor(instance) // Always resend style — per-element JSON is small, and this avoids // bugs from same-reference mutations or style removal. - container.renderer.setStyle(instance.id, newProps.style ?? {}) + container.renderer.setStyle(instance.id, computeStyle(newProps, container)) diffEventListeners(container, instance.id, oldProps, newProps) // Custom prop diff (for non-div/text elements) diffCustomProps(container.renderer, instance.id, instance.type, oldProps, newProps) @@ -392,11 +421,21 @@ export const hostConfig = { }, hideInstance(instance: Instance): void { - rendererFor(instance).setStyle(instance.id, { visibility: "hidden" }) + // Keep the element's own style. `visibility: hidden` skips the paint and + // keeps the layout box, so replacing the whole style here would collapse + // the box and lose every other style source on the element. + // + // The pseudo-selector styles go, because a hidden element must stay + // hidden. A hover style that sets `visibility` would otherwise paint an + // element React asked to hide. + const container = containerFor(instance) + const { hover: _hover, active: _active, ...base } = computeStyle(instance.props, container) + container.renderer.setStyle(instance.id, { ...base, visibility: "hidden" }) }, - unhideInstance(instance: Instance, _props: Props): void { - rendererFor(instance).setStyle(instance.id, instance.props.style ?? {}) + unhideInstance(instance: Instance, props: Props): void { + const container = containerFor(instance) + container.renderer.setStyle(instance.id, computeStyle(props, container)) }, hideTextInstance(_textInstance: TextInstance): void {}, diff --git a/packages/react/src/reconciler/reconciler.ts b/packages/react/src/reconciler/reconciler.ts index a2e11bfc..7bd408a1 100644 --- a/packages/react/src/reconciler/reconciler.ts +++ b/packages/react/src/reconciler/reconciler.ts @@ -4,8 +4,14 @@ import ReactReconciler from "react-reconciler" import type { OpaqueRoot } from "react-reconciler" import { ConcurrentRoot } from "react-reconciler/constants.js" import { GpuixContext } from "../hooks/use-gpuix.js" -import type { Container, ElementIdAllocator, NativeRenderer } from "../types/host.js" +import type { + Container, + ElementIdAllocator, + NativeRenderer, + RootOptions, +} from "../types/host.js" import { wrapWithBatching } from "./batch-renderer.js" +import { createClassNameCache } from "./class-names.js" import { attachRoot, detachRoot } from "./event-registry.js" import { hostConfig } from "./host-config.js" @@ -42,13 +48,17 @@ function idAllocatorFor(renderer: NativeRenderer): ElementIdAllocator { return alloc } -export function createRoot(renderer: NativeRenderer): Root { +export function createRoot(renderer: NativeRenderer, options: RootOptions = {}): Root { let container: OpaqueRoot | null = null const batchedRenderer = wrapWithBatching(renderer) const gpuixContainer: Container = { renderer: batchedRenderer, ids: idAllocatorFor(renderer), eventHandlers: new Map(), + classNames: options.resolveClassName + ? createClassNameCache(options.resolveClassName) + : null, + warnedAboutClassName: false, } attachRoot(renderer, gpuixContainer) attachRoot(batchedRenderer, gpuixContainer) diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index 507d5dbe..1ec92a96 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -16,6 +16,7 @@ import type { DebugFrameOverlayMode, DebugFrameOverlayStats, NativeRenderer, + RootOptions, } from "./types/host.js" import { createRoot, flushSync, type Root } from "./reconciler/reconciler.js" import { handleGpuixEvent } from "./reconciler/event-registry.js" @@ -50,6 +51,8 @@ interface NativeTestRendererApi extends NativeRenderer { cycleDebugFrameOverlay(): string resetDebugFrameOverlayStats(): void getDebugFrameOverlayStats(): DebugFrameOverlayStats + styleResolutions(): number + resetStyleResolutions(): void dragSelect(x1: number, y1: number, x2: number, y2: number): void getSelectedText(): string | null getPaintedText(): string[] @@ -528,6 +531,16 @@ export class TestRenderer implements NativeRenderer { return this.native.getDebugFrameOverlayStats() } + /** How many styles the renderer resolved since the last reset. + * A frame that changes nothing must not raise this. */ + styleResolutions(): number { + return this.native.styleResolutions() + } + + resetStyleResolutions(): void { + this.native.resetStyleResolutions() + } + /** Capture a screenshot of the current rendered UI and save as PNG. * macOS only — requires Metal GPU rendering via VisualTestAppContext. */ captureScreenshot(path: string): void { @@ -556,9 +569,9 @@ export interface TestRoot { * Returns the Root (for rendering), the TestRenderer (for inspection/events), * and convenience methods. */ -export function createTestRoot(): TestRoot { +export function createTestRoot(options: RootOptions = {}): TestRoot { const renderer = new TestRenderer() - const root = createRoot(renderer) + const root = createRoot(renderer, options) const render = (node: ReactNode): void => { flushSync(() => root.render(node)) diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 1e83f085..7ab003c8 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -5,12 +5,12 @@ export type DimensionValue = number | string export interface MotionStyle { width?: number height?: number - opacity?: number - top?: number - right?: number - bottom?: number - left?: number - borderRadius?: number + opacity?: Numeric + top?: Numeric + right?: Numeric + bottom?: Numeric + left?: Numeric + borderRadius?: Numeric } export type MotionEase = @@ -43,23 +43,33 @@ export interface BoxShadow { color: string } +/** + * A style value that resolves to a number. + * + * A bare number is pixels, which is what the `style` prop has always taken. + * A string is there for `var()` and for writing the unit, so `8`, `"8px"` and + * `"var(--pad)"` all mean the same padding. Any other unit drops the + * declaration rather than paint the wrong size, so `"2rem"` does nothing. + */ +export type Numeric = number | string + export interface StyleDesc { display?: string visibility?: string flexDirection?: string flexWrap?: string - flexGrow?: number - flexShrink?: number - flexBasis?: number + flexGrow?: Numeric + flexShrink?: Numeric + flexBasis?: Numeric alignItems?: string alignSelf?: string alignContent?: string justifyContent?: string - gap?: number - rowGap?: number - columnGap?: number - gridTemplateColumns?: number - gridTemplateRows?: number + gap?: Numeric + rowGap?: Numeric + columnGap?: Numeric + gridTemplateColumns?: Numeric + gridTemplateRows?: Numeric gridColumnMin?: "zero" | "min-content" | "max-content" gridRowMin?: "zero" | "min-content" | "max-content" @@ -70,17 +80,17 @@ export interface StyleDesc { maxWidth?: DimensionValue maxHeight?: DimensionValue - padding?: number - paddingTop?: number - paddingRight?: number - paddingBottom?: number - paddingLeft?: number + padding?: Numeric + paddingTop?: Numeric + paddingRight?: Numeric + paddingBottom?: Numeric + paddingLeft?: Numeric - margin?: number - marginTop?: number - marginRight?: number - marginBottom?: number - marginLeft?: number + margin?: Numeric + marginTop?: Numeric + marginRight?: Numeric + marginBottom?: Numeric + marginLeft?: Numeric position?: string top?: number @@ -93,27 +103,27 @@ export interface StyleDesc { color?: string opacity?: number - borderWidth?: number - borderTopWidth?: number - borderRightWidth?: number - borderBottomWidth?: number - borderLeftWidth?: number + borderWidth?: Numeric + borderTopWidth?: Numeric + borderRightWidth?: Numeric + borderBottomWidth?: Numeric + borderLeftWidth?: Numeric borderColor?: string borderRadius?: number - borderTopLeftRadius?: number - borderTopRightRadius?: number - borderBottomLeftRadius?: number - borderBottomRightRadius?: number + borderTopLeftRadius?: Numeric + borderTopRightRadius?: Numeric + borderBottomLeftRadius?: Numeric + borderBottomRightRadius?: Numeric boxShadow?: BoxShadow - fontSize?: number + fontSize?: Numeric fontFamily?: string fontWeight?: string | number textAlign?: string - lineHeight?: number + lineHeight?: Numeric whiteSpace?: "normal" | "nowrap" textOverflow?: "ellipsis" | "ellipsis-start" - lineClamp?: number + lineClamp?: Numeric overflow?: string overflowX?: string @@ -129,12 +139,36 @@ export interface StyleDesc { /** Selection wash colour for this subtree. Defaults to the theme accent at 35%. */ selectionColor?: string - // Pseudo-selector styles — applied by GPUI natively (no JS round-trip). + // Pseudo-selector styles, applied by GPUI natively (no JS round-trip). // Nesting is one level deep: hover/active cannot contain hover/active. - hover?: Omit - active?: Omit + // + // These two are the only conditions `style` carries, and they are here for + // history. A CSS `style` attribute holds declarations, not selectors. Any + // further condition belongs in a class, not here. + hover?: StyleDeclarations + active?: StyleDeclarations + + // Custom properties. A declaration here is in scope for `var()` on this + // element and on everything below it, the same as in CSS. + // + // A number declares its own plain text, so `{ "--pad": 8 }` declares `8`. + // The name needs both dashes: `"-pad"` is a type error rather than a + // variable that silently never resolves. + [name: `--${string}`]: string | number | undefined } +/** + * What `hover` and `active` may hold. + * + * No nesting, and no custom properties. A declaration inside a state has + * nothing to apply to, because the cascade reads variables from the element + * itself, not from one of its states. + */ +export type StyleDeclarations = Omit< + StyleDesc, + "hover" | "active" | `--${string}` +> + // Element types supported by GPUIX export type ElementType = | "div" @@ -254,6 +288,14 @@ export interface GpuixTheme { // Use React refs to get an element's ID: ref.current.id export interface Props { style?: StyleDesc + /** + * Class tokens, separated by spaces, read by the root's resolver. + * + * `string | undefined` is the whole type, so `clsx` and `cn` need no special + * handling. Without a resolver on the root this does nothing and warns once. + * A declaration in `style` beats one from a class in every state. + */ + className?: string children?: React.ReactNode ref?: React.Ref @@ -488,6 +530,38 @@ export interface Container { renderer: NativeRenderer ids: ElementIdAllocator eventHandlers: EventHandlerMap + /** How this root reads `className`, or `null` when nothing registered one. */ + classNames: ClassNameCache | null + /** Whether this root has already warned that it has no resolver. */ + warnedAboutClassName: boolean +} + +/** + * Reads one class token, such as `p-4`, into the style it declares. + * + * Returns `null` for a token it does not know. The token never holds a space, + * because the root splits the string before it calls this. + */ +export type ClassNameResolver = (token: string) => StyleDesc | null + +/** A resolver with what a root has already asked it. */ +export interface ClassNameCache { + resolve: ClassNameResolver + /** One entry per token, holding `null` for a token the resolver rejected. */ + tokens: Map + /** Whole strings, bounded, least recently used out first. */ + strings: Map +} + +/** Options for a root. */ +export interface RootOptions { + /** + * How to read `className` on this root's elements. + * + * This is an option on the root rather than a global, so two roots can hold + * different resolvers and two test files can run at once. + */ + resolveClassName?: ClassNameResolver } // Instance — minimal handle for React's reconciler. diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json new file mode 100644 index 00000000..c5ee9e70 --- /dev/null +++ b/packages/react/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + // `tsc` never sees `src/__tests__`: the build config excludes it, and the + // test files there carry type errors that predate this config. `files` + // survives `exclude`, so this config adds back the one file whose whole + // purpose is to be typechecked. + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true }, + "files": ["src/__tests__/style-types.check.ts"], + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/__tests__"] +} From 7dae1eba45cd4ac60b7742c38f27cc8c5e740d4e Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 13:53:01 +0200 Subject: [PATCH 02/29] feat(motion): animate height to auto --- .changeset/animate-height-to-auto.md | 15 +++ packages/native/src/motion.rs | 83 +++++++++++- packages/native/src/renderer.rs | 1 + packages/native/src/renderer/auto_height.rs | 129 +++++++++++++++++++ packages/native/src/renderer/frame.rs | 30 ++++- packages/native/src/style/resolve.rs | 10 +- packages/react/src/__tests__/styles.test.tsx | 63 +++++++++ packages/react/src/types/host.ts | 10 +- 8 files changed, 328 insertions(+), 13 deletions(-) create mode 100644 .changeset/animate-height-to-auto.md create mode 100644 packages/native/src/renderer/auto_height.rs diff --git a/.changeset/animate-height-to-auto.md b/.changeset/animate-height-to-auto.md new file mode 100644 index 00000000..e76fa672 --- /dev/null +++ b/.changeset/animate-height-to-auto.md @@ -0,0 +1,15 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Animate `height` to `auto` + +A motion `height` now takes `"auto"` at either end of the animation. `auto` is +the height the content takes, and only layout knows that number, so the element +measures its content every frame and interpolates against the measurement. An +animation that opens a panel follows content that changes while it runs. + +The measurement happens before the element knows its own width, so declare a +pixel `width` to make it exact. Without one the content measures unwrapped, +which reads short for text that would have wrapped. diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index d96f4b9e..6de28e7b 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -10,7 +10,7 @@ use crate::style::{DimensionValue, StyleDesc}; #[serde(rename_all = "camelCase")] pub(crate) struct MotionStyle { pub width: Option, - pub height: Option, + pub height: Option, pub opacity: Option, pub top: Option, pub right: Option, @@ -19,15 +19,74 @@ pub(crate) struct MotionStyle { pub border_radius: Option, } +/// One end of a `height` interpolation. +/// +/// CSS Values 5 calls an interpolation with a keyword at one end an +/// `interpolate-size`. `auto` has no number until layout runs, so it stays a +/// keyword here and the element that owns the height resolves it. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +pub(crate) enum MotionHeight { + Length(f64), + Keyword(HeightKeyword), +} + +/// The size keywords a `height` animation accepts. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +pub(crate) enum HeightKeyword { + #[serde(rename = "auto")] + Auto, +} + +impl MotionHeight { + /// This end as a number, or `None` when it is a keyword. + fn length(self) -> Option { + match self { + Self::Length(value) => Some(value), + Self::Keyword(HeightKeyword::Auto) => None, + } + } +} + +/// A `height` interpolation with `auto` at one end or both. +/// +/// `None` means `auto`. Only layout knows what number that is, so the element +/// that owns the height measures its content and calls `resolve`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct HeightTween { + pub from: Option, + pub to: Option, + pub progress: f64, +} + +impl HeightTween { + /// The height for this frame, given the height the content takes. + pub(crate) fn resolve(self, content: f64) -> f64 { + let from = self.from.unwrap_or(content); + let to = self.to.unwrap_or(content); + from + (to - from) * self.progress + } +} + impl MotionStyle { fn interpolate(self, target: Self, progress: f64) -> Self { fn value(from: Option, to: Option, progress: f64) -> Option { to.map(|to| from.unwrap_or(to) + (to - from.unwrap_or(to)) * progress) } + // A keyword at either end leaves `height` alone. `MotionState::frame` + // hands that case to the renderer as a `HeightTween` instead. + let height = match (self.height, target.height) { + (from, Some(MotionHeight::Length(to))) => { + let from = from.and_then(MotionHeight::length).unwrap_or(to); + Some(MotionHeight::Length(from + (to - from) * progress)) + } + _ => None, + }; + Self { width: value(self.width, target.width, progress), - height: value(self.height, target.height, progress), + height, opacity: value(self.opacity, target.opacity, progress), top: value(self.top, target.top, progress), right: value(self.right, target.right, progress), @@ -41,7 +100,7 @@ impl MotionStyle { if let Some(value) = self.width { style.width = Some(DimensionValue::Pixels(value)); } - if let Some(value) = self.height { + if let Some(MotionHeight::Length(value)) = self.height { style.height = Some(DimensionValue::Pixels(value)); } if let Some(value) = self.opacity { @@ -120,6 +179,9 @@ struct MotionDescription { #[derive(Clone, Copy, Debug)] pub(crate) struct MotionFrame { pub style: MotionStyle, + /// The `height` interpolation when `auto` is at one end of it, which + /// `style` cannot carry because it has no number yet. + pub height: Option, pub active: bool, } @@ -210,8 +272,16 @@ impl MotionState { let active = self.from != self.target && raw < 1.0; let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease); + let keyword_at_either_end = matches!(self.target.height, Some(MotionHeight::Keyword(_))) + || matches!(self.from.height, Some(MotionHeight::Keyword(_))); + MotionFrame { style: self.from.interpolate(self.target, progress), + height: (keyword_at_either_end && self.target.height.is_some()).then(|| HeightTween { + from: self.from.height.and_then(MotionHeight::length), + to: self.target.height.and_then(MotionHeight::length), + progress, + }), active, } } @@ -237,7 +307,7 @@ fn parse_description(source: &serde_json::Value) -> Result Result<(), String> { for (name, value) in [ ("width", style.width), - ("height", style.height), + ("height", style.height.and_then(MotionHeight::length)), ("opacity", style.opacity), ("top", style.top), ("right", style.right), @@ -250,7 +320,10 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> { } } if style.width.is_some_and(|value| value < 0.0) - || style.height.is_some_and(|value| value < 0.0) + || style + .height + .and_then(MotionHeight::length) + .is_some_and(|value| value < 0.0) || style.border_radius.is_some_and(|value| value < 0.0) { return Err("motion sizes and borderRadius must be non-negative".to_string()); diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 8608841d..7c1a815e 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -40,6 +40,7 @@ use crate::theme::Theme; gpui::actions!(gpuix_focus, [FocusNext, FocusPrevious]); +mod auto_height; mod batch; mod frame; mod virtual_list; diff --git a/packages/native/src/renderer/auto_height.rs b/packages/native/src/renderer/auto_height.rs new file mode 100644 index 00000000..db0548c4 --- /dev/null +++ b/packages/native/src/renderer/auto_height.rs @@ -0,0 +1,129 @@ +//! Animating a `height` toward the height the content takes. + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, Pixels, Style, Window, px, size, +}; + +use crate::motion::HeightTween; + +/// One element whose `height` animates with `auto` at an end of it. +/// +/// `auto` is the height the content takes, and only layout knows that number. +/// GPUI lets an element lay a child out as a detached root while it requests +/// its own layout, so this measures the content there, resolves the tween +/// against the measurement, and asks for that height. +/// +/// The measurement runs before this element knows its own width. A declared +/// width is what makes it exact. Without one the content measures unwrapped, +/// which reads short for text that would have wrapped. +pub(super) struct AutoHeight { + id: u64, + child: AnyElement, + tween: HeightTween, + width: Option, +} + +impl AutoHeight { + pub(super) fn new( + id: u64, + child: AnyElement, + tween: HeightTween, + width: Option, + ) -> Self { + Self { + id, + child, + tween, + width, + } + } +} + +impl Element for AutoHeight { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + let available = size( + self.width + .map_or(AvailableSpace::MaxContent, AvailableSpace::Definite), + AvailableSpace::MaxContent, + ); + let content = self.child.layout_as_root(available, window, cx); + let height = self.tween.resolve(f64::from(f32::from(content.height))); + + let mut style = Style::default(); + style.size.height = px(height as f32).into(); + if let Some(width) = self.width { + style.size.width = width.into(); + } + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut (), + window: &mut Window, + cx: &mut App, + ) { + // The content keeps the height it measured, so the box clips while the + // animated height is shorter than it. Taffy's `overflow` decides + // layout, not painting, which is why this is a mask rather than a + // style. + window.with_content_mask(Some(ContentMask { bounds }), |window| { + self.child.layout_as_root( + size( + AvailableSpace::Definite(bounds.size.width), + AvailableSpace::MaxContent, + ), + window, + cx, + ); + self.child.prepaint_at(bounds.origin, window, cx); + }); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask(Some(ContentMask { bounds }), |window| { + self.child.paint(window, cx); + }); + // The child painted its own tracker at the height it measured. The box + // on screen is this one, so it records last and wins. + crate::automation::record_bounds(self.id, bounds); + } +} + +impl IntoElement for AutoHeight { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 1b5c4530..f4110fd2 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -70,7 +70,7 @@ pub(super) fn build_element( state.is_valid().then(|| { let frame = state.frame(ctx.now); *ctx.motion_active |= frame.active; - frame.style + frame }) } else { ctx.motion_states.remove(&id); @@ -109,9 +109,9 @@ pub(super) fn build_element( // Custom renderers take a `StyleDesc` and resolve it themselves, so // a motion frame reaches them folded into one. They are the only // callers that still pay for that fold. - let animated = motion.map(|motion| { + let animated = motion.map(|frame| { let mut declared = element.style.clone().unwrap_or_default(); - motion.apply_to(&mut declared); + frame.style.apply_to(&mut declared); declared }); let style = animated.as_deref().or(style); @@ -145,6 +145,26 @@ pub(super) fn build_element( } }; + // A `height` animating toward `auto` needs a number that only layout knows, + // so the element that owns it measures its content and wraps this one. + let built = match motion.and_then(|frame| frame.height) { + Some(tween) => { + // The measurement runs before the wrapper knows its own width, so a + // declared width is what makes it exact. + let width = motion + .and_then(|frame| frame.style.width) + .or_else(|| match style.and_then(|style| style.width.as_ref()) { + Some(crate::style::DimensionValue::Pixels(value)) => Some(*value), + _ => None, + }) + .map(|value| gpui::px(value as f32)); + gpui::IntoElement::into_any_element(super::auto_height::AutoHeight::new( + id, built, tween, width, + )) + } + None => built, + }; + ctx.cascade = parent_cascade; built } @@ -289,7 +309,7 @@ pub(crate) fn build_div( element: &crate::retained_tree::RetainedElement, style: Option<&StyleDesc>, resolved: Option>, - motion: Option, + motion: Option, ctx: &mut BuildCtx, window: &mut gpui::Window, cx: &mut gpui::Context, @@ -651,7 +671,7 @@ pub(crate) fn build_text( element: &crate::retained_tree::RetainedElement, style: Option<&StyleDesc>, resolved: Option>, - motion: Option, + motion: Option, ctx: &mut BuildCtx, window: &mut gpui::Window, cx: &mut gpui::Context, diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index 786ab8ac..c7624806 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -140,13 +140,19 @@ pub(crate) fn apply_resolved(mut el: E, resolved: &StyleRefinem /// value. pub(crate) fn apply_motion( mut el: E, - motion: crate::motion::MotionStyle, + frame: crate::motion::MotionFrame, declared: Option<&StyleDesc>, ) -> E { + let motion = frame.style; if let Some(width) = motion.width { el = el.w(gpui::px(width as f32)); } - if let Some(height) = motion.height { + // A `height` animated toward `auto` has no number yet. `AutoHeight` owns + // that case, and it measures this element to find the number, so this + // element must not declare a height of its own. + if frame.height.is_some() { + el.style().size.height = Some(gpui::Length::Auto); + } else if let Some(crate::motion::MotionHeight::Length(height)) = motion.height { el = el.h(gpui::px(height as f32)); } if let Some(top) = motion.top { diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index 3b111389..62f7a868 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1777,6 +1777,69 @@ describeNative("motion", () => { expect(end).toBeCloseTo(240, 0) }) + it("animates height to the height the content takes", () => { + const { render, renderer } = createTestRoot() + + renderer.clockPause() + render( + +
+
+ + ) + + const id = renderer.findByType("div")[0]!.id + const height = () => renderer.getElementBounds(id)?.[3] ?? -1 + + const start = height() + renderer.clockFastForward(500) + const middle = height() + renderer.clockFastForward(1000) + const end = height() + renderer.clockResume() + + expect(start).toBeCloseTo(0, 0) + // Two children of 60 and 40 stack to 100, and nothing declares that number. + expect(end).toBeCloseTo(100, 0) + expect(middle).toBeCloseTo(50, 0) + }) + + it("follows content that grows while the animation runs", () => { + const { render, renderer } = createTestRoot() + + const tree = (rows: number) => ( + + {Array.from({ length: rows }, (_, row) => ( +
+ ))} + + ) + + renderer.clockPause() + render(tree(2)) + const id = renderer.findByType("div")[0]!.id + const height = () => renderer.getElementBounds(id)?.[3] ?? -1 + + renderer.clockFastForward(2000) + expect(height()).toBeCloseTo(100, 0) + + // A third row lands after the animation finished. `auto` is measured every + // frame, so the box grows with it rather than holding the old number. + render(tree(3)) + expect(height()).toBeCloseTo(150, 0) + renderer.clockResume() + }) + it("renders the normal element when an internal motion payload is invalid", () => { const { render, renderer } = createTestRoot() diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 7ab003c8..5c650193 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -4,7 +4,15 @@ export type DimensionValue = number | string export interface MotionStyle { width?: number - height?: number + /** + * A length in pixels, or `"auto"` for the height the content takes. + * + * `"auto"` is measured every frame, so the animation follows content that + * changes while it runs. The measurement happens before the element knows + * its own width, so declare a pixel `width` to make it exact. Without one + * the content measures unwrapped, which reads short for text that wraps. + */ + height?: number | "auto" opacity?: Numeric top?: Numeric right?: Numeric From 196304b3c55ac71996a4ba418aa66d91842ea445 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 14:05:39 +0200 Subject: [PATCH 03/29] fix(motion): measure auto height at the resolved width --- .changeset/animate-height-to-auto.md | 6 +- CONTEXT.md | 5 + packages/native/src/motion.rs | 4 +- packages/native/src/renderer/auto_height.rs | 124 +++++++++++++------ packages/native/src/renderer/frame.rs | 5 +- packages/react/src/__tests__/styles.test.tsx | 27 ++++ packages/react/src/types/host.ts | 9 +- 7 files changed, 132 insertions(+), 48 deletions(-) diff --git a/.changeset/animate-height-to-auto.md b/.changeset/animate-height-to-auto.md index e76fa672..d3a8c22b 100644 --- a/.changeset/animate-height-to-auto.md +++ b/.changeset/animate-height-to-auto.md @@ -10,6 +10,6 @@ the height the content takes, and only layout knows that number, so the element measures its content every frame and interpolates against the measurement. An animation that opens a panel follows content that changes while it runs. -The measurement happens before the element knows its own width, so declare a -pixel `width` to make it exact. Without one the content measures unwrapped, -which reads short for text that would have wrapped. +The measurement runs at the width the element really gets, whether that width +comes from a declared length, from `flex`, from a percentage or from a stretched +cross axis. Text wraps the way it will on screen. diff --git a/CONTEXT.md b/CONTEXT.md index 0c2fd0ce..a583b532 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -84,6 +84,11 @@ inside it. `opacity`. A motion frame is not a declaration. It reaches the style sink after the resolved style does, so an animated element keeps its cached resolution. +**Isolated layout tree.** A taffy tree of its own, for laying content out while +the main tree computes. Taffy runs one tree at a time, so an element that sizes +itself from content it has to measure needs a second tree. `IsolatedLayout` in +GPUI holds it, and `AutoHeight` is the element that uses it. + **Resolved style.** The output of the resolve phase for one element: computed values plus the conditional blocks that paint may still apply. Cached on the retained element and dropped when the style changes. diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index 6de28e7b..4bd29f74 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -64,7 +64,9 @@ impl HeightTween { pub(crate) fn resolve(self, content: f64) -> f64 { let from = self.from.unwrap_or(content); let to = self.to.unwrap_or(content); - from + (to - from) * self.progress + // An easing that overshoots can carry a collapse below zero, and CSS has + // no negative `height`. + (from + (to - from) * self.progress).max(0.0) } } diff --git a/packages/native/src/renderer/auto_height.rs b/packages/native/src/renderer/auto_height.rs index db0548c4..7d8e5f0a 100644 --- a/packages/native/src/renderer/auto_height.rs +++ b/packages/native/src/renderer/auto_height.rs @@ -1,25 +1,44 @@ //! Animating a `height` toward the height the content takes. +use std::cell::RefCell; +use std::rc::Rc; + use gpui::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, - InspectorElementId, IntoElement, LayoutId, Pixels, Style, Window, px, size, + InspectorElementId, IntoElement, IsolatedLayout, LayoutId, Pixels, Size, Style, Window, px, + size, }; use crate::motion::HeightTween; +/// The content, and the layout tree it measures in. +/// +/// The measure closure and the element phases both reach this, so it is shared. +struct Content { + element: AnyElement, + /// The content is laid out here rather than in the window's tree, because + /// the measurement runs while the window's tree computes. + layout: IsolatedLayout, +} + /// One element whose `height` animates with `auto` at an end of it. /// /// `auto` is the height the content takes, and only layout knows that number. -/// GPUI lets an element lay a child out as a detached root while it requests -/// its own layout, so this measures the content there, resolves the tween -/// against the measurement, and asks for that height. +/// This asks taffy for a measured box, and taffy calls back with the width the +/// parent gives it. The content is measured at that width, the interpolation +/// resolves against the measurement, and the measured box reports the result as +/// its height. /// -/// The measurement runs before this element knows its own width. A declared -/// width is what makes it exact. Without one the content measures unwrapped, -/// which reads short for text that would have wrapped. +/// Taking the width from taffy is what makes this exact for a width that comes +/// from `flex`, from a percentage, or from a stretched cross axis. Text wraps at +/// the width it will really have. +/// +/// The content keeps the height it measured, so the box clips while the animated +/// height is shorter than it. That is the `overflow: hidden` the web asks for on +/// a box whose height animates. pub(super) struct AutoHeight { id: u64, - child: AnyElement, + content: Rc>, tween: HeightTween, width: Option, } @@ -27,17 +46,31 @@ pub(super) struct AutoHeight { impl AutoHeight { pub(super) fn new( id: u64, - child: AnyElement, + element: AnyElement, tween: HeightTween, width: Option, ) -> Self { Self { id, - child, + content: Rc::new(RefCell::new(Content { + element, + layout: IsolatedLayout::new(), + })), tween, width, } } + + /// Run `f` with the content and the tree it lives in. + fn with_content( + &self, + window: &mut Window, + f: impl FnOnce(&mut AnyElement, &mut Window) -> R, + ) -> R { + let content = &mut *self.content.borrow_mut(); + let element = &mut content.element; + content.layout.enter(window, |window| f(element, window)) + } } impl Element for AutoHeight { @@ -57,22 +90,37 @@ impl Element for AutoHeight { _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, window: &mut Window, - cx: &mut App, + _cx: &mut App, ) -> (LayoutId, ()) { - let available = size( - self.width - .map_or(AvailableSpace::MaxContent, AvailableSpace::Definite), - AvailableSpace::MaxContent, - ); - let content = self.child.layout_as_root(available, window, cx); - let height = self.tween.resolve(f64::from(f32::from(content.height))); - let mut style = Style::default(); - style.size.height = px(height as f32).into(); if let Some(width) = self.width { style.size.width = width.into(); } - (window.request_layout(style, [], cx), ()) + + let content = self.content.clone(); + let tween = self.tween; + let layout_id = window.request_measured_layout( + style, + move |known: Size>, available: Size, window, cx| { + // Taffy asks more than once, with a known width on the pass that + // has resolved one. That pass is the answer that counts, and the + // earlier ones are the intrinsic widths this box reports. + let width = known + .width + .map_or(available.width, AvailableSpace::Definite); + + let content = &mut *content.borrow_mut(); + let element = &mut content.element; + let measured = content + .layout + .enter(window, |window| { + element.layout_as_root(size(width, AvailableSpace::MaxContent), window, cx) + }); + + size(measured.width, px(tween.resolve(f32::from(measured.height) as f64) as f32)) + }, + ); + (layout_id, ()) } fn prepaint( @@ -84,20 +132,18 @@ impl Element for AutoHeight { window: &mut Window, cx: &mut App, ) { - // The content keeps the height it measured, so the box clips while the - // animated height is shorter than it. Taffy's `overflow` decides - // layout, not painting, which is why this is a mask rather than a - // style. - window.with_content_mask(Some(ContentMask { bounds }), |window| { - self.child.layout_as_root( - size( - AvailableSpace::Definite(bounds.size.width), - AvailableSpace::MaxContent, - ), - window, - cx, - ); - self.child.prepaint_at(bounds.origin, window, cx); + self.with_content(window, |element, window| { + window.with_content_mask(Some(ContentMask { bounds }), |window| { + element.layout_as_root( + size( + AvailableSpace::Definite(bounds.size.width), + AvailableSpace::MaxContent, + ), + window, + cx, + ); + element.prepaint_at(bounds.origin, window, cx); + }); }); } @@ -111,10 +157,12 @@ impl Element for AutoHeight { window: &mut Window, cx: &mut App, ) { - window.with_content_mask(Some(ContentMask { bounds }), |window| { - self.child.paint(window, cx); + self.with_content(window, |element, window| { + window.with_content_mask(Some(ContentMask { bounds }), |window| { + element.paint(window, cx); + }); }); - // The child painted its own tracker at the height it measured. The box + // The content painted its own tracker at the height it measured. The box // on screen is this one, so it records last and wins. crate::automation::record_bounds(self.id, bounds); } diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index f4110fd2..041d80bc 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -149,8 +149,9 @@ pub(super) fn build_element( // so the element that owns it measures its content and wraps this one. let built = match motion.and_then(|frame| frame.height) { Some(tween) => { - // The measurement runs before the wrapper knows its own width, so a - // declared width is what makes it exact. + // A declared pixel width goes on the wrapper so taffy resolves the + // box straight to it. Any other width reaches the measurement + // through taffy instead. let width = motion .and_then(|frame| frame.style.width) .or_else(|| match style.and_then(|style| style.width.as_ref()) { diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index 62f7a868..6cc28fac 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1809,6 +1809,33 @@ describeNative("motion", () => { expect(middle).toBeCloseTo(50, 0) }) + it("measures the content at the width the parent gives it", () => { + const { render, renderer } = createTestRoot() + renderer.clockPause() + render( +
+ +
+
+ +
+ ) + const id = renderer.findByType("div")[1]!.id + renderer.clockFastForward(2000) + const end = renderer.getElementBounds(id)?.[3] ?? -1 + renderer.clockResume() + // Nothing declares a width here. The width is the 200 the parent stretches + // the box to, and taffy hands that number to the measurement. Two children + // of 120 wrap into two rows of 30. Measured at max-content instead they + // would sit on one row and the box would stop at 30. + expect(end).toBeCloseTo(60, 0) + }) + it("follows content that grows while the animation runs", () => { const { render, renderer } = createTestRoot() diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 5c650193..76ff1bbc 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -7,10 +7,11 @@ export interface MotionStyle { /** * A length in pixels, or `"auto"` for the height the content takes. * - * `"auto"` is measured every frame, so the animation follows content that - * changes while it runs. The measurement happens before the element knows - * its own width, so declare a pixel `width` to make it exact. Without one - * the content measures unwrapped, which reads short for text that wraps. + * `"auto"` is measured at the width the element really gets, whether that + * comes from a declared length, from `flex`, from a percentage or from a + * stretched cross axis, so text wraps the way it will on screen. The + * measurement repeats every frame, so the animation follows content that + * changes while it runs. */ height?: number | "auto" opacity?: Numeric From 773027dfcd0e3eef4be6efec6c6bbd946de4cae9 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 16:40:31 +0200 Subject: [PATCH 04/29] build: point the zed submodule at the mateo-m fork --- .gitmodules | 4 ++-- zed | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index e5ddfc4f..06cf2b12 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "zed"] path = zed - url = https://github.com/remorses/zed.git - branch = gpui-macos-embedded + url = https://github.com/mateo-m/zed.git + branch = gpui-isolated-layout diff --git a/zed b/zed index 4d809271..06292d41 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit 4d80927168182a26f2820f8d7a06495c6d050123 +Subproject commit 06292d41edc909b1a26d1fb467fd91fc0b0d1245 From 6a0eb3d8a4556ae2cb380114ec42c7e7751785dc Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 16:45:42 +0200 Subject: [PATCH 05/29] build: use ssh for the zed submodule url --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 06cf2b12..a0700209 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "zed"] path = zed - url = https://github.com/mateo-m/zed.git + url = git@github.com:mateo-m/zed.git branch = gpui-isolated-layout From ab004c2a131f989d4d744b8b0c9cf862cb0bdcef Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 18:50:02 +0200 Subject: [PATCH 06/29] feat(style): read width and height as css lengths --- .changeset/sizes-read-css-lengths.md | 21 ++++ examples/chat.test.tsx | 4 +- packages/native/src/style.rs | 103 ++++--------------- packages/native/src/style/resolve.rs | 89 +++++++--------- packages/native/src/style/vars.rs | 78 ++++++++++++++ packages/react/src/__tests__/sizing.test.tsx | 97 +++++++++++++++++ packages/react/src/types/host.ts | 11 ++ 7 files changed, 266 insertions(+), 137 deletions(-) create mode 100644 .changeset/sizes-read-css-lengths.md create mode 100644 packages/react/src/__tests__/sizing.test.tsx diff --git a/.changeset/sizes-read-css-lengths.md b/.changeset/sizes-read-css-lengths.md new file mode 100644 index 00000000..c804d747 --- /dev/null +++ b/.changeset/sizes-read-css-lengths.md @@ -0,0 +1,21 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Read `width` and `height` the way CSS reads them. + +`width`, `height`, `minWidth`, `minHeight`, `maxWidth` and `maxHeight` used to +take a number, a percentage or `"auto"` and nothing else, so `"200px"`, +`"6rem"`, `"calc(100px + 2rem)"` and `"var(--size)"` were all rejected. They now +go through the same length parser as `padding`, `gap` and `fontSize`, and keep +the percentage and `"auto"` they always took. + +A value the parser cannot read used to throw out of `setStyle` and lose every +other property written in the same commit, so one bad size painted an element +with no style at all. It now drops the one declaration and leaves the rest +alone, which is what a browser does with a declaration it cannot parse. + +The values resolve when the style resolves rather than when it is read off the +wire, so a size can name a custom property and follow it when the property +changes. diff --git a/examples/chat.test.tsx b/examples/chat.test.tsx index 2a91bc1f..b4613b7a 100644 --- a/examples/chat.test.tsx +++ b/examples/chat.test.tsx @@ -111,9 +111,11 @@ describeNative('chat example', () => { const transcript = renderer.findByType('virtual-list')[0] expect(transcript).toBeDefined() + // Every row declares `width: "100%"`, and the retained style now reports + // back what was written rather than a number it read the percentage as. expect( transcript.children.map((id) => renderer.getElement(id)?.style.width) - ).toEqual(Array(transcript.children.length).fill(1)) + ).toEqual(Array(transcript.children.length).fill('100%')) const painted = renderer.getPaintedText() diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 2391ba23..11804be3 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -144,84 +144,20 @@ pub struct BoxShadowValue { pub color: String, } -/// A dimension value that can be a number (pixels) or a string (percentage, auto, etc.) -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(untagged)] +/// What a sizing property resolves to. +/// +/// `width` and its family take `auto` and resolve a percentage against the +/// parent, which the other length properties do not, so they have their own +/// resolved type. `Scope::dimension` is the only thing that builds one. +#[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum DimensionValue { Pixels(f64), - Percentage(f64), // 0.0 to 1.0 + /// A share of the parent, where `1.0` is the whole of it. + Percentage(f64), + #[default] Auto, } -impl Default for DimensionValue { - fn default() -> Self { - DimensionValue::Auto - } -} - -impl<'de> Deserialize<'de> for DimensionValue { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - use serde::de::{self, Visitor}; - - struct DimensionVisitor; - - impl<'de> Visitor<'de> for DimensionVisitor { - type Value = DimensionValue; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a number or a string like '100%' or 'auto'") - } - - fn visit_f64(self, v: f64) -> Result - where - E: de::Error, - { - Ok(DimensionValue::Pixels(v)) - } - - fn visit_i64(self, v: i64) -> Result - where - E: de::Error, - { - Ok(DimensionValue::Pixels(v as f64)) - } - - fn visit_u64(self, v: u64) -> Result - where - E: de::Error, - { - Ok(DimensionValue::Pixels(v as f64)) - } - - fn visit_str(self, v: &str) -> Result - where - E: de::Error, - { - if v == "auto" { - Ok(DimensionValue::Auto) - } else if v.ends_with('%') { - let num_str = v.trim_end_matches('%'); - match num_str.parse::() { - Ok(n) => Ok(DimensionValue::Percentage(n / 100.0)), - Err(_) => Err(de::Error::custom(format!("invalid percentage: {}", v))), - } - } else { - // Try to parse as a number - match v.parse::() { - Ok(n) => Ok(DimensionValue::Pixels(n)), - Err(_) => Err(de::Error::custom(format!("invalid dimension: {}", v))), - } - } - } - } - - deserializer.deserialize_any(DimensionVisitor) - } -} - /// Declares `StyleDesc` and its `Deserialize` from one field list. /// /// The wire name beside each field drives both directions, so what JS writes @@ -430,13 +366,14 @@ style_desc! { grid_column_min: Option = "gridColumnMin", grid_row_min: Option = "gridRowMin", - // Sizing - now supports both numbers and strings like "100%" or "auto" - width: Option = "width", - height: Option = "height", - min_width: Option = "minWidth", - min_height: Option = "minHeight", - max_width: Option = "maxWidth", - max_height: Option = "maxHeight", + // Sizing. These read the same CSS lengths as every other length property, + // and `auto` and a percentage on top of them. + width: Option = "width", + height: Option = "height", + min_width: Option = "minWidth", + min_height: Option = "minHeight", + max_width: Option = "maxWidth", + max_height: Option = "maxHeight", // Spacing (padding) padding: Option = "padding", @@ -618,8 +555,8 @@ mod tests { .unwrap(); assert_eq!(style.padding_top, Some(Numeric::Number(8.0))); assert_eq!(style.gap, Some(Numeric::Text("var(--gap)".to_owned()))); - assert_eq!(style.width, Some(DimensionValue::Percentage(1.0))); - assert_eq!(style.height, Some(DimensionValue::Auto)); + assert_eq!(style.width, Some(Numeric::Text("100%".to_owned()))); + assert_eq!(style.height, Some(Numeric::Text("auto".to_owned()))); assert_eq!(style.font_weight, Some(FontWeightValue::Str("bold".to_owned()))); assert_eq!(style.line_clamp, None); assert_eq!(style.hover.unwrap().color.as_deref(), Some("red")); @@ -668,7 +605,7 @@ mod tests { let style = StyleDesc { gap: Some(Numeric::Text("calc(1rem + 2px)".to_owned())), font_size: Some(Numeric::Number(14.0)), - max_width: Some(DimensionValue::Pixels(320.0)), + max_width: Some(Numeric::Number(320.0)), user_select: Some("none".to_owned()), custom: [("--pad".to_owned(), serde_json::json!("8px"))].into_iter().collect(), hover: Some(Box::new(StyleDesc { diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index c7624806..b06f1851 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -147,13 +147,13 @@ pub(crate) fn apply_motion( if let Some(width) = motion.width { el = el.w(gpui::px(width as f32)); } - // A `height` animated toward `auto` has no number yet. `AutoHeight` owns - // that case, and it measures this element to find the number, so this - // element must not declare a height of its own. - if frame.height.is_some() { - el.style().size.height = Some(gpui::Length::Auto); - } else if let Some(crate::motion::MotionHeight::Length(height)) = motion.height { - el = el.h(gpui::px(height as f32)); + match motion.height.map(crate::motion::MotionHeight::length) { + Some(Some(height)) => el = el.h(gpui::px(height as f32)), + // A height that still needs the content has no number yet. + // `auto_height::wrap` measures this element to find one, so this + // element must not declare a height of its own. + Some(None) => el.style().size.height = Some(gpui::Length::Auto), + None => {} } if let Some(top) = motion.top { el = el.top(gpui::px(top as f32)); @@ -193,21 +193,37 @@ pub(crate) fn apply_motion( // ── Style application ──────────────────────────────────────────────── -pub(crate) fn apply_width(el: E, dim: &crate::style::DimensionValue) -> E { - match dim { - crate::style::DimensionValue::Pixels(v) => el.w(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) if *v >= 0.999 => el.w_full(), - crate::style::DimensionValue::Percentage(v) => el.w(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => el, +/// The six sizing properties, each read the same way and each landing in its +/// own slot. `Auto` is what all six already default to, so writing it changes +/// nothing. +fn apply_sizes(mut el: E, style: &StyleDesc, scope: &Scope) -> E { + let sizes = el.style(); + for (declared, slot) in [ + (&style.width, &mut sizes.size.width), + (&style.height, &mut sizes.size.height), + (&style.min_width, &mut sizes.min_size.width), + (&style.min_height, &mut sizes.min_size.height), + (&style.max_width, &mut sizes.max_size.width), + (&style.max_height, &mut sizes.max_size.height), + ] { + if let Some(value) = scope.dimension(declared) { + *slot = Some(dimension(value)); + } } + el } -pub(crate) fn apply_height(el: E, dim: &crate::style::DimensionValue) -> E { - match dim { - crate::style::DimensionValue::Pixels(v) => el.h(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) if *v >= 0.999 => el.h_full(), - crate::style::DimensionValue::Percentage(v) => el.h(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => el, +/// The GPUI length a resolved sizing value means. +fn dimension(value: crate::style::DimensionValue) -> gpui::Length { + match value { + crate::style::DimensionValue::Pixels(pixels) => gpui::px(pixels as f32).into(), + // A hair under the whole is a rounded 100%, and a whole is what + // `w_full` writes. + crate::style::DimensionValue::Percentage(share) if share >= 0.999 => { + gpui::relative(1.0).into() + } + crate::style::DimensionValue::Percentage(share) => gpui::relative(share as f32).into(), + crate::style::DimensionValue::Auto => gpui::Length::Auto, } } @@ -316,40 +332,7 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: if let Some(gap) = scope.number(&style.column_gap) { el = el.gap_x(gpui::px(gap as f32)); } - if let Some(ref w) = style.width { - el = apply_width(el, w); - } - if let Some(ref h) = style.height { - el = apply_height(el, h); - } - if let Some(ref min_w) = style.min_width { - match min_w { - crate::style::DimensionValue::Pixels(v) => el = el.min_w(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.min_w(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(ref min_h) = style.min_height { - match min_h { - crate::style::DimensionValue::Pixels(v) => el = el.min_h(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.min_h(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(ref max_w) = style.max_width { - match max_w { - crate::style::DimensionValue::Pixels(v) => el = el.max_w(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.max_w(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } - if let Some(ref max_h) = style.max_height { - match max_h { - crate::style::DimensionValue::Pixels(v) => el = el.max_h(gpui::px(*v as f32)), - crate::style::DimensionValue::Percentage(v) => el = el.max_h(gpui::relative(*v as f32)), - crate::style::DimensionValue::Auto => {} - } - } + el = apply_sizes(el, style, scope); if let Some(p) = scope.number(&style.padding) { el = el.p(gpui::px(p as f32)); } diff --git a/packages/native/src/style/vars.rs b/packages/native/src/style/vars.rs index 83c26b4a..c76838db 100644 --- a/packages/native/src/style/vars.rs +++ b/packages/native/src/style/vars.rs @@ -77,6 +77,37 @@ impl<'a> Scope<'a> { } } + /// The size a sizing property means, or `None` when it means none. + /// + /// `width` and its family take `auto`, and a percentage on them resolves + /// against the parent rather than dropping, so they read through here + /// rather than through `number`. + pub fn dimension( + &self, + value: &Option, + ) -> Option { + use crate::style::DimensionValue; + + let length = match value.as_ref()? { + crate::style::Numeric::Number(number) => Length::Number(*number as f32), + crate::style::Numeric::Text(text) => { + let text = self.value(text)?; + // `auto` is a keyword rather than a length, so the length + // parser never sees it. + if text.trim().eq_ignore_ascii_case("auto") { + return Some(DimensionValue::Auto); + } + gpuix_css::length::length(&text, self.rem_size)? + } + }; + Some(match length { + Length::Number(number) | Length::Pixels(number) => { + DimensionValue::Pixels(number as f64) + } + Length::Fraction(fraction) => DimensionValue::Percentage(fraction as f64), + }) + } + /// The pixels a declaration means. /// /// This is what most properties want. A bare number is pixels, which is how @@ -463,6 +494,53 @@ mod tests { assert_eq!(scope.length(&text("8px")), Some(Length::Pixels(8.0))); } + fn dimension( + value: Option, + pairs: &[(&str, &str)], + ) -> Option { + let variables = scope_of(pairs); + Scope::new(&variables, Rgba::BLACK, false, 16.0).dimension(&value) + } + + #[test] + fn a_size_reads_every_length_the_other_properties_read() { + use crate::style::{DimensionValue, Numeric}; + let text = |t: &str| Some(Numeric::Text(t.to_string())); + + assert_eq!(dimension(Some(Numeric::Number(200.0)), &[]), Some(DimensionValue::Pixels(200.0))); + assert_eq!(dimension(text("200px"), &[]), Some(DimensionValue::Pixels(200.0))); + assert_eq!(dimension(text("6rem"), &[]), Some(DimensionValue::Pixels(96.0))); + assert_eq!(dimension(text("calc(100px + 2rem)"), &[]), Some(DimensionValue::Pixels(132.0))); + assert_eq!( + dimension(text("calc(var(--spacing) * 30)"), &[("--spacing", "4px")]), + Some(DimensionValue::Pixels(120.0)) + ); + } + + #[test] + fn a_size_also_takes_a_share_and_auto() { + use crate::style::{DimensionValue, Numeric}; + let text = |t: &str| Some(Numeric::Text(t.to_string())); + + assert_eq!(dimension(text("50%"), &[]), Some(DimensionValue::Percentage(0.5))); + assert_eq!(dimension(text("auto"), &[]), Some(DimensionValue::Auto)); + assert_eq!(dimension(text("AUTO"), &[]), Some(DimensionValue::Auto)); + assert_eq!(dimension(text("var(--w)"), &[("--w", "auto")]), Some(DimensionValue::Auto)); + } + + #[test] + fn a_size_it_cannot_read_drops_the_declaration() { + use crate::style::Numeric; + let text = |t: &str| Some(Numeric::Text(t.to_string())); + + // None of these throws. The declaration drops and the element keeps + // what it had, which is what CSS does with a value it cannot parse. + assert_eq!(dimension(text("banana"), &[]), None); + assert_eq!(dimension(text("3em"), &[]), None); + assert_eq!(dimension(text("12vw"), &[]), None); + assert_eq!(dimension(text("var(--missing)"), &[]), None); + } + #[test] fn an_absent_declaration_stays_absent() { let variables = scope_of(&[]); diff --git a/packages/react/src/__tests__/sizing.test.tsx b/packages/react/src/__tests__/sizing.test.tsx new file mode 100644 index 00000000..25b9d6d3 --- /dev/null +++ b/packages/react/src/__tests__/sizing.test.tsx @@ -0,0 +1,97 @@ +/// `width`, `height` and the four `min`/`max` properties. +/// +/// These six read the same CSS lengths as every other length property, plus +/// `auto` and a percentage. A value none of that can read drops on its own and +/// leaves the rest of the style alone. + +import fs from "fs" +import path from "path" +import React from "react" +import { beforeAll, describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import type { StyleDesc } from "../types/host.js" +import { expectScreenshotsEqual, SHOTS_DIR } from "./test-utils.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +beforeAll(() => { + fs.mkdirSync(SHOTS_DIR, { recursive: true }) +}) + +const shot = (name: string) => path.join(SHOTS_DIR, `sizing-${name}.png`) + +/// The painted box of the only div in the tree. +function boxOf(style: StyleDesc): [number, number, number, number] { + const { render, renderer } = createTestRoot() + render(
) + const id = renderer.findByType("div")[0]!.id + const bounds = renderer.getElementBounds(id) + expect(bounds).toBeTruthy() + return bounds as [number, number, number, number] +} + +const sizeOf = (style: StyleDesc) => boxOf(style).slice(2) as [number, number] + +describeNative("sizing", () => { + it("takes every absolute unit and rem", () => { + expect(sizeOf({ width: 200, height: 100 })).toEqual([200, 100]) + expect(sizeOf({ width: "200px", height: "100px" })).toEqual([200, 100]) + // A 16 px root, so 6rem is 96 and 1in is 96. + expect(sizeOf({ width: "6rem", height: "1in" })).toEqual([96, 96]) + expect(sizeOf({ width: "72pt", height: "4pc" })).toEqual([96, 64]) + }) + + it("folds arithmetic before layout sees it", () => { + expect(sizeOf({ width: "calc(100px + 2rem)" })[0]).toBe(132) + expect(sizeOf({ width: "min(180px, 12rem)" })[0]).toBe(180) + expect(sizeOf({ width: "clamp(60px, 8rem, 120px)" })[0]).toBe(120) + }) + + it("reads a length through a variable", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+ ) + const id = renderer.findByType("div")[1]!.id + expect(renderer.getElementBounds(id)?.slice(2)).toEqual([120, 4]) + }) + + it("still takes a percentage and auto", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+ ) + const id = renderer.findByType("div")[1]!.id + expect(renderer.getElementBounds(id)?.slice(2)).toEqual([200, 50]) + // `auto` is a keyword, so the length parser never sees it. It has to land + // on the size the box takes when nothing declares one. + expect(sizeOf({ width: "auto", height: "auto" })).toEqual(sizeOf({})) + }) + + it("clamps between min and max written as lengths", () => { + expect(sizeOf({ width: "10rem", minWidth: "12rem" })[0]).toBe(192) + expect(sizeOf({ width: "20rem", maxWidth: "calc(100px + 1rem)" })[0]).toBe(116) + expect(sizeOf({ height: 10, minHeight: "3rem" })[1]).toBe(48) + expect(sizeOf({ height: 200, maxHeight: "5rem" })[1]).toBe(80) + }) + + it("drops a size it cannot read and keeps the rest of the style", () => { + // This used to throw out of setStyle and lose every other property in the + // same commit, so the element painted nothing at all. + const paint = (name: string, style: StyleDesc) => { + const root = createTestRoot() + root.render(
) + root.renderer.captureScreenshot(shot(name)) + root.unmount() + } + for (const bad of ["banana", "3em", "12vw", "var(--missing)"]) { + paint("dropped", { width: bad, height: 100, backgroundColor: "#ff0000" }) + paint("plain", { height: 100, backgroundColor: "#ff0000" }) + expectScreenshotsEqual(shot("dropped"), shot("plain")) + } + }) +}) diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 76ff1bbc..9f438007 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -1,5 +1,16 @@ import type { EventPayload } from "@gpuix/native" +/** + * A value `width`, `height` and the four min and max forms take. + * + * A bare number is pixels. A string is read by the same length parser every + * other length property uses, so `"6rem"`, `"1in"`, `"calc(100px + 2rem)"` and + * `"var(--size)"` all work. On top of those it takes a percentage of the + * parent, such as `"50%"`, and `"auto"` for the size the content takes. + * + * A value the parser cannot read drops the one declaration and leaves the rest + * of the style alone, the way a browser drops a declaration it cannot parse. + */ export type DimensionValue = number | string export interface MotionStyle { From c0dde15ed939ac3575239bc07ca8a52af0c37850 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 18:50:02 +0200 Subject: [PATCH 07/29] fix(motion): collapse a height from where auto left it --- .changeset/close-a-height-from-where-it-is.md | 16 ++ packages/native/src/motion.rs | 228 +++++++++++++----- packages/native/src/renderer/auto_height.rs | 56 ++++- packages/native/src/renderer/frame.rs | 25 +- packages/react/src/__tests__/styles.test.tsx | 55 +++++ 5 files changed, 288 insertions(+), 92 deletions(-) create mode 100644 .changeset/close-a-height-from-where-it-is.md diff --git a/.changeset/close-a-height-from-where-it-is.md b/.changeset/close-a-height-from-where-it-is.md new file mode 100644 index 00000000..33f84a34 --- /dev/null +++ b/.changeset/close-a-height-from-where-it-is.md @@ -0,0 +1,16 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Collapse a `height: auto` animation from the height on screen. + +Opening a panel animated to the height the content takes, but closing it snapped +shut in one frame. Every frame of the closing animation measured zero. + +A motion height now carries a number of pixels and a share of the height the +content takes. `"auto"` is the whole share, a length is none of it, and a frame +between the two is part of each, so `"auto"` and a length are the same kind of +value. A collapse starts from the height that is on screen, and pressing the +button again part way through turns back from the frame it reached instead of +jumping. diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index 4bd29f74..eda8de8c 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use serde::Deserialize; -use crate::style::{DimensionValue, StyleDesc}; +use crate::style::StyleDesc; #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -19,76 +19,108 @@ pub(crate) struct MotionStyle { pub border_radius: Option, } -/// One end of a `height` interpolation. +/// A `height`, as a number of pixels plus a share of the height the content +/// takes. /// /// CSS Values 5 calls an interpolation with a keyword at one end an /// `interpolate-size`. `auto` has no number until layout runs, so it stays a -/// keyword here and the element that owns the height resolves it. +/// share here and the element that owns the height multiplies it out. +/// +/// Both parts are needed because a frame part way between `auto` and a length +/// is part of each. Half way from `0` to `auto` is half the content, and +/// retargeting there has to start from that, which one number cannot hold. #[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[serde(from = "HeightWire")] +pub(crate) struct MotionHeight { + pixels: f64, + content: f64, +} + +/// What a `height` looks like on the wire: a number of pixels or `"auto"`. +/// +/// A motion description parses once per change, so the buffering an untagged +/// enum does costs nothing here, unlike the 36 fields that read `Numeric`. +#[derive(Deserialize)] #[serde(untagged)] -pub(crate) enum MotionHeight { - Length(f64), +enum HeightWire { + Pixels(f64), Keyword(HeightKeyword), } -/// The size keywords a `height` animation accepts. -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] -pub(crate) enum HeightKeyword { +#[derive(Deserialize)] +enum HeightKeyword { #[serde(rename = "auto")] Auto, } -impl MotionHeight { - /// This end as a number, or `None` when it is a keyword. - fn length(self) -> Option { - match self { - Self::Length(value) => Some(value), - Self::Keyword(HeightKeyword::Auto) => None, +impl From for MotionHeight { + fn from(wire: HeightWire) -> Self { + match wire { + HeightWire::Pixels(value) => Self::pixels(value), + HeightWire::Keyword(HeightKeyword::Auto) => Self::content(), } } } -/// A `height` interpolation with `auto` at one end or both. -/// -/// `None` means `auto`. Only layout knows what number that is, so the element -/// that owns the height measures its content and calls `resolve`. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct HeightTween { - pub from: Option, - pub to: Option, - pub progress: f64, -} +impl MotionHeight { + fn pixels(value: f64) -> Self { + Self { + pixels: value, + content: 0.0, + } + } + + /// `auto`, the whole height the content takes. + fn content() -> Self { + Self { + pixels: 0.0, + content: 1.0, + } + } + + /// Whether this needs the height of the content before it is a number. + pub(crate) fn needs_content(self) -> bool { + self.content != 0.0 + } -impl HeightTween { - /// The height for this frame, given the height the content takes. + /// This as a number, or `None` while it still needs the content. + pub(crate) fn length(self) -> Option { + (!self.needs_content()).then_some(self.pixels) + } + + fn mix(self, to: Self, progress: f64) -> Self { + Self { + pixels: mix(self.pixels, to.pixels, progress), + content: mix(self.content, to.content, progress), + } + } + + /// The height this means, given the height the content takes. pub(crate) fn resolve(self, content: f64) -> f64 { - let from = self.from.unwrap_or(content); - let to = self.to.unwrap_or(content); - // An easing that overshoots can carry a collapse below zero, and CSS has - // no negative `height`. - (from + (to - from) * self.progress).max(0.0) + // An easing that overshoots can carry a collapse below zero, and CSS + // has no negative `height`. + (self.pixels + self.content * content).max(0.0) } } +/// One step of a linear interpolation. +fn mix(from: f64, to: f64, progress: f64) -> f64 { + from + (to - from) * progress +} + impl MotionStyle { fn interpolate(self, target: Self, progress: f64) -> Self { fn value(from: Option, to: Option, progress: f64) -> Option { - to.map(|to| from.unwrap_or(to) + (to - from.unwrap_or(to)) * progress) + to.map(|to| mix(from.unwrap_or(to), to, progress)) } - // A keyword at either end leaves `height` alone. `MotionState::frame` - // hands that case to the renderer as a `HeightTween` instead. - let height = match (self.height, target.height) { - (from, Some(MotionHeight::Length(to))) => { - let from = from.and_then(MotionHeight::length).unwrap_or(to); - Some(MotionHeight::Length(from + (to - from) * progress)) - } - _ => None, - }; - Self { width: value(self.width, target.width, progress), - height, + // `auto` interpolates the same way as a length, because both ends + // are pixels plus a share of the content. + height: target + .height + .map(|to| self.height.unwrap_or(to).mix(to, progress)), opacity: value(self.opacity, target.opacity, progress), top: value(self.top, target.top, progress), right: value(self.right, target.right, progress), @@ -100,10 +132,12 @@ impl MotionStyle { pub(crate) fn apply_to(self, style: &mut StyleDesc) { if let Some(value) = self.width { - style.width = Some(DimensionValue::Pixels(value)); + style.width = Some(value.into()); } - if let Some(MotionHeight::Length(value)) = self.height { - style.height = Some(DimensionValue::Pixels(value)); + // A height that still needs the content belongs to `AutoHeight`, which + // reads it from the frame rather than from the style. + if let Some(height) = self.height.and_then(MotionHeight::length) { + style.height = Some(height.into()); } if let Some(value) = self.opacity { style.opacity = Some(value.into()); @@ -181,12 +215,17 @@ struct MotionDescription { #[derive(Clone, Copy, Debug)] pub(crate) struct MotionFrame { pub style: MotionStyle, - /// The `height` interpolation when `auto` is at one end of it, which - /// `style` cannot carry because it has no number yet. - pub height: Option, pub active: bool, } +impl MotionFrame { + /// The `height` for this frame when it still needs the height the content + /// takes. `AutoHeight` measures that, so the style sink leaves it alone. + pub(crate) fn measured_height(&self) -> Option { + self.style.height.filter(|height| height.needs_content()) + } +} + pub(crate) struct MotionState { source: serde_json::Value, from: MotionStyle, @@ -274,16 +313,8 @@ impl MotionState { let active = self.from != self.target && raw < 1.0; let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease); - let keyword_at_either_end = matches!(self.target.height, Some(MotionHeight::Keyword(_))) - || matches!(self.from.height, Some(MotionHeight::Keyword(_))); - MotionFrame { style: self.from.interpolate(self.target, progress), - height: (keyword_at_either_end && self.target.height.is_some()).then(|| HeightTween { - from: self.from.height.and_then(MotionHeight::length), - to: self.target.height.and_then(MotionHeight::length), - progress, - }), active, } } @@ -309,7 +340,7 @@ fn parse_description(source: &serde_json::Value) -> Result Result<(), String> { for (name, value) in [ ("width", style.width), - ("height", style.height.and_then(MotionHeight::length)), + ("height", style.height.map(|height| height.pixels)), ("opacity", style.opacity), ("top", style.top), ("right", style.right), @@ -322,10 +353,7 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> { } } if style.width.is_some_and(|value| value < 0.0) - || style - .height - .and_then(MotionHeight::length) - .is_some_and(|value| value < 0.0) + || style.height.is_some_and(|height| height.pixels < 0.0) || style.border_radius.is_some_and(|value| value < 0.0) { return Err("motion sizes and borderRadius must be non-negative".to_string()); @@ -472,6 +500,82 @@ mod tests { } } + /// The height the frame reports, given a content height of 200. + fn at(frame: MotionFrame) -> Option { + frame.style.height.map(|height| height.resolve(200.0)) + } + + #[test] + fn opens_toward_the_height_the_content_takes() { + let started = Instant::now(); + let description = serde_json::json!({ + "initial": { "height": 0.0 }, + "animate": { "height": "auto" }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let state = MotionState::new(&description, started).unwrap(); + + assert_eq!(at(state.frame(started)), Some(0.0)); + assert_eq!(at(state.frame(started + Duration::from_millis(500))), Some(100.0)); + assert_eq!(at(state.frame(started + Duration::from_secs(1))), Some(200.0)); + } + + #[test] + fn collapses_from_the_height_auto_reached() { + let started = Instant::now(); + let opening = serde_json::json!({ + "initial": { "height": 0.0 }, + "animate": { "height": "auto" }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let mut state = MotionState::new(&opening, started).unwrap(); + + let settled = started + Duration::from_secs(1); + let closing = serde_json::json!({ + "initial": false, + "animate": { "height": 0.0 }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + state.sync(&closing, settled).unwrap(); + + assert_eq!(at(state.frame(settled)), Some(200.0)); + assert_eq!(at(state.frame(settled + Duration::from_millis(500))), Some(100.0)); + assert_eq!(at(state.frame(settled + Duration::from_secs(1))), Some(0.0)); + } + + #[test] + fn reverses_mid_open_without_a_jump() { + let started = Instant::now(); + let opening = serde_json::json!({ + "initial": { "height": 0.0 }, + "animate": { "height": "auto" }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let mut state = MotionState::new(&opening, started).unwrap(); + + let turned = started + Duration::from_millis(500); + let closing = serde_json::json!({ + "initial": false, + "animate": { "height": 0.0 }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + state.sync(&closing, turned).unwrap(); + + // Half open when it turned, so the collapse starts at half. + assert_eq!(at(state.frame(turned)), Some(100.0)); + assert_eq!(at(state.frame(turned + Duration::from_millis(500))), Some(50.0)); + } + + #[test] + fn rejects_a_height_keyword_it_cannot_measure() { + let now = Instant::now(); + let description = serde_json::json!({ + "animate": { "height": "min-content" }, + "transition": {} + }); + assert!(MotionState::new(&description, now).is_err()); + } + #[test] fn finishes_at_the_exact_target() { let started = Instant::now(); diff --git a/packages/native/src/renderer/auto_height.rs b/packages/native/src/renderer/auto_height.rs index 7d8e5f0a..339b0b1e 100644 --- a/packages/native/src/renderer/auto_height.rs +++ b/packages/native/src/renderer/auto_height.rs @@ -9,7 +9,44 @@ use gpui::{ size, }; -use crate::motion::HeightTween; +use crate::motion::{MotionFrame, MotionHeight}; +use crate::style::resolve::Resolved; + +/// `built` inside the element that measures it, when its `height` animates +/// with `auto` at an end. Otherwise `built` as it was. +/// +/// The inner element has to declare no height of its own for the measurement +/// to see the content. `apply_motion` writes `auto` on it for that reason. +/// +/// A pixel width goes on the wrapper so taffy resolves the box straight to it. +/// Any other width reaches the measurement through taffy instead. +pub(super) fn wrap( + id: u64, + built: AnyElement, + motion: Option, + resolved: Option<&Resolved>, +) -> AnyElement { + let Some((frame, height)) = motion.and_then(|frame| Some((frame, frame.measured_height()?))) + else { + return built; + }; + let width = frame + .style + .width + .map(|value| px(value as f32)) + .or_else(|| absolute_pixels(resolved?.base.size.width)); + AutoHeight::new(id, built, height, width).into_any_element() +} + +/// The pixels a resolved length is, or `None` when it is a share or `auto`. +fn absolute_pixels(length: Option) -> Option { + match length? { + gpui::Length::Definite(gpui::DefiniteLength::Absolute(gpui::AbsoluteLength::Pixels( + pixels, + ))) => Some(pixels), + _ => None, + } +} /// The content, and the layout tree it measures in. /// @@ -36,18 +73,18 @@ struct Content { /// The content keeps the height it measured, so the box clips while the animated /// height is shorter than it. That is the `overflow: hidden` the web asks for on /// a box whose height animates. -pub(super) struct AutoHeight { +struct AutoHeight { id: u64, content: Rc>, - tween: HeightTween, + height: MotionHeight, width: Option, } impl AutoHeight { - pub(super) fn new( + fn new( id: u64, element: AnyElement, - tween: HeightTween, + height: MotionHeight, width: Option, ) -> Self { Self { @@ -56,7 +93,7 @@ impl AutoHeight { element, layout: IsolatedLayout::new(), })), - tween, + height, width, } } @@ -98,7 +135,7 @@ impl Element for AutoHeight { } let content = self.content.clone(); - let tween = self.tween; + let height = self.height; let layout_id = window.request_measured_layout( style, move |known: Size>, available: Size, window, cx| { @@ -117,7 +154,10 @@ impl Element for AutoHeight { element.layout_as_root(size(width, AvailableSpace::MaxContent), window, cx) }); - size(measured.width, px(tween.resolve(f32::from(measured.height) as f64) as f32)) + size( + measured.width, + px(height.resolve(f32::from(measured.height) as f64) as f32), + ) }, ); (layout_id, ()) diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 041d80bc..583d794d 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -93,11 +93,11 @@ pub(super) fn build_element( let built = match element.element_type.as_str() { "div" => { ctx.custom_registry.destroy(id); - build_div(element, style, resolved, motion, ctx, window, cx) + build_div(element, style, resolved.clone(), motion, ctx, window, cx) } "text" => { ctx.custom_registry.destroy(id); - build_text(element, style, resolved, motion, ctx, window, cx) + build_text(element, style, resolved.clone(), motion, ctx, window, cx) } "virtual-list" => { ctx.custom_registry.destroy(id); @@ -145,26 +145,7 @@ pub(super) fn build_element( } }; - // A `height` animating toward `auto` needs a number that only layout knows, - // so the element that owns it measures its content and wraps this one. - let built = match motion.and_then(|frame| frame.height) { - Some(tween) => { - // A declared pixel width goes on the wrapper so taffy resolves the - // box straight to it. Any other width reaches the measurement - // through taffy instead. - let width = motion - .and_then(|frame| frame.style.width) - .or_else(|| match style.and_then(|style| style.width.as_ref()) { - Some(crate::style::DimensionValue::Pixels(value)) => Some(*value), - _ => None, - }) - .map(|value| gpui::px(value as f32)); - gpui::IntoElement::into_any_element(super::auto_height::AutoHeight::new( - id, built, tween, width, - )) - } - None => built, - }; + let built = super::auto_height::wrap(id, built, motion, resolved.as_deref()); ctx.cascade = parent_cascade; built diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index 6cc28fac..6e9012bb 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1867,6 +1867,61 @@ describeNative("motion", () => { renderer.clockResume() }) + /// A box that opens from 0 to `auto` over one second, holding 100 pixels of + /// content, on a paused clock. + function openingToAuto() { + const { render, renderer } = createTestRoot() + const tree = (open: boolean) => ( + +
+ + ) + renderer.clockPause() + render(tree(true)) + const id = renderer.findByType("div")[0]!.id + return { + open: () => render(tree(true)), + close: () => render(tree(false)), + after: (ms: number) => renderer.clockFastForward(ms), + height: () => renderer.getElementBounds(id)?.[3] ?? -1, + done: () => renderer.clockResume(), + } + } + + it("collapses from the height auto reached", () => { + const box = openingToAuto() + box.after(2000) + expect(box.height()).toBeCloseTo(100, 0) + + // Going back to a length used to lose the height `auto` had, so the box + // snapped shut on the first frame of the collapse. + box.close() + expect(box.height()).toBeCloseTo(100, 0) + box.after(500) + expect(box.height()).toBeCloseTo(50, 0) + box.after(500) + expect(box.height()).toBeCloseTo(0, 0) + box.done() + }) + + it("reverses part way open without jumping", () => { + const box = openingToAuto() + box.after(500) + expect(box.height()).toBeCloseTo(50, 0) + + // Half way to `auto` is half the content, and the collapse starts there. + box.close() + expect(box.height()).toBeCloseTo(50, 0) + box.after(500) + expect(box.height()).toBeCloseTo(25, 0) + box.done() + }) + it("renders the normal element when an internal motion payload is invalid", () => { const { render, renderer } = createTestRoot() From d595afc53a7a0350746f0de5c0d85d76e45e738c Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 18:50:02 +0200 Subject: [PATCH 08/29] fix(react): accept key on a built-in tag --- .changeset/key-on-a-built-in-tag.md | 15 +++++++ packages/react/jsx-dev-runtime.d.ts | 43 ++----------------- packages/react/jsx-runtime.d.ts | 37 +++++++++------- .../react/src/__tests__/jsx-types.check.tsx | 28 ++++++++++++ packages/react/tsconfig.typecheck.json | 9 ++-- 5 files changed, 75 insertions(+), 57 deletions(-) create mode 100644 .changeset/key-on-a-built-in-tag.md create mode 100644 packages/react/src/__tests__/jsx-types.check.tsx diff --git a/.changeset/key-on-a-built-in-tag.md b/.changeset/key-on-a-built-in-tag.md new file mode 100644 index 00000000..49fe8c06 --- /dev/null +++ b/.changeset/key-on-a-built-in-tag.md @@ -0,0 +1,15 @@ +--- +"@gpuix/react": patch +--- + +Accept `key` on a built-in tag under `jsxImportSource: "@gpuix/react"`. + +`` failed to typecheck. TypeScript reads +`JSX.IntrinsicAttributes` for a component tag but not for a built-in one, so +`key` has to sit in the props of each tag. React does the same for every DOM +tag. The props of a tag stay closed, so a name that is not a prop is still an +error. + +`jsx-runtime.d.ts` also imported its types with no file extension, which +`moduleResolution: "nodenext"` cannot resolve. Under `skipLibCheck` that import +became `any` and every tag took any prop at all. The import now names the file. diff --git a/packages/react/jsx-dev-runtime.d.ts b/packages/react/jsx-dev-runtime.d.ts index 0cbcf838..e5066f73 100644 --- a/packages/react/jsx-dev-runtime.d.ts +++ b/packages/react/jsx-dev-runtime.d.ts @@ -1,41 +1,6 @@ -/// GPUIX JSX dev-runtime types — mirrors jsx-runtime.d.ts for development builds. +/// GPUIX JSX dev-runtime types. The JSX namespace comes from jsx-runtime.d.ts, +/// so the two transforms cannot describe different tags. -import type { - AnchoredProps, - CodeProps, - DiffProps, - ImgProps, - InputProps, - MarkdownProps, - Props, - SvgProps, - TextareaProps, - VirtualListProps, -} from "./dist/types/host" +export { jsxDEV, jsxDEV as jsx, jsxDEV as jsxs, Fragment } from "react/jsx-dev-runtime" -export { jsx, jsxs, Fragment } from "react/jsx-dev-runtime" - -export namespace JSX { - type ElementType = React.JSX.ElementType - type Element = React.JSX.Element - type ElementClass = React.JSX.ElementClass - type ElementAttributesProperty = React.JSX.ElementAttributesProperty - type ElementChildrenAttribute = React.JSX.ElementChildrenAttribute - type IntrinsicAttributes = React.JSX.IntrinsicAttributes - type IntrinsicClassAttributes = React.JSX.IntrinsicClassAttributes - - interface IntrinsicElements { - div: Props - text: Props - img: ImgProps - svg: SvgProps - canvas: Props - input: InputProps - textarea: TextareaProps - anchored: AnchoredProps - code: CodeProps - diff: DiffProps - markdown: MarkdownProps - "virtual-list": VirtualListProps - } -} +export type { JSX } from "./jsx-runtime.js" diff --git a/packages/react/jsx-runtime.d.ts b/packages/react/jsx-runtime.d.ts index bcfa4610..7cf2b2f2 100644 --- a/packages/react/jsx-runtime.d.ts +++ b/packages/react/jsx-runtime.d.ts @@ -1,5 +1,5 @@ -/// GPUIX JSX runtime types — maps intrinsic elements to GPUIX Props -/// instead of DOM types. Activated via "jsxImportSource": "@gpuix/react". +/// GPUIX JSX runtime types. Maps intrinsic elements to GPUIX Props instead of +/// DOM types. Turned on with "jsxImportSource": "@gpuix/react". import type { AnchoredProps, @@ -12,7 +12,7 @@ import type { SvgProps, TextareaProps, VirtualListProps, -} from "./dist/types/host" +} from "./dist/types/host.js" export { jsx, jsxs, Fragment } from "react/jsx-runtime" @@ -25,18 +25,25 @@ export namespace JSX { type IntrinsicAttributes = React.JSX.IntrinsicAttributes type IntrinsicClassAttributes = React.JSX.IntrinsicClassAttributes + /// The props one built-in tag takes. + /// + /// TypeScript reads `IntrinsicAttributes` for a component tag but not for a + /// built-in one, so `key` has to sit in the props of each tag. React does the + /// same for every DOM tag through `ClassAttributes`. + type Tag

= P & IntrinsicAttributes + interface IntrinsicElements { - div: Props - text: Props - img: ImgProps - svg: SvgProps - canvas: Props - input: InputProps - textarea: TextareaProps - anchored: AnchoredProps - code: CodeProps - diff: DiffProps - markdown: MarkdownProps - "virtual-list": VirtualListProps + div: Tag + text: Tag + img: Tag + svg: Tag + canvas: Tag + input: Tag + textarea: Tag + anchored: Tag + code: Tag + diff: Tag + markdown: Tag + "virtual-list": Tag } } diff --git a/packages/react/src/__tests__/jsx-types.check.tsx b/packages/react/src/__tests__/jsx-types.check.tsx new file mode 100644 index 00000000..cc1254f3 --- /dev/null +++ b/packages/react/src/__tests__/jsx-types.check.tsx @@ -0,0 +1,28 @@ +// Type-only checks for the JSX runtime. `tsc --noEmit` runs them, and nothing +// imports this at runtime. +// +// A `@ts-expect-error` that stops being an error fails the build, so these pin +// the rejections as firmly as the acceptances. + +// TypeScript reads `JSX.IntrinsicAttributes` for a component tag but not for a +// built-in one, so `key` has to sit in the props of each built-in tag. +const keyed = ( +

+ {[1, 2].map((row) => ( + {String(row)} + ))} +
+) + +const keyedList =
+ +// @ts-expect-error a tag that is not ours is still not a tag +const notATag = + +// @ts-expect-error the props of a tag stay closed +const notAProp =
+ +export type Checked = typeof keyed & + typeof keyedList & + typeof notATag & + typeof notAProp diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json index c5ee9e70..26d96b4a 100644 --- a/packages/react/tsconfig.typecheck.json +++ b/packages/react/tsconfig.typecheck.json @@ -1,11 +1,14 @@ { // `tsc` never sees `src/__tests__`: the build config excludes it, and the // test files there carry type errors that predate this config. `files` - // survives `exclude`, so this config adds back the one file whose whole - // purpose is to be typechecked. + // survives `exclude`, so this config adds back the files whose whole purpose + // is to be typechecked. "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true }, - "files": ["src/__tests__/style-types.check.ts"], + "files": [ + "src/__tests__/style-types.check.ts", + "src/__tests__/jsx-types.check.tsx" + ], "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/__tests__"] } From 9d09bca0d004d4e52485013e2e13d01423565986 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 18:50:02 +0200 Subject: [PATCH 09/29] feat(react): take root options in render and expose the renderer --- .changeset/render-takes-a-class-resolver.md | 17 +++++++++++++++++ packages/react/src/__tests__/render.test.tsx | 13 +++++++++++++ packages/react/src/reconciler/reconciler.ts | 3 +++ packages/react/src/reconciler/renderer.ts | 8 ++++---- 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 .changeset/render-takes-a-class-resolver.md diff --git a/.changeset/render-takes-a-class-resolver.md b/.changeset/render-takes-a-class-resolver.md new file mode 100644 index 00000000..85ca3f31 --- /dev/null +++ b/.changeset/render-takes-a-class-resolver.md @@ -0,0 +1,17 @@ +--- +"@gpuix/react": minor +--- + +Pass a class resolver to `render()`. + +`createRoot` took `resolveClassName`, but `render()` did not, so an application +that wanted `className` had to open the window and build the root by hand. +`render()` now takes the same root options: + +```ts +render(, { resolveClassName, title: "Demo" }) +``` + +A `Root` also carries the `renderer` it draws on, so an application that lets +`render()` open the window can still reach the handle afterwards. Inside the +tree, `useGpuixRequired()` gives the same renderer. diff --git a/packages/react/src/__tests__/render.test.tsx b/packages/react/src/__tests__/render.test.tsx index 5c8d0017..09b76994 100644 --- a/packages/react/src/__tests__/render.test.tsx +++ b/packages/react/src/__tests__/render.test.tsx @@ -130,6 +130,19 @@ describeNative("render()", () => { expect(renderer.getAllText()).toEqual(["after"]) }) + it("takes a class resolver", () => { + // Without this, a class channel meant building the root by hand, because + // render() had no way to pass one on. + render(named, { + renderer, + resolveClassName: (token) => (token === "brand" ? { color: "#ff0000" } : null), + }) + renderer.flush() + + const node = renderer.findByType("text")[0] + expect(node?.style).toMatchObject({ color: "#ff0000" }) + }) + it("remounts under bun --hot without creating a new root", async () => { const file = join(srcDir, "__tests__", "hot-app.tmp.tsx") writeFileSync(file, hotAppSource("hello")) diff --git a/packages/react/src/reconciler/reconciler.ts b/packages/react/src/reconciler/reconciler.ts index 7bd408a1..0c508940 100644 --- a/packages/react/src/reconciler/reconciler.ts +++ b/packages/react/src/reconciler/reconciler.ts @@ -35,6 +35,8 @@ export const flushSync = _r.flushSyncFromReconciler ?? _r.flushSync export interface Root { render: (node: ReactNode) => void unmount: () => void + /** The renderer this root draws on, which is what `render()` opened. */ + renderer: NativeRenderer } const idAllocators = new WeakMap() @@ -108,5 +110,6 @@ export function createRoot(renderer: NativeRenderer, options: RootOptions = {}): }, unmount: cleanup, + renderer, } } diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index 7c6e7c17..4849696b 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -2,7 +2,7 @@ import type { ReactNode } from "react" import { GpuixRenderer } from "@gpuix/native" import type { EventPayload, WindowOptions } from "@gpuix/native" import { createRoot, flushSync, type Root } from "./reconciler.js" -import type { DebugFrameOverlayMode, NativeRenderer } from "../types/host.js" +import type { DebugFrameOverlayMode, NativeRenderer, RootOptions } from "../types/host.js" import { handleGpuixEvent } from "./event-registry.js" import { InProcessBackend, @@ -129,7 +129,7 @@ function renderSlot(): RenderSlot { return created } -export interface RenderOptions extends WindowOptions { +export interface RenderOptions extends WindowOptions, RootOptions { onEvent?: (event: EventPayload) => void renderer?: NativeRenderer /** GPUI scene overlay. Does not go through React or layout. */ @@ -145,7 +145,7 @@ export function resetRender(): void { /** Mount the app. Under `bun --hot`, later calls remount on the same native window. */ export function render(node: ReactNode, options: RenderOptions = {}): Root { - const { onEvent, renderer: injected, debugFrameOverlay, ...windowOptions } = options + const { onEvent, renderer: injected, debugFrameOverlay, resolveClassName, ...windowOptions } = options const slot = renderSlot() const remount = slot.root != null if (!slot.renderer) { @@ -169,7 +169,7 @@ export function render(node: ReactNode, options: RenderOptions = {}): Root { console.log("[gpuix] remount: unmount previous tree") slot.root.unmount() } - const root = createRoot(host) + const root = createRoot(host, options) slot.root = root flushSync(() => { root.render(node) From 5b6708027ca793147a9a7b7869ca4c1ed1678420 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 18:50:02 +0200 Subject: [PATCH 10/29] feat(examples): add a demo of css values, classes and motion --- examples/demo.test.tsx | 256 +++++++++++++++++++++++++++++++++ examples/demo.tsx | 23 +++ examples/demo/app.tsx | 145 +++++++++++++++++++ examples/demo/class-names.tsx | 146 +++++++++++++++++++ examples/demo/classes.ts | 191 ++++++++++++++++++++++++ examples/demo/colors.tsx | 132 +++++++++++++++++ examples/demo/inheritance.tsx | 124 ++++++++++++++++ examples/demo/lengths.tsx | 174 ++++++++++++++++++++++ examples/demo/motion-panel.tsx | 254 ++++++++++++++++++++++++++++++++ examples/demo/perf.tsx | 176 +++++++++++++++++++++++ examples/demo/ui.tsx | 108 ++++++++++++++ examples/demo/variables.tsx | 160 +++++++++++++++++++++ examples/package.json | 3 +- examples/tsconfig.json | 6 +- 14 files changed, 1896 insertions(+), 2 deletions(-) create mode 100644 examples/demo.test.tsx create mode 100644 examples/demo.tsx create mode 100644 examples/demo/app.tsx create mode 100644 examples/demo/class-names.tsx create mode 100644 examples/demo/classes.ts create mode 100644 examples/demo/colors.tsx create mode 100644 examples/demo/inheritance.tsx create mode 100644 examples/demo/lengths.tsx create mode 100644 examples/demo/motion-panel.tsx create mode 100644 examples/demo/perf.tsx create mode 100644 examples/demo/ui.tsx create mode 100644 examples/demo/variables.tsx diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx new file mode 100644 index 00000000..93cfd63e --- /dev/null +++ b/examples/demo.test.tsx @@ -0,0 +1,256 @@ +/** + * The demo, driven through the native GPUI test renderer. + * + * Every panel mounts and paints on real Metal, and the cases that carry a + * number are asserted rather than looked at. Screenshots land in + * /tmp/gpuix-demo-*.png for the ones that only a person can judge. + */ + +import fs from "fs" +import React from "react" +import { describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "@gpuix/react" +import type { TestRoot } from "@gpuix/react" +import { App, BASE, PALETTES } from "./demo/app" +import { ClassNames } from "./demo/class-names" +import { Colors } from "./demo/colors" +import { Inheritance } from "./demo/inheritance" +import { Lengths } from "./demo/lengths" +import { motion } from "@gpuix/react" +import { Motion } from "./demo/motion-panel" +import { Variables } from "./demo/variables" +import { resolveClassName } from "./demo/classes" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +const shot = (name: string) => `/tmp/gpuix-demo-${name}.png` + +function root(): TestRoot { + return createTestRoot({ resolveClassName }) +} + +const PANELS = [ + ["colors", ], + ["lengths", ], + ["variables", ], + ["inheritance", ], + ["classes", ], + ["motion", ], +] as const + +describeNative("demo panels", () => { + for (const [name, panel] of PANELS) { + it(`${name} mounts and paints`, () => { + const test = root() + test.render( +
+ {panel} +
+ ) + test.renderer.captureScreenshot(shot(name)) + expect(fs.statSync(shot(name)).size).toBeGreaterThan(0) + expect(test.renderer.getPaintedText().length).toBeGreaterThan(0) + test.unmount() + }) + } +}) + +describeNative("a class and the style it stands for", () => { + it("paints the same pixels", () => { + const viaClass = root() + viaClass.render(
) + viaClass.renderer.captureScreenshot(shot("class")) + viaClass.unmount() + + const viaStyle = root() + viaStyle.render( +
+ ) + viaStyle.renderer.captureScreenshot(shot("style")) + viaStyle.unmount() + + expect(fs.readFileSync(shot("class")).equals(fs.readFileSync(shot("style")))).toBe(true) + }) + + it("lets the style prop beat the class in every state", () => { + const test = root() + test.render( +
+ ) + test.renderer.captureScreenshot(shot("inline-wins")) + test.unmount() + + const expected = root() + expected.render(
) + expected.renderer.captureScreenshot(shot("inline-wins-expected")) + expected.unmount() + + expect( + fs.readFileSync(shot("inline-wins")).equals(fs.readFileSync(shot("inline-wins-expected"))) + ).toBe(true) + }) +}) + +describeNative("height: auto", () => { + const WORDS = + "The measurement runs at the width the element really gets, so the same " + + "words wrap into a different number of lines in a different column." + + const column = (width: number) => ( +
+ + {WORDS} + +
+ ) + + /// The same words in two widths. The narrow column wraps into more lines, so + /// it has to settle taller. Neither number is written anywhere, and measuring + /// at max-content instead would give both of them one line. + it("settles at the height the content takes at each width", () => { + const settled = [220, 440].map((width) => { + const test = root() + test.renderer.clockPause() + test.render(column(width)) + const id = test.renderer.findByType("div")[1]!.id + test.renderer.clockFastForward(2000) + const height = test.renderer.getElementBounds(id)?.[3] ?? -1 + test.renderer.clockResume() + test.unmount() + return height + }) + expect(settled[0]).toBeGreaterThan(0) + expect(settled[1]).toBeGreaterThan(0) + expect(settled[0]).toBeGreaterThan(settled[1]!) + }) + + it("animates open and reaches the measured height", () => { + const test = root() + test.renderer.clockPause() + test.render(column(300)) + const id = test.renderer.findByType("div")[1]!.id + const at = () => test.renderer.getElementBounds(id)?.[3] ?? -1 + const start = at() + test.renderer.clockFastForward(250) + const middle = at() + test.renderer.clockFastForward(500) + const end = at() + test.renderer.clockResume() + test.unmount() + expect(start).toBe(0) + expect(middle).toBeGreaterThan(start) + expect(end).toBeGreaterThan(middle) + }) + + it("collapses from the height it reached and turns back without a jump", () => { + const test = root() + test.renderer.clockPause() + const tree = (open: boolean) => ( +
+ +
+ +
+ ) + test.render(tree(true)) + const id = test.renderer.findByType("div")[1]!.id + const at = () => test.renderer.getElementBounds(id)?.[3] ?? -1 + test.renderer.clockFastForward(1000) + expect(at()).toBe(100) + test.render(tree(false)) + expect(at()).toBe(100) + test.renderer.clockFastForward(500) + expect(at()).toBe(50) + test.renderer.clockFastForward(500) + expect(at()).toBe(0) + + // Turn back part way through, which starts from a frame that is part + // pixels and part content. + test.render(tree(true)) + test.renderer.clockFastForward(500) + expect(at()).toBe(50) + test.render(tree(false)) + expect(at()).toBe(50) + test.renderer.clockFastForward(500) + expect(at()).toBe(25) + test.renderer.clockResume() + test.unmount() + }) +}) + +describeNative("the whole application", () => { + /// Walk the sidebar and paint each section, so the whole application is + /// covered rather than the one it opens on. The test renderer has the frame + /// overlay, so the performance panel is in the walk too. + it("paints every section the sidebar reaches", () => { + const test = root() + test.render() + expect(test.renderer.getPaintedText()).toContain("GPUIX") + + for (const title of ["Lengths", "Variables", "Inheritance", "className", "Motion", "Performance", "Colours"]) { + const item = test.renderer.findByText(title) + expect(item, `no sidebar item named ${title}`).toBeDefined() + const bounds = test.renderer.getElementBounds(item!.id) + expect(bounds).not.toBeNull() + test.renderer.nativeSimulateClick(bounds![0]! + 4, bounds![1]! + 4) + test.renderer.flush() + test.renderer.captureScreenshot(shot(`app-${title.toLowerCase()}`)) + expect(test.renderer.getPaintedText().length).toBeGreaterThan(4) + } + test.unmount() + }) + + /// A frame that changes nothing must resolve nothing. GPUI rebuilds its + /// element tree every frame, so this is what stops the rebuild from + /// repeating the style work. + it("resolves nothing on a frame that changed nothing", () => { + const test = root() + test.render() + test.renderer.resetStyleResolutions() + for (let frame = 0; frame < 5; frame += 1) test.renderer.flush() + expect(test.renderer.styleResolutions()).toBe(0) + test.unmount() + }) + + /// The palette is one declaration at the root, and every class reads it + /// through `var()`. Changing it has to reach the whole tree. + it("repaints the tree when the palette changes", () => { + const test = root() + test.render() + test.renderer.captureScreenshot(shot("palette-before")) + const paper = test.renderer.findByText("paper") + expect(paper).toBeDefined() + const bounds = test.renderer.getElementBounds(paper!.id) + expect(bounds).not.toBeNull() + test.renderer.nativeSimulateClick(bounds![0] + 4, bounds![1] + 4) + test.renderer.flush() + test.renderer.captureScreenshot(shot("palette-after")) + const before = fs.readFileSync(shot("palette-before")) + const after = fs.readFileSync(shot("palette-after")) + expect(before.equals(after)).toBe(false) + test.unmount() + }) +}) diff --git a/examples/demo.tsx b/examples/demo.tsx new file mode 100644 index 00000000..898acb2f --- /dev/null +++ b/examples/demo.tsx @@ -0,0 +1,23 @@ +/** + * Every feature on the css-values-and-classname branch, in one window. + * + * Colour values, lengths and arithmetic, custom properties, inheritance, the + * `className` channel and the `height: auto` animation each get a panel. Pick + * one in the sidebar. + * + * Run with: cd examples && bun run demo + */ + +import React from "react" +import { render } from "@gpuix/react" +import { App } from "./demo/app.js" +import { countedResolveClassName } from "./demo/classes.js" + +render(, { + title: "GPUIX", + width: 1180, + height: 820, + minWidth: 720, + minHeight: 520, + resolveClassName: countedResolveClassName, +}) diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx new file mode 100644 index 00000000..340577d8 --- /dev/null +++ b/examples/demo/app.tsx @@ -0,0 +1,145 @@ +/// The shell around the panels. +/// +/// The whole palette is custom properties on one element. Every class token +/// points at one of them, so switching the palette changes one declaration at +/// the root and the whole tree follows it on the next frame. No token is +/// resolved again, because the class channel never held a colour. + +import React, { useState } from "react" +import { useGpuixRequired } from "@gpuix/react" +import type { StyleDesc } from "@gpuix/react" +import { ClassNames } from "./class-names.js" +import { Colors } from "./colors.js" +import { Inheritance } from "./inheritance.js" +import { Lengths } from "./lengths.js" +import { Motion } from "./motion-panel.js" +import { frameOverlay, Perf } from "./perf.js" +import { Variables } from "./variables.js" + +/// The palette every panel reads. Exported so a test can mount one panel +/// on its own and still get the colours. +export const PALETTES: Record = { + midnight: { + "--color-bg": "#0b0b12", + "--color-panel": "#14141d", + "--color-raised": "#1c1c28", + "--color-track": "#23232f", + "--color-line": "#2b2b3a", + "--color-fg": "#e8e8f2", + "--color-muted": "#9a9ab4", + "--color-faint": "#6b6b85", + "--color-brand": "#7c6cff", + }, + forest: { + "--color-bg": "#07120d", + "--color-panel": "#0e1c15", + "--color-raised": "#16281f", + "--color-track": "#1d3227", + "--color-line": "#24402f", + "--color-fg": "#e4f2e9", + "--color-muted": "#8fb5a0", + "--color-faint": "#5f8570", + "--color-brand": "#22c55e", + }, + paper: { + "--color-bg": "#f4f4f7", + "--color-panel": "#ffffff", + "--color-raised": "#ececf2", + "--color-track": "#e2e2ea", + "--color-line": "#d5d5e0", + "--color-fg": "#15151f", + "--color-muted": "#54546a", + "--color-faint": "#8a8aa0", + "--color-brand": "#5b4bd6", + }, +} + +type PaletteName = keyof typeof PALETTES + +/// Declarations every palette shares. +export const BASE: StyleDesc = { + "--spacing": "4px", + "--font-mono": "Menlo", + "--color-brand-soft": "color-mix(in oklch, var(--color-brand) 22%, var(--color-panel))", +} + +const SECTIONS = [ + { id: "colors", title: "Colours", render: () => }, + { id: "lengths", title: "Lengths", render: () => }, + { id: "variables", title: "Variables", render: () => }, + { id: "inheritance", title: "Inheritance", render: () => }, + { id: "classes", title: "className", render: () => }, + { id: "motion", title: "Motion", render: () => }, +] as const + +type SectionId = (typeof SECTIONS)[number]["id"] | "perf" + +function SidebarItem({ title, active, onClick }: { + title: string + active: boolean + onClick: () => void +}) { + return ( +
+ {title} +
+ ) +} + +export function App() { + const [section, setSection] = useState("colors") + const [palette, setPalette] = useState("midnight") + const current = SECTIONS.find((entry) => entry.id === section) + // The performance panel reads the frame overlay, which a renderer may not + // have. It is in the sidebar only when this one does. + const overlay = frameOverlay(useGpuixRequired()) + + return ( +
+
+
+ GPUIX + CSS values, classes, motion +
+ {SECTIONS.map((entry) => ( + setSection(entry.id)} + /> + ))} + {overlay ? ( + setSection("perf")} /> + ) : null} + +
+ Palette + {(Object.keys(PALETTES) as PaletteName[]).map((name) => ( + setPalette(name)} + /> + ))} +
+ +
+ {section === "perf" && overlay ? : current?.render()} +
+
+ ) +} diff --git a/examples/demo/class-names.tsx b/examples/demo/class-names.tsx new file mode 100644 index 00000000..87fb470b --- /dev/null +++ b/examples/demo/class-names.tsx @@ -0,0 +1,146 @@ +/// The `className` channel. +/// +/// GPUIX ships no resolver. A root takes one through +/// `createRoot(renderer, { resolveClassName })`. This demo passes the small one +/// in `classes.ts`, which is shaped like the `@gpuix/tailwind` package this +/// repository plans to publish. +/// +/// The resolver reads one token, never a whole string. That is what makes the +/// cache work. `clsx("p-4", a && "bg-brand", b && "text-lg")` writes up to +/// eight strings out of three tokens, and five toggles write thirty-two. A +/// bounded cache over whole strings sits in front of the token cache, because +/// the same string usually repeats between two frames. +/// +/// CSS Style Attributes gives the `style` attribute "a specificity higher than +/// any selector", so a declaration in `style` beats one from a class in every +/// state. + +import React, { useState } from "react" +import { Button, Grid, Panel, Row, Sample } from "./ui.js" +import { resolverCalls } from "./classes.js" + +const CARD = "col gap-2 p-4 rounded bg-raised border w-full" + +function Toggles() { + const [padded, setPadded] = useState(true) + const [loud, setLoud] = useState(false) + const [big, setBig] = useState(false) + const [round, setRound] = useState(true) + const [asked, setAsked] = useState(resolverCalls()) + + const className = [ + "row items-center justify-center h-[80px] border", + padded ? "p-6" : "p-1", + loud ? "bg-brand" : "bg-raised", + big ? "text-2xl" : "text-sm", + round ? "rounded-xl" : "rounded-none", + ].join(" ") + + return ( + + +
+ ) +} diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx new file mode 100644 index 00000000..702e73aa --- /dev/null +++ b/examples/demo/gradients.tsx @@ -0,0 +1,73 @@ +/// Gradient fills. +/// +/// `linear-gradient()` reaches lightningcss as written. The engine fixes the +/// stops up the way CSS Images 3 says and the quad shader paints them, so a +/// gradient costs the same as a flat colour: one quad, no texture. Stop +/// positions are percentages. Radial and conic gradients are not painted yet. + +import React from "react" +import { Grid, Panel, Sample, Swatch } from "./ui.js" + +const DIRECTIONS: Array<[string, string]> = [ + ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], + ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], + ["linear-gradient(45deg, #ff5c8a, #5cc8ff)", "an angle, clockwise from top"], + ["linear-gradient(0.75turn, #ff5c8a, #5cc8ff)", "the same in turns"], + ["linear-gradient(to top right, #ff5c8a, #5cc8ff)", "a corner: the 50% line joins the other two corners"], + ["linear-gradient(to bottom left, #ff5c8a, #5cc8ff)", ""], +] + +const STOPS: Array<[string, string]> = [ + ["linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet)", "seven stops, spread evenly"], + ["linear-gradient(to right, #ff5c8a 30%, #5cc8ff 70%)", "flat colour outside the stops"], + ["linear-gradient(to right, #ff5c8a 50%, #5cc8ff 50%)", "two stops in one place make a hard edge"], + ["linear-gradient(to right, #ff5c8a, 20%, #5cc8ff)", "a hint moves the half-way point"], + ["linear-gradient(to right, #ff5c8a 60%, #5cc8ff 20%)", "a stop never goes backwards"], + ["linear-gradient(to right, var(--color-brand), white)", "a stop over a variable"], +] + +const ALPHA: Array<[string, string]> = [ + ["linear-gradient(to right, rgb(255 92 138 / 0), #ff5c8a)", "fades in from clear"], + ["linear-gradient(to bottom, transparent, black)", "a scrim"], + ["linear-gradient(to right, currentColor, transparent)", "currentColor as a stop"], +] + +function List({ title, note, entries }: { + title: string + note: string + entries: Array<[string, string]> +}) { + return ( + + + {entries.map(([value, hint]) => ( + + + + ))} + + + ) +} + +export function Gradients() { + return ( +
+ + + +
+ ) +} diff --git a/packages/native/css/src/background.rs b/packages/native/css/src/background.rs new file mode 100644 index 00000000..611099a7 --- /dev/null +++ b/packages/native/css/src/background.rs @@ -0,0 +1,331 @@ +//! Background fills for GPUIX. +//! +//! A `background` or `background-image` value is a colour or one +//! `linear-gradient()`. lightningcss reads the gradient syntax. This module +//! then fixes the colour stops up the way CSS Images 3 section 3.4.3 says, so +//! that every stop leaves here with a position from 0 to 1 that never +//! decreases. The renderer paints that list as it is. +//! +//! Stop positions are percentages only. A length needs the size of the box, +//! which only paint knows, so a length here is `Unsupported`. Radial and conic +//! gradients, repeating gradients and `url()` images are `Unsupported` too. + +use lightningcss::traits::Parse; +use lightningcss::values::gradient::{Gradient, GradientItem, LineDirection}; +use lightningcss::values::image::Image; +use lightningcss::values::percentage::DimensionPercentage; +use lightningcss::values::position::{HorizontalPositionKeyword, VerticalPositionKeyword}; + +use crate::color::{self, ColorContext, Rgba}; +use crate::CssError; + +/// Where the line of a linear gradient points. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Line { + /// Degrees clockwise from `to top`. + Angle(f32), + ToTopLeft, + ToTopRight, + ToBottomRight, + ToBottomLeft, +} + +/// One colour stop after fix-up. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Stop { + pub color: Rgba, + /// Where on the gradient line, from 0 to 1. + pub position: f32, + /// Where between this stop and the next the mix is half way, as a + /// fraction of that span. 0 means no hint. + pub hint: f32, +} + +/// A `linear-gradient()` ready to paint. +#[derive(Debug, Clone, PartialEq)] +pub struct LinearGradient { + pub line: Line, + /// At least two stops, positions from 0 to 1 and never decreasing. + pub stops: Vec, +} + +/// What a background value paints. +#[derive(Debug, Clone, PartialEq)] +pub enum Fill { + Color(Rgba), + LinearGradient(LinearGradient), +} + +impl Fill { + /// The colour the fill paints first, for callers that want one colour. + pub fn first_color(&self) -> Rgba { + match self { + Fill::Color(color) => *color, + Fill::LinearGradient(gradient) => gradient.stops[0].color, + } + } +} + +/// A fill, and what it needed from the context to finish. +#[derive(Debug, Clone, PartialEq)] +pub struct Reading { + pub fill: Fill, + /// Whether any colour in the value read `currentColor`. + pub read_current_color: bool, +} + +/// Read one background value. `none` reads as `Ok(None)`. +pub fn read(value: &str, context: &ColorContext) -> Result, CssError> { + let Ok(image) = Image::parse_string(value) else { + let reading = color::read(value, context)?; + return Ok(Some(Reading { + fill: Fill::Color(reading.color), + read_current_color: reading.read_current_color, + })); + }; + match image { + Image::None => Ok(None), + Image::Gradient(gradient) => match *gradient { + Gradient::Linear(linear) => { + let line = line_of(&linear.direction); + let (stops, read_current_color) = fix_up(&linear.items, context, value)?; + Ok(Some(Reading { + fill: Fill::LinearGradient(LinearGradient { line, stops }), + read_current_color, + })) + } + other => Err(unsupported( + match other { + Gradient::RepeatingLinear(_) => "repeating-linear-gradient()", + Gradient::Radial(_) => "radial-gradient()", + Gradient::RepeatingRadial(_) => "repeating-radial-gradient()", + Gradient::Conic(_) => "conic-gradient()", + Gradient::RepeatingConic(_) => "repeating-conic-gradient()", + _ => "a vendor gradient", + }, + value, + )), + }, + Image::Url(_) => Err(unsupported("url() images", value)), + Image::ImageSet(_) => Err(unsupported("image-set()", value)), + } +} + +fn unsupported(feature: &str, value: &str) -> CssError { + CssError::Unsupported { + feature: feature.to_string(), + value: value.to_string(), + } +} + +fn line_of(direction: &LineDirection) -> Line { + use HorizontalPositionKeyword::{Left, Right}; + use VerticalPositionKeyword::{Bottom, Top}; + match direction { + LineDirection::Angle(angle) => Line::Angle(angle.to_degrees()), + LineDirection::Horizontal(Left) => Line::Angle(270.0), + LineDirection::Horizontal(Right) => Line::Angle(90.0), + LineDirection::Vertical(Top) => Line::Angle(0.0), + LineDirection::Vertical(Bottom) => Line::Angle(180.0), + LineDirection::Corner { horizontal: Left, vertical: Top } => Line::ToTopLeft, + LineDirection::Corner { horizontal: Right, vertical: Top } => Line::ToTopRight, + LineDirection::Corner { horizontal: Right, vertical: Bottom } => Line::ToBottomRight, + LineDirection::Corner { horizontal: Left, vertical: Bottom } => Line::ToBottomLeft, + } +} + +type Item = GradientItem; + +/// A stop or hint while fix-up runs. A hint has no colour. +struct Pending { + color: Option, + position: Option, +} + +/// Turn the parsed items into stops with positions, the way CSS Images 3 +/// section 3.4.3 says. +/// +/// 1. A first stop with no position gets 0, a last one gets 1. +/// 2. A position smaller than one before it becomes that earlier position. +/// 3. A run of stops with no position spreads evenly between its neighbours. +/// +/// A hint then folds into the stop before it as a fraction of the span to +/// the stop after it. +fn fix_up( + items: &[Item], + context: &ColorContext, + value: &str, +) -> Result<(Vec, bool), CssError> { + let mut read_current_color = false; + let mut pending = Vec::with_capacity(items.len()); + for item in items { + match item { + GradientItem::ColorStop(stop) => { + read_current_color |= color::reads_current_color(&stop.color); + pending.push(Pending { + color: Some(color::resolve(&stop.color, context)?), + position: stop + .position + .as_ref() + .map(|p| fraction(p, value)) + .transpose()?, + }); + } + GradientItem::Hint(position) => pending.push(Pending { + color: None, + position: Some(fraction(position, value)?), + }), + } + } + if pending.len() < 2 { + return Err(CssError::BadValue { + property: "background".to_string(), + value: value.to_string(), + }); + } + + let last = pending.len() - 1; + pending[0].position.get_or_insert(0.0); + pending[last].position.get_or_insert(1.0); + let mut floor = 0.0f32; + for item in &mut pending { + if let Some(position) = item.position.as_mut() { + *position = position.max(floor); + floor = *position; + } + } + let mut index = 0; + while index < pending.len() { + if pending[index].position.is_some() { + index += 1; + continue; + } + let start = index; + while pending[index].position.is_none() { + index += 1; + } + let from = pending[start - 1].position.unwrap(); + let to = pending[index].position.unwrap(); + let steps = (index - start + 1) as f32; + for (offset, item) in pending[start..index].iter_mut().enumerate() { + item.position = Some(from + (to - from) * (offset as f32 + 1.0) / steps); + } + } + + let mut stops: Vec = Vec::with_capacity(pending.len()); + for (i, item) in pending.iter().enumerate() { + let position = item.position.unwrap(); + match item.color { + Some(color) => stops.push(Stop { color, position, hint: 0.0 }), + None => { + let Some(previous) = stops.last_mut() else { continue }; + let next = pending[i + 1..] + .iter() + .find(|p| p.color.is_some()) + .and_then(|p| p.position) + .unwrap_or(position); + let span = next - previous.position; + if span > 0.0 { + previous.hint = (position - previous.position) / span; + } + } + } + } + Ok((stops, read_current_color)) +} + +fn fraction(position: &DimensionPercentage, value: &str) -> Result { + match position { + DimensionPercentage::Percentage(percentage) => Ok(percentage.0), + _ => Err(unsupported("gradient stop lengths", value)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gradient(value: &str) -> LinearGradient { + match read(value, &ColorContext::default()) { + Ok(Some(Reading { fill: Fill::LinearGradient(gradient), .. })) => gradient, + other => panic!("`{value}` did not read as a gradient: {other:?}"), + } + } + + fn positions(value: &str) -> Vec { + gradient(value) + .stops + .iter() + .map(|s| (s.position * 1000.0).round() / 1000.0) + .collect() + } + + #[test] + fn reads_a_plain_colour_as_a_fill() { + let reading = read("red", &ColorContext::default()).unwrap().unwrap(); + assert_eq!(reading.fill, Fill::Color(Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 })); + } + + #[test] + fn none_is_no_fill() { + assert_eq!(read("none", &ColorContext::default()).unwrap(), None); + } + + #[test] + fn spreads_stops_with_no_position() { + assert_eq!(positions("linear-gradient(red, lime, blue)"), vec![0.0, 0.5, 1.0]); + assert_eq!( + positions("linear-gradient(red 10%, lime, blue, black 70%)"), + vec![0.1, 0.3, 0.5, 0.7] + ); + } + + #[test] + fn a_position_never_decreases() { + assert_eq!(positions("linear-gradient(red 60%, lime 20%, blue)"), vec![0.6, 0.6, 1.0]); + } + + #[test] + fn folds_a_hint_into_the_stop_before_it() { + let gradient = gradient("linear-gradient(red, 20%, blue)"); + assert_eq!(gradient.stops.len(), 2); + assert!((gradient.stops[0].hint - 0.2).abs() < 1e-6); + assert_eq!(gradient.stops[1].hint, 0.0); + } + + #[test] + fn reads_every_direction() { + assert_eq!(gradient("linear-gradient(red, blue)").line, Line::Angle(180.0)); + assert_eq!(gradient("linear-gradient(to right, red, blue)").line, Line::Angle(90.0)); + assert_eq!(gradient("linear-gradient(0.25turn, red, blue)").line, Line::Angle(90.0)); + assert_eq!(gradient("linear-gradient(to top left, red, blue)").line, Line::ToTopLeft); + assert_eq!( + gradient("linear-gradient(to bottom right, red, blue)").line, + Line::ToBottomRight + ); + } + + #[test] + fn current_color_inside_a_stop_is_reported() { + let context = ColorContext { current_color: Rgba::TRANSPARENT, dark: false }; + let reading = read("linear-gradient(currentColor, blue)", &context).unwrap().unwrap(); + assert!(reading.read_current_color); + assert_eq!(reading.fill.first_color(), Rgba::TRANSPARENT); + } + + #[test] + fn rejects_what_it_cannot_paint() { + let context = ColorContext::default(); + assert!(matches!( + read("radial-gradient(red, blue)", &context), + Err(CssError::Unsupported { .. }) + )); + assert!(matches!( + read("linear-gradient(red 10px, blue)", &context), + Err(CssError::Unsupported { .. }) + )); + assert!(matches!(read("url(x.png)", &context), Err(CssError::Unsupported { .. }))); + assert!(matches!(read("linear-gradient(red)", &context), Err(CssError::BadValue { .. }))); + assert!(matches!(read("nonsense", &context), Err(CssError::BadValue { .. }))); + } +} diff --git a/packages/native/css/src/lib.rs b/packages/native/css/src/lib.rs index 6486c8ce..a9b596cc 100644 --- a/packages/native/css/src/lib.rs +++ b/packages/native/css/src/lib.rs @@ -8,6 +8,7 @@ //! reads live on the element and its ancestors. Such a value comes back as //! `Parsed::Pending`, and the cascade finishes it later with `substitute`. +pub mod background; pub mod color; pub mod length; diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 30438153..e2bd9e4d 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -224,6 +224,11 @@ export declare class TestGpuixRenderer { getSelectedText(): string | null /** Drop the current selection. */ clearSelection(): void + /** + * The text the last clipboard write put there, or null when there is + * none or it was not text. + */ + readClipboardText(): string | null /** * Syntax-cache counters as `[hits, misses, documents]`. * @@ -279,6 +284,13 @@ export declare class TestGpuixRenderer { * macOS only — requires Metal GPU rendering via VisualTestAppContext. */ captureScreenshot(path: string): void + /** + * The colour of one painted pixel as `[r, g, b, a]`, each 0 to 255. + * + * `x` and `y` are logical pixels from the top left of the window, the + * same space every other test coordinate is in. + */ + pixelAt(x: number, y: number): Array /** * Return and clear all collected events since the last drain. * Events are collected synchronously — no event loop queuing. diff --git a/packages/native/src/color.rs b/packages/native/src/color.rs index e13f44c2..0ff1d0f5 100644 --- a/packages/native/src/color.rs +++ b/packages/native/src/color.rs @@ -29,6 +29,36 @@ pub(crate) fn to_hsla(color: Rgba) -> gpui::Hsla { to_gpui(color).into() } +/// Turn a fill the engine read into what GPUI paints. +/// +/// A gradient carries its stops already fixed up, so this only copies them +/// across. +pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Background { + use gpuix_css::background::{Fill, Line}; + match fill { + Fill::Color(color) => to_hsla(*color).into(), + Fill::LinearGradient(gradient) => { + let line = match gradient.line { + Line::Angle(degrees) => gpui::GradientLine::Angle(degrees), + Line::ToTopLeft => gpui::GradientLine::ToTopLeft, + Line::ToTopRight => gpui::GradientLine::ToTopRight, + Line::ToBottomRight => gpui::GradientLine::ToBottomRight, + Line::ToBottomLeft => gpui::GradientLine::ToBottomLeft, + }; + let stops: Vec = gradient + .stops + .iter() + .map(|stop| gpui::LinearColorStop { + color: to_hsla(stop.color), + percentage: stop.position, + hint: stop.hint, + }) + .collect(); + gpui::linear_gradient_stops(line, &stops) + } + } +} + /// Read a colour that depends on the element or the window. /// /// `currentColor` and `light-dark()` both need context, so this is the entry diff --git a/packages/native/src/inheritance.rs b/packages/native/src/inheritance.rs index 295af4eb..d3d5fbcf 100644 --- a/packages/native/src/inheritance.rs +++ b/packages/native/src/inheritance.rs @@ -30,6 +30,10 @@ use crate::style::StyleDesc; struct Values { /// False once an ancestor sets `userSelect: "none"`. selectable: bool, + /// True once an ancestor sets a `cursor` other than `auto`. CSS inherits + /// the cursor, and GPUI keeps a parent's cursor over a child that sets + /// none, so this only has to tell selectable text not to show its I-beam. + cursor_declared: bool, /// Selection wash colour for this subtree. selection_wash: Rgba, /// The computed `color` here, which is what `currentColor` names. @@ -110,6 +114,7 @@ impl Inherited { let wash = Rgba { a: 0.35, ..accent }; Self(Arc::new(Values { selectable: true, + cursor_declared: false, selection_wash: wash, color: Rgba::BLACK, dark, @@ -135,6 +140,11 @@ impl Inherited { Some("text") | Some("auto") => next.selectable = true, _ => {} } + match style.cursor.as_deref() { + Some("auto") => next.cursor_declared = false, + Some(_) => next.cursor_declared = true, + None => {} + } if let Some(text) = style.selection_color.as_deref() { let context = gpuix_css::color::ColorContext { current_color: next.color, @@ -184,6 +194,11 @@ impl Inherited { self.0.selectable } + /// Whether an ancestor set a `cursor` that this subtree inherits. + pub fn cursor_declared(&self) -> bool { + self.0.cursor_declared + } + /// The selection wash colour for this subtree. pub fn selection_wash(&self) -> Rgba { self.0.selection_wash @@ -231,6 +246,17 @@ mod tests { style } + #[test] + fn a_declared_cursor_reaches_the_subtree_until_auto_resets_it() { + let root = Inherited::root(Rgba::BLACK, false, 16.0); + assert!(!root.cursor_declared()); + let link = root.descend(Some(&styled(|s| s.cursor = Some("pointer".into())))); + assert!(link.cursor_declared()); + assert!(link.descend(None).cursor_declared()); + let reset = link.descend(Some(&styled(|s| s.cursor = Some("auto".into())))); + assert!(!reset.cursor_declared()); + } + #[test] fn an_element_with_no_style_keeps_its_parent_context() { let parent = root(); diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index eda8de8c..c1c3fbb2 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -1,5 +1,7 @@ //! Native motion tracks resolved during GPUI rendering, outside React. +use std::cell::Cell; +use std::rc::Rc; use std::time::{Duration, Instant}; use serde::Deserialize; @@ -17,6 +19,79 @@ pub(crate) struct MotionStyle { pub bottom: Option, pub left: Option, pub border_radius: Option, + pub corner_shape: Option, +} + +/// A `cornerShape` on the move: the curvature `K` of `superellipse(K)`. +/// +/// Reads a number or any `` text. Interpolates the way +/// CSS Borders 4 says, in the "half corner" space where `bevel` sits at 0.5, +/// so a `round` to `square` transition sweeps the visible shape at an even +/// pace instead of jumping at the infinite end. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[serde(try_from = "MotionShapeWire")] +pub(crate) struct MotionShape(pub f64); + +#[derive(Deserialize)] +#[serde(untagged)] +enum MotionShapeWire { + Number(f64), + Text(String), +} + +impl TryFrom for MotionShape { + type Error = String; + + fn try_from(wire: MotionShapeWire) -> Result { + match wire { + MotionShapeWire::Number(k) if !k.is_nan() => Ok(Self(k)), + MotionShapeWire::Number(_) => Err("motion cornerShape must be a number".into()), + MotionShapeWire::Text(text) => crate::style::corners::shape(&text) + .map(|k| Self(k as f64)) + .ok_or_else(|| format!("motion cornerShape {text:?} is not a corner shape")), + } + } +} + +impl MotionShape { + /// Where the curve crosses the corner's diagonal, 0 at `notch`, 0.5 at + /// `bevel`, 1 at `square`. + fn half_corner(self) -> f64 { + let k = self.0; + if k.is_infinite() { + return if k > 0.0 { 1.0 } else { 0.0 }; + } + let convex = 0.5f64.powf(1.0 / 2f64.powf(k.abs())); + if k >= 0.0 { + convex + } else { + 1.0 - convex + } + } + + fn from_half_corner(h: f64) -> Self { + if h >= 1.0 { + return Self(f64::INFINITY); + } + if h <= 0.0 { + return Self(f64::NEG_INFINITY); + } + let (convex, sign) = if h >= 0.5 { (h, 1.0) } else { (1.0 - h, -1.0) }; + Self(sign * (0.5f64.ln() / convex.ln()).log2()) + } + + fn mix(self, to: Self, progress: f64) -> Self { + Self::from_half_corner(mix(self.half_corner(), to.half_corner(), progress)) + } + + /// The value as `StyleDesc` text. + fn css(self) -> String { + match self.0 { + k if k == f64::INFINITY => "square".to_string(), + k if k == f64::NEG_INFINITY => "notch".to_string(), + k => format!("superellipse({k})"), + } + } } /// A `height`, as a number of pixels plus a share of the height the content @@ -127,6 +202,9 @@ impl MotionStyle { bottom: value(self.bottom, target.bottom, progress), left: value(self.left, target.left, progress), border_radius: value(self.border_radius, target.border_radius, progress), + corner_shape: target + .corner_shape + .map(|to| self.corner_shape.unwrap_or(to).mix(to, progress)), } } @@ -157,6 +235,9 @@ impl MotionStyle { if let Some(value) = self.border_radius { style.border_radius = Some(value.into()); } + if let Some(shape) = self.corner_shape { + style.corner_shape = Some(shape.css()); + } } } @@ -212,10 +293,33 @@ struct MotionDescription { transition: MotionTransition, } -#[derive(Clone, Copy, Debug)] +/// The height the content took, as the element that measures it reports it. +/// +/// `AutoHeight` writes here during layout and the state reads it at the start +/// of the next frame. It is shared because the measure closure outlives the +/// frame that built it. +#[derive(Clone, Debug, Default)] +pub(crate) struct ContentHeight(Rc>>); + +impl ContentHeight { + pub(crate) fn report(&self, height: f64) { + self.0.set(Some(height)); + } + + fn get(&self) -> Option { + self.0.get() + } +} + +#[derive(Clone, Debug)] pub(crate) struct MotionFrame { pub style: MotionStyle, pub active: bool, + /// The content height this frame's `height` resolves against while the + /// animation runs. `None` before anything was measured. + pub content: Option, + /// Where the element that measures the content reports what it found. + pub measured: ContentHeight, } impl MotionFrame { @@ -233,6 +337,9 @@ pub(crate) struct MotionState { transition: MotionTransition, started: Instant, valid: bool, + /// The content height the last frame resolved against. + content: Option, + measured: ContentHeight, } impl MotionState { @@ -251,6 +358,8 @@ impl MotionState { transition: description.transition, started: now, valid: true, + content: None, + measured: ContentHeight::default(), }) } @@ -262,6 +371,8 @@ impl MotionState { transition: MotionTransition::default(), started: now, valid: false, + content: None, + measured: ContentHeight::default(), } } @@ -269,7 +380,10 @@ impl MotionState { self.valid } + /// Bring the state up to date with `source` and with what the content + /// measured, before this frame is read. pub(crate) fn sync(&mut self, source: &serde_json::Value, now: Instant) -> Result<(), String> { + self.follow_content(now); if self.source == *source { return Ok(()); } @@ -299,7 +413,44 @@ impl MotionState { Ok(()) } - pub(crate) fn frame(&self, now: Instant) -> MotionFrame { + /// Take in the height the content measured last frame. + /// + /// A `height` with `auto` at an end resolves against the content every + /// frame, so content that grows while the animation runs moves the height + /// with it, and the box jumps. When the measurement changes part way, the + /// start is rewritten so the frame at this progress still lands on the + /// height that was on screen, and the rest of the curve bends toward the + /// new end. The clock keeps running, so the animation ends when it would + /// have. + fn follow_content(&mut self, now: Instant) { + let measured = self.measured.get(); + if measured == self.content { + return; + } + if let (Some(old), Some(new)) = (self.content, measured) { + let (raw, progress) = self.progress(now); + let ends = match (self.from.height, self.target.height) { + (Some(from), Some(target)) if raw < 1.0 => Some((from, target)), + _ => None, + }; + if let Some((from, target)) = ends.filter(|(from, target)| { + from.needs_content() || target.needs_content() + }) { + let visible = from.mix(target, progress).resolve(old); + let end = target.resolve(new); + // The pixels a start needs so that mixing it toward `end` at + // `progress` gives `visible`. It can go below zero, which only + // means the curve was already past it. + let start = (visible - progress * end) / (1.0 - progress); + self.from.height = Some(MotionHeight::pixels(start)); + } + } + self.content = measured; + } + + /// Where the transition is at `now`: the share of the duration that has + /// passed, and the same share after easing. + fn progress(&self, now: Instant) -> (f64, f64) { let delay = seconds(self.transition.delay); let duration = seconds(self.transition.duration); let elapsed = now.saturating_duration_since(self.started); @@ -310,12 +461,18 @@ impl MotionState { } else { elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64() }; + (raw, ease(raw.clamp(0.0, 1.0), &self.transition.ease)) + } + + pub(crate) fn frame(&self, now: Instant) -> MotionFrame { + let (raw, progress) = self.progress(now); let active = self.from != self.target && raw < 1.0; - let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease); MotionFrame { style: self.from.interpolate(self.target, progress), active, + content: self.content, + measured: self.measured.clone(), } } } @@ -442,6 +599,39 @@ fn cubic_bezier(x: f64, [x1, y1, x2, y2]: [f64; 4]) -> f64 { mod tests { use super::*; + #[test] + fn corner_shapes_move_through_half_corner_space() { + let round = MotionShape(1.0); + let square = MotionShape(f64::INFINITY); + let notch = MotionShape(f64::NEG_INFINITY); + for k in [-3.0, -1.0, 0.0, 0.5, 1.0, 2.0, 4.0] { + let back = MotionShape::from_half_corner(MotionShape(k).half_corner()).0; + assert!((back - k).abs() < 1e-9, "{k} came back as {back}"); + } + let close = |a: MotionShape, b: MotionShape| a == b || (a.0 - b.0).abs() < 1e-9; + assert!(close(round.mix(square, 0.0), round)); + assert!(close(round.mix(square, 1.0), square)); + assert_eq!(MotionShape(0.0).half_corner(), 0.5); + // Half way from round to square sits between the two, not at infinity. + let mid = round.mix(square, 0.5).0; + assert!(mid > 1.0 && mid.is_finite(), "{mid}"); + assert!(close(notch.mix(square, 0.5), MotionShape(0.0))); + + let started = Instant::now(); + let spec = serde_json::json!({ + "initial": { "cornerShape": "notch" }, + "animate": { "cornerShape": "square" }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let mut state = MotionState::new(&spec, started).unwrap(); + let frame = state.frame(started + Duration::from_millis(500)); + let mut style = StyleDesc::default(); + frame.style.apply_to(&mut style); + assert_eq!(style.corner_shape.as_deref(), Some("superellipse(0)")); + let bad = serde_json::json!({ "animate": { "cornerShape": "oval" } }); + assert!(MotionState::new(&bad, started).is_err()); + } + #[test] fn interpolates_and_retargets_from_the_visible_value() { let started = Instant::now(); @@ -566,6 +756,38 @@ mod tests { assert_eq!(at(state.frame(turned + Duration::from_millis(500))), Some(50.0)); } + #[test] + fn bends_toward_content_that_grows_while_it_opens() { + let started = Instant::now(); + let description = serde_json::json!({ + "initial": { "height": 0.0 }, + "animate": { "height": "auto" }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let mut state = MotionState::new(&description, started).unwrap(); + + // The first frame measured the content at 100. + state.frame(started).measured.report(100.0); + let half = started + Duration::from_millis(500); + state.sync(&description, half).unwrap(); + let frame = state.frame(half); + assert_eq!(frame.content, Some(100.0)); + assert_eq!(frame.style.height.map(|h| h.resolve(100.0)), Some(50.0)); + + // The content grew to 200 during that frame. + frame.measured.report(200.0); + state.sync(&description, half).unwrap(); + let frame = state.frame(half); + assert_eq!(frame.content, Some(200.0)); + // Still at 50 with the new content, where the last frame was. + assert_eq!(frame.style.height.map(|h| h.resolve(200.0)), Some(50.0)); + // And it ends on the new content, at the time it would have. + let later = state.frame(started + Duration::from_millis(750)); + assert_eq!(later.style.height.map(|h| h.resolve(200.0)), Some(125.0)); + let done = state.frame(started + Duration::from_secs(1)); + assert_eq!(done.style.height.map(|h| h.resolve(200.0)), Some(200.0)); + } + #[test] fn rejects_a_height_keyword_it_cannot_measure() { let now = Instant::now(); diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 11804be3..2b4bf7d2 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -1,3 +1,4 @@ +pub(crate) mod corners; pub(crate) mod resolve; pub(crate) mod vars; @@ -399,6 +400,7 @@ style_desc! { // Background & Colors background: Option = "background", background_color: Option = "backgroundColor", + background_image: Option = "backgroundImage", color: Option = "color", opacity: Option = "opacity", @@ -414,6 +416,47 @@ style_desc! { border_top_right_radius: Option = "borderTopRightRadius", border_bottom_left_radius: Option = "borderBottomLeftRadius", border_bottom_right_radius: Option = "borderBottomRightRadius", + border_start_start_radius: Option = "borderStartStartRadius", + border_start_end_radius: Option = "borderStartEndRadius", + border_end_start_radius: Option = "borderEndStartRadius", + border_end_end_radius: Option = "borderEndEndRadius", + + // Corner shape (CSS Borders 4, section 3.9). `corner*` shorthands take a + // radius list and a shape list in either order; `*Shape` takes shapes only. + corner_shape: Option = "cornerShape", + corner_top_left_shape: Option = "cornerTopLeftShape", + corner_top_right_shape: Option = "cornerTopRightShape", + corner_bottom_right_shape: Option = "cornerBottomRightShape", + corner_bottom_left_shape: Option = "cornerBottomLeftShape", + corner_start_start_shape: Option = "cornerStartStartShape", + corner_start_end_shape: Option = "cornerStartEndShape", + corner_end_start_shape: Option = "cornerEndStartShape", + corner_end_end_shape: Option = "cornerEndEndShape", + corner_top_shape: Option = "cornerTopShape", + corner_right_shape: Option = "cornerRightShape", + corner_bottom_shape: Option = "cornerBottomShape", + corner_left_shape: Option = "cornerLeftShape", + corner_block_start_shape: Option = "cornerBlockStartShape", + corner_block_end_shape: Option = "cornerBlockEndShape", + corner_inline_start_shape: Option = "cornerInlineStartShape", + corner_inline_end_shape: Option = "cornerInlineEndShape", + corner: Option = "corner", + corner_top_left: Option = "cornerTopLeft", + corner_top_right: Option = "cornerTopRight", + corner_bottom_right: Option = "cornerBottomRight", + corner_bottom_left: Option = "cornerBottomLeft", + corner_start_start: Option = "cornerStartStart", + corner_start_end: Option = "cornerStartEnd", + corner_end_start: Option = "cornerEndStart", + corner_end_end: Option = "cornerEndEnd", + corner_top: Option = "cornerTop", + corner_right: Option = "cornerRight", + corner_bottom: Option = "cornerBottom", + corner_left: Option = "cornerLeft", + corner_block_start: Option = "cornerBlockStart", + corner_block_end: Option = "cornerBlockEnd", + corner_inline_start: Option = "cornerInlineStart", + corner_inline_end: Option = "cornerInlineEnd", box_shadow: Option = "boxShadow", // Text diff --git a/packages/native/src/style/corners.rs b/packages/native/src/style/corners.rs new file mode 100644 index 00000000..57de2416 --- /dev/null +++ b/packages/native/src/style/corners.rs @@ -0,0 +1,340 @@ +//! `corner-shape` and the `corner*` shorthands from CSS Borders 4, section 3.9, +//! plus the `border-*-radius` family they share their corners with. +//! +//! Each property parses on its own. A bad value drops that one property, as +//! in CSS, and the rest still apply. Logical names map with `horizontal-tb` +//! and `ltr`, which is the only writing mode GPUIX lays out. + +use crate::style::{Numeric, StyleDesc}; +use gpui::Corners; +use std::fmt::Debug; + +/// The curvature of a plain `round` corner, which is also what a shorthand +/// resets the shape to when it names a radius only. +const ROUND: f32 = 1.0; + +/// The curvature `K` of one ``, or `None` when the text is +/// not one. The keywords map to `round = 1`, `squircle = 2`, `square = +inf`, +/// `bevel = 0`, `scoop = -1` and `notch = -inf`. `superellipse(K)` takes any +/// finite number or `infinity` with an optional sign. +pub(crate) fn shape(text: &str) -> Option { + let lower = text.trim().to_ascii_lowercase(); + match lower.as_str() { + "round" => return Some(ROUND), + "squircle" => return Some(2.0), + "square" => return Some(f32::INFINITY), + "bevel" => return Some(0.0), + "scoop" => return Some(-1.0), + "notch" => return Some(f32::NEG_INFINITY), + _ => {} + } + let inner = lower + .strip_prefix("superellipse(")? + .strip_suffix(')')? + .trim(); + match inner { + "infinity" | "+infinity" => Some(f32::INFINITY), + "-infinity" => Some(f32::NEG_INFINITY), + _ => inner.parse::().ok().filter(|k| k.is_finite()), + } +} + +/// Split on whitespace outside parentheses, so `superellipse( 2 )` stays one +/// token. +fn tokens(text: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut start = None; + for (i, c) in text.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = (depth - 1).max(0), + c if c.is_whitespace() && depth == 0 => { + if let Some(s) = start.take() { + out.push(&text[s..i]); + } + continue; + } + _ => {} + } + if start.is_none() { + start = Some(i); + } + } + if let Some(s) = start { + out.push(&text[s..]); + } + out +} + +/// One to `max` shapes, or `None` when any token is not a shape. +fn shape_list(text: Option<&str>, max: usize) -> Option> { + let shapes: Vec = tokens(text?) + .into_iter() + .map(shape) + .collect::>()?; + (1..=max).contains(&shapes.len()).then_some(shapes) +} + +/// A `corner*` shorthand: radii and shapes in either order. A missing part +/// resets to its initial value, `0` or `round`, as every CSS shorthand does. +/// A `/` (elliptical radii) makes the whole value invalid. +fn shorthand(text: Option<&str>, max: usize) -> Option<(Vec, Vec)> { + let mut radii = Vec::new(); + let mut shapes = Vec::new(); + for token in tokens(text?) { + if token.contains('/') { + return None; + } + match shape(token) { + Some(k) => shapes.push(k), + None => radii.push(Numeric::Text(token.to_owned())), + } + } + if radii.len() > max || shapes.len() > max || radii.is_empty() && shapes.is_empty() { + return None; + } + if radii.is_empty() { + radii.push(Numeric::Number(0.0)); + } + if shapes.is_empty() { + shapes.push(ROUND); + } + Some((radii, shapes)) +} + +/// Expand a one-to-four value list the way `border-radius` does. Four corners +/// read top-left, top-right, bottom-right, bottom-left. Two corners read in the +/// order the side lists them. +fn spread(values: &[T], count: usize) -> Vec { + let pick = |i: usize| values[i.min(values.len() - 1)].clone(); + match (count, values.len()) { + (4, 2) => vec![pick(0), pick(1), pick(0), pick(1)], + (4, 3) => vec![pick(0), pick(1), pick(2), pick(1)], + _ => (0..count).map(pick).collect(), + } +} + +#[derive(Clone, Copy)] +enum Corner { + TopLeft, + TopRight, + BottomRight, + BottomLeft, +} + +use Corner::*; + +/// The four corners in the order a four-value list names them. +const ALL: [Corner; 4] = [TopLeft, TopRight, BottomRight, BottomLeft]; + +fn slot(corners: &mut Corners, corner: Corner) -> &mut T { + match corner { + TopLeft => &mut corners.top_left, + TopRight => &mut corners.top_right, + BottomRight => &mut corners.bottom_right, + BottomLeft => &mut corners.bottom_left, + } +} + +/// The radius and shape each corner ends up with, `None` where nothing set it. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct ResolvedCorners { + pub radii: Corners>, + pub shapes: Corners>, +} + +impl ResolvedCorners { + fn set(&mut self, corners: &[Corner], radii: Option<&[Numeric]>, shapes: Option<&[f32]>) { + if let Some(radii) = radii { + for (corner, radius) in corners.iter().zip(spread(radii, corners.len())) { + *slot(&mut self.radii, *corner) = Some(radius); + } + } + if let Some(shapes) = shapes { + for (corner, shape) in corners.iter().zip(spread(shapes, corners.len())) { + *slot(&mut self.shapes, *corner) = Some(shape); + } + } + } + + fn combined(&mut self, text: Option<&str>, corners: &[Corner]) { + if let Some((radii, shapes)) = shorthand(text, corners.len()) { + self.set(corners, Some(&radii), Some(&shapes)); + } + } + + fn shapes_only(&mut self, text: Option<&str>, corners: &[Corner]) { + if let Some(shapes) = shape_list(text, corners.len()) { + self.set(corners, None, Some(&shapes)); + } + } +} + +/// Resolve every corner property of `style` into one radius and one shape per +/// corner. +/// +/// Properties that name fewer corners win over ones that name more, and a +/// single-purpose property wins over a combined shorthand of the same reach. +/// So a longhand beats `cornerTopLeft`, which beats `cornerTop` and +/// `cornerTopShape`, which beat `borderRadius` and `cornerShape`, which beat +/// `corner`. CSS decides this by declaration order, which a style object does +/// not keep, so this is the nearest fixed rule. +pub(crate) fn resolve(style: &StyleDesc) -> ResolvedCorners { + let mut out = ResolvedCorners::default(); + + out.combined(style.corner.as_deref(), &ALL); + if let Some(radius) = &style.border_radius { + out.set(&ALL, Some(std::slice::from_ref(radius)), None); + } + out.shapes_only(style.corner_shape.as_deref(), &ALL); + + let sides: [(&Option, &Option, [Corner; 2]); 8] = [ + (&style.corner_top, &style.corner_top_shape, [TopLeft, TopRight]), + (&style.corner_right, &style.corner_right_shape, [TopRight, BottomRight]), + (&style.corner_bottom, &style.corner_bottom_shape, [BottomLeft, BottomRight]), + (&style.corner_left, &style.corner_left_shape, [TopLeft, BottomLeft]), + (&style.corner_block_start, &style.corner_block_start_shape, [TopLeft, TopRight]), + (&style.corner_block_end, &style.corner_block_end_shape, [BottomLeft, BottomRight]), + (&style.corner_inline_start, &style.corner_inline_start_shape, [TopLeft, BottomLeft]), + (&style.corner_inline_end, &style.corner_inline_end_shape, [TopRight, BottomRight]), + ]; + for (both, shape, corners) in sides { + out.combined(both.as_deref(), &corners); + out.shapes_only(shape.as_deref(), &corners); + } + + let singles: [(&Option, &Option, &Option, Corner); 8] = [ + (&style.corner_top_left, &style.border_top_left_radius, &style.corner_top_left_shape, TopLeft), + (&style.corner_top_right, &style.border_top_right_radius, &style.corner_top_right_shape, TopRight), + (&style.corner_bottom_right, &style.border_bottom_right_radius, &style.corner_bottom_right_shape, BottomRight), + (&style.corner_bottom_left, &style.border_bottom_left_radius, &style.corner_bottom_left_shape, BottomLeft), + (&style.corner_start_start, &style.border_start_start_radius, &style.corner_start_start_shape, TopLeft), + (&style.corner_start_end, &style.border_start_end_radius, &style.corner_start_end_shape, TopRight), + (&style.corner_end_end, &style.border_end_end_radius, &style.corner_end_end_shape, BottomRight), + (&style.corner_end_start, &style.border_end_start_radius, &style.corner_end_start_shape, BottomLeft), + ]; + for (both, radius, shape, corner) in singles { + out.combined(both.as_deref(), &[corner]); + if let Some(radius) = radius { + out.set(&[corner], Some(std::slice::from_ref(radius)), None); + } + out.shapes_only(shape.as_deref(), &[corner]); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styled(build: impl FnOnce(&mut StyleDesc)) -> StyleDesc { + let mut style = StyleDesc::default(); + build(&mut style); + style + } + + fn text(value: &str) -> Option { + Some(Numeric::Text(value.to_owned())) + } + + #[test] + fn parses_every_keyword_and_the_function() { + assert_eq!(shape("round"), Some(1.0)); + assert_eq!(shape("Squircle"), Some(2.0)); + assert_eq!(shape("square"), Some(f32::INFINITY)); + assert_eq!(shape("bevel"), Some(0.0)); + assert_eq!(shape("scoop"), Some(-1.0)); + assert_eq!(shape("notch"), Some(f32::NEG_INFINITY)); + assert_eq!(shape("superellipse(1.5)"), Some(1.5)); + assert_eq!(shape("superellipse( -3 )"), Some(-3.0)); + assert_eq!(shape("superellipse(infinity)"), Some(f32::INFINITY)); + assert_eq!(shape("superellipse(-infinity)"), Some(f32::NEG_INFINITY)); + } + + #[test] + fn rejects_values_the_spec_does_not_allow() { + for bad in ["circle", "superellipse()", "superellipse(nan)", "superellipse(1px)", "superellipse(1", "2"] { + assert_eq!(shape(bad), None, "{bad}"); + } + } + + #[test] + fn corner_shape_fills_missing_corners_like_border_radius() { + let style = styled(|s| s.corner_shape = Some("bevel scoop notch".into())); + let out = resolve(&style); + assert_eq!(out.shapes.top_left, Some(0.0)); + assert_eq!(out.shapes.top_right, Some(-1.0)); + assert_eq!(out.shapes.bottom_right, Some(f32::NEG_INFINITY)); + assert_eq!(out.shapes.bottom_left, Some(-1.0)); + } + + #[test] + fn a_bad_token_drops_the_whole_property() { + let style = styled(|s| s.corner_shape = Some("bevel oval".into())); + assert_eq!(resolve(&style), ResolvedCorners::default()); + let style = styled(|s| s.corner_shape = Some("bevel bevel bevel bevel bevel".into())); + assert_eq!(resolve(&style), ResolvedCorners::default()); + } + + #[test] + fn corner_shorthand_takes_radii_and_shapes_in_either_order() { + let style = styled(|s| s.corner = Some("squircle 8px 16px".into())); + let out = resolve(&style); + assert_eq!(out.radii.top_left, text("8px")); + assert_eq!(out.radii.top_right, text("16px")); + assert_eq!(out.radii.bottom_right, text("8px")); + assert_eq!(out.shapes.bottom_left, Some(2.0)); + } + + #[test] + fn a_shorthand_resets_the_part_it_leaves_out() { + let style = styled(|s| s.corner_top_left = Some("bevel".into())); + let out = resolve(&style); + assert_eq!(out.radii.top_left, Some(Numeric::Number(0.0))); + assert_eq!(out.shapes.top_left, Some(0.0)); + assert_eq!(out.radii.top_right, None); + + let style = styled(|s| s.corner = Some("4px".into())); + assert_eq!(resolve(&style).shapes.top_left, Some(ROUND)); + } + + #[test] + fn a_slash_makes_the_shorthand_invalid() { + let style = styled(|s| s.corner = Some("8px / 4px bevel".into())); + assert_eq!(resolve(&style), ResolvedCorners::default()); + } + + #[test] + fn narrower_properties_win() { + let style = styled(|s| { + s.corner = Some("notch 1px".into()); + s.corner_shape = Some("bevel".into()); + s.corner_top_shape = Some("scoop squircle".into()); + s.corner_top_left_shape = Some("square".into()); + s.border_radius = Some(Numeric::Number(8.0)); + s.border_top_right_radius = Some(Numeric::Number(2.0)); + }); + let out = resolve(&style); + assert_eq!(out.shapes.top_left, Some(f32::INFINITY)); + assert_eq!(out.shapes.top_right, Some(2.0)); + assert_eq!(out.shapes.bottom_right, Some(0.0)); + assert_eq!(out.radii.top_left, Some(Numeric::Number(8.0))); + assert_eq!(out.radii.top_right, Some(Numeric::Number(2.0))); + } + + #[test] + fn logical_names_map_to_horizontal_ltr() { + let style = styled(|s| { + s.corner_inline_end_shape = Some("bevel scoop".into()); + s.corner_end_start_shape = Some("notch".into()); + s.border_start_start_radius = Some(Numeric::Number(3.0)); + }); + let out = resolve(&style); + assert_eq!(out.shapes.top_right, Some(0.0)); + assert_eq!(out.shapes.bottom_right, Some(-1.0)); + assert_eq!(out.shapes.bottom_left, Some(f32::NEG_INFINITY)); + assert_eq!(out.radii.top_left, Some(Numeric::Number(3.0))); + } +} diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index b06f1851..d35a0fa7 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -140,7 +140,7 @@ pub(crate) fn apply_resolved(mut el: E, resolved: &StyleRefinem /// value. pub(crate) fn apply_motion( mut el: E, - frame: crate::motion::MotionFrame, + frame: &crate::motion::MotionFrame, declared: Option<&StyleDesc>, ) -> E { let motion = frame.style; @@ -185,6 +185,34 @@ pub(crate) fn apply_motion( el = el.rounded_br(radius); } } + if let Some(shape) = motion.corner_shape { + // Same rule as the radius: a property narrower than `cornerShape` + // keeps its corner. `corner` and `cornerShape` are what motion drives. + let narrow = declared.map(|style| { + let wide = StyleDesc { + corner: None, + corner_shape: None, + ..style.clone() + }; + super::corners::resolve(&wide).shapes + }); + let shape = gpui::CornerShape(shape.0 as f32); + let free = |pick: fn(&gpui::Corners>) -> Option| { + narrow.as_ref().and_then(pick).is_none() + }; + if free(|c| c.top_left) { + el = el.corner_shape_tl(shape); + } + if free(|c| c.top_right) { + el = el.corner_shape_tr(shape); + } + if free(|c| c.bottom_left) { + el = el.corner_shape_bl(shape); + } + if free(|c| c.bottom_right) { + el = el.corner_shape_br(shape); + } + } if let Some(opacity) = motion.opacity { el = el.opacity(opacity as f32); } @@ -227,6 +255,27 @@ fn dimension(value: crate::style::DimensionValue) -> gpui::Length { } } +/// The one fill an element paints, or none. +/// +/// GPUI paints one fill per box, so an image wins over a colour outright. A +/// browser would paint the image over the colour, which only differs when +/// the image has transparent parts. `background` is the shorthand, so it +/// loses to both longhands. +fn background_fill(style: &StyleDesc, scope: &Scope) -> Option { + let image = style + .background_image + .as_deref() + .and_then(|text| scope.fill(text)); + if image.is_some() { + return image; + } + style + .background_color + .as_deref() + .or(style.background.as_deref()) + .and_then(|text| scope.fill(text)) +} + pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: &Scope) -> E { // `visibility` reached StyleDesc but nothing read it, so `hideInstance` // hid nothing. GPUI's Visibility::Hidden has the CSS meaning: skip the @@ -380,13 +429,8 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: if let Some(left) = scope.number(&style.left) { el = el.left(gpui::px(left as f32)); } - if let Some(color) = style - .background_color - .as_deref() - .or(style.background.as_deref()) - .and_then(|bg| scope.color(bg)) - { - el = el.bg(crate::color::to_hsla(color)); + if let Some(fill) = background_fill(style, scope) { + el = el.bg(crate::color::to_background(&fill)); } if let Some(color) = style.color.as_deref().and_then(|c| scope.color(c)) { el = el.text_color(crate::color::to_hsla(color)); @@ -439,22 +483,31 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: _ => {} } } - if let Some(radius) = scope.number(&style.border_radius) { - el = el.rounded(gpui::px(radius as f32)); - } - // Apply corner longhands after the shorthand so the explicit corner wins. - if let Some(radius) = scope.number(&style.border_top_left_radius) { + let corners = super::corners::resolve(style); + if let Some(radius) = scope.number(&corners.radii.top_left) { el = el.rounded_tl(gpui::px(radius as f32)); } - if let Some(radius) = scope.number(&style.border_top_right_radius) { + if let Some(radius) = scope.number(&corners.radii.top_right) { el = el.rounded_tr(gpui::px(radius as f32)); } - if let Some(radius) = scope.number(&style.border_bottom_left_radius) { + if let Some(radius) = scope.number(&corners.radii.bottom_left) { el = el.rounded_bl(gpui::px(radius as f32)); } - if let Some(radius) = scope.number(&style.border_bottom_right_radius) { + if let Some(radius) = scope.number(&corners.radii.bottom_right) { el = el.rounded_br(gpui::px(radius as f32)); } + if let Some(shape) = corners.shapes.top_left { + el = el.corner_shape_tl(gpui::CornerShape(shape)); + } + if let Some(shape) = corners.shapes.top_right { + el = el.corner_shape_tr(gpui::CornerShape(shape)); + } + if let Some(shape) = corners.shapes.bottom_left { + el = el.corner_shape_bl(gpui::CornerShape(shape)); + } + if let Some(shape) = corners.shapes.bottom_right { + el = el.corner_shape_br(gpui::CornerShape(shape)); + } // `borderWidth: 0` must clear a border, not be ignored: an element that // draws its own border needs a way for the caller to remove it. if let Some(width) = scope.number(&style.border_width) { @@ -490,10 +543,8 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: if let Some(opacity) = scope.number(&style.opacity) { el = el.opacity(opacity as f32); } - match style.cursor.as_deref() { - Some("pointer") => el = el.cursor_pointer(), - Some("default") => el = el.cursor_default(), - _ => {} + if let Some(cursor) = style.cursor.as_deref().and_then(cursor_style) { + el = el.cursor(cursor); } // Overflow: hidden is on the Styled trait, so we handle it here. // overflow: "scroll" requires StatefulInteractiveElement — handled in build_div(). @@ -606,6 +657,34 @@ mod tests { ); } + #[test] + fn a_gradient_image_wins_over_the_colour() { + let style = StyleDesc { + background_color: Some("#111111".to_string()), + background_image: Some("linear-gradient(to right, red, blue)".to_string()), + ..Default::default() + }; + let fill = background_of(&style, &no_variables()).expect("a fill"); + let background = fill.color().expect("a background"); + assert!(background.as_solid().is_none(), "should be a gradient: {background:?}"); + + // `none` steps aside for the colour underneath. + let style = StyleDesc { + background_image: Some("none".to_string()), + ..style + }; + let fill = background_of(&style, &no_variables()).expect("a fill"); + assert!(fill.color().and_then(|b| b.as_solid()).is_some()); + + // The shorthand takes a gradient too. + let shorthand = StyleDesc { + background: Some("linear-gradient(red, blue)".to_string()), + ..Default::default() + }; + let fill = background_of(&shorthand, &no_variables()).expect("a fill"); + assert!(fill.color().and_then(|b| b.as_solid()).is_none()); + } + #[test] fn a_style_with_no_states_resolves_to_none() { let resolved = Resolved::build(&styled("#111111"), &no_variables()); @@ -771,3 +850,33 @@ mod tests { assert_eq!(resolved.base.padding.top, Some(gpui::px(24.0).into())); } } + +/// The GPUI cursor for a CSS `cursor` keyword. `auto` and unknown words set +/// nothing, so the element keeps the cursor of whatever it sits in. +pub(crate) fn cursor_style(name: &str) -> Option { + use gpui::CursorStyle::*; + Some(match name.trim() { + "default" => Arrow, + "pointer" => PointingHand, + "text" => IBeam, + "vertical-text" => IBeamCursorForVerticalLayout, + "crosshair" => Crosshair, + "grab" => OpenHand, + "grabbing" => ClosedHand, + "not-allowed" | "no-drop" => OperationNotAllowed, + "col-resize" => ResizeColumn, + "row-resize" => ResizeRow, + "e-resize" => ResizeRight, + "w-resize" => ResizeLeft, + "n-resize" => ResizeUp, + "s-resize" => ResizeDown, + "ew-resize" => ResizeLeftRight, + "ns-resize" => ResizeUpDown, + "nesw-resize" | "ne-resize" | "sw-resize" => ResizeUpRightDownLeft, + "nwse-resize" | "nw-resize" | "se-resize" => ResizeUpLeftDownRight, + "alias" => DragLink, + "copy" => DragCopy, + "context-menu" => ContextualMenu, + _ => return None, + }) +} diff --git a/packages/native/src/style/vars.rs b/packages/native/src/style/vars.rs index c76838db..d6e00cca 100644 --- a/packages/native/src/style/vars.rs +++ b/packages/native/src/style/vars.rs @@ -138,6 +138,23 @@ impl<'a> Scope<'a> { Some(reading.color) } + /// The fill a `background` or `background-image` declaration names. + /// + /// `None` when the value is `none`, or when it is not something this + /// build paints. A colour or a `linear-gradient()` are what it paints. + pub fn fill(&self, text: &str) -> Option { + let text = self.value(text)?; + let context = ColorContext { + current_color: self.current_color, + dark: self.dark, + }; + let reading = gpuix_css::background::read(&text, &context).ok()??; + if reading.read_current_color { + self.used.set(true); + } + Some(reading.fill) + } + /// Whether resolving read a variable. pub fn used_a_variable(&self) -> bool { self.used.get() diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index 6e9012bb..afe677c4 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1922,6 +1922,107 @@ describeNative("motion", () => { box.done() }) + it("paints a linear gradient across the box", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+
+ ) + // Left edge red, right edge blue, middle a mix of both. + const [leftR, , leftB] = renderer.pixelAt(12, 30) + const [rightR, , rightB] = renderer.pixelAt(208, 30) + const [midR, , midB] = renderer.pixelAt(110, 30) + expect(leftR).toBeGreaterThan(220) + expect(leftB).toBeLessThan(40) + expect(rightB).toBeGreaterThan(220) + expect(rightR).toBeLessThan(40) + expect(midR).toBeGreaterThan(80) + expect(midB).toBeGreaterThan(80) + + // Two stops in one place make a hard edge, and the shorthand takes a gradient. + const [topR] = renderer.pixelAt(110, 65) + const [, , bottomB] = renderer.pixelAt(110, 95) + expect(topR).toBeGreaterThan(220) + expect(bottomB).toBeGreaterThan(220) + + // `none` leaves the colour to paint. + const [plainR, , plainB] = renderer.pixelAt(110, 130) + expect(plainR).toBeGreaterThan(220) + expect(plainB).toBeLessThan(40) + }) + + it("cuts corners to the declared shape", () => { + const { render, renderer } = createTestRoot() + const box = { width: 100, height: 100, backgroundColor: "#ff0000" } + render( +
+
+
+
+
+
+
+ ) + // Boxes sit at x = 10, 120, 230, 340, 450. The window is opaque, so the + // colour tells the fill from the background, not the alpha. + const red = (x: number, y: number) => { + const [r, g, b] = renderer.pixelAt(x, y) + return r > 200 && g < 60 && b < 60 + } + // (8, 8) from the corner: outside a 40px circle, a bevel, a scoop and a + // notch, inside a square. + expect(red(18, 18)).toBe(false) + expect(red(128, 18)).toBe(false) + expect(red(238, 18)).toBe(true) + expect(red(348, 18)).toBe(false) + expect(red(458, 18)).toBe(false) + // (14, 14): inside the circle, still cut by the bevel line x + y = 40. + expect(red(24, 24)).toBe(true) + expect(red(134, 24)).toBe(false) + // (35, 35): the notch removes the whole 40px square. + expect(red(375, 45)).toBe(false) + // (20, 20): 28px from the corner, inside the scoop's 40px cut-out. The + // invalid `oval` longhand drops itself, so the shorthand's scoop stays. + expect(red(470, 30)).toBe(false) + // The centre of every box is filled. + for (const left of [10, 120, 230, 340, 450]) expect(red(left + 50, 60)).toBe(true) + }) + + it("bends toward content that grows while it opens", () => { + const { render, renderer } = createTestRoot() + const tree = (rows: number) => ( + + {Array.from({ length: rows }, (_, index) => ( +
+ ))} + + ) + renderer.clockPause() + render(tree(1)) + const id = renderer.findByType("div")[0]!.id + const height = () => renderer.getElementBounds(id)?.[3] ?? -1 + + renderer.clockFastForward(500) + expect(height()).toBeCloseTo(50, 0) + + // A second row doubles the content half way through. The box used to + // jump to half of the new content on the next frame. + render(tree(2)) + renderer.clockFastForward(16) + expect(height()).toBeLessThan(60) + renderer.clockFastForward(484) + expect(height()).toBeCloseTo(200, 0) + renderer.clockResume() + }) + it("renders the normal element when an internal motion payload is invalid", () => { const { render, renderer } = createTestRoot() diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 9f438007..da47d75f 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -31,6 +31,12 @@ export interface MotionStyle { bottom?: Numeric left?: Numeric borderRadius?: Numeric + /** + * A corner shape keyword, `superellipse(K)` text, or the number `K` itself. + * Interpolates in the half-corner space CSS Borders 4 names, so + * `round` to `square` moves at an even pace. + */ + cornerShape?: number | string } export type MotionEase = @@ -118,8 +124,13 @@ export interface StyleDesc { bottom?: number left?: number + /** A colour or a `linear-gradient()`. The shorthand, so both longhands + * win over it. */ background?: string backgroundColor?: string + /** A `linear-gradient()` or `none`. Wins over `backgroundColor`, since a + * box paints one fill. Stop positions are percentages. */ + backgroundImage?: string color?: string opacity?: number @@ -134,6 +145,63 @@ export interface StyleDesc { borderTopRightRadius?: Numeric borderBottomLeftRadius?: Numeric borderBottomRightRadius?: Numeric + borderStartStartRadius?: Numeric + borderStartEndRadius?: Numeric + borderEndStartRadius?: Numeric + borderEndEndRadius?: Numeric + + /** + * CSS Borders 4 `corner-shape`, one to four of `round`, `squircle`, + * `square`, `bevel`, `scoop`, `notch` or `superellipse(K)`, read + * top-left, top-right, bottom-right, bottom-left like `borderRadius`. + * The shape only shows where the corner has a radius. A value the spec + * rejects drops the whole property. Logical names assume `horizontal-tb` + * and `ltr`. + */ + cornerShape?: string + cornerTopLeftShape?: string + cornerTopRightShape?: string + cornerBottomRightShape?: string + cornerBottomLeftShape?: string + cornerStartStartShape?: string + cornerStartEndShape?: string + cornerEndStartShape?: string + cornerEndEndShape?: string + /** Two shapes, in the order the side runs: left to right, or top to bottom. */ + cornerTopShape?: string + cornerRightShape?: string + cornerBottomShape?: string + cornerLeftShape?: string + cornerBlockStartShape?: string + cornerBlockEndShape?: string + cornerInlineStartShape?: string + cornerInlineEndShape?: string + /** + * Radius and shape together, in either order: `"8px squircle"`. A part + * you leave out resets, so `corner: "bevel"` also sets the radius to 0. + * `/` (elliptical radii) is not supported and makes the value invalid. + * A narrower property wins over a wider one, and a single-purpose one + * over a shorthand: `cornerTopLeftShape` beats `cornerTopLeft`, which + * beats `cornerTop`, which beats `cornerShape` and `borderRadius`, which + * beat `corner`. + */ + corner?: string + cornerTopLeft?: string + cornerTopRight?: string + cornerBottomRight?: string + cornerBottomLeft?: string + cornerStartStart?: string + cornerStartEnd?: string + cornerEndStart?: string + cornerEndEnd?: string + cornerTop?: string + cornerRight?: string + cornerBottom?: string + cornerLeft?: string + cornerBlockStart?: string + cornerBlockEnd?: string + cornerInlineStart?: string + cornerInlineEnd?: string boxShadow?: BoxShadow fontSize?: Numeric @@ -149,6 +217,13 @@ export interface StyleDesc { overflowX?: string overflowY?: string + /** + * A CSS cursor keyword: `default`, `pointer`, `text`, `vertical-text`, + * `crosshair`, `grab`, `grabbing`, `not-allowed`, `no-drop`, `col-resize`, + * `row-resize`, the eight `*-resize` directions, `alias`, `copy` and + * `context-menu`. `auto` and other words set nothing, so the element keeps + * the cursor of its parent. Selectable text shows the I-beam under `auto`. + */ cursor?: string /** `"auto"` blocks hits behind this element. `"none"` never does. Unset blocks when the element paints a fill or is absolutely positioned. */ pointerEvents?: "auto" | "none" From 5aa479966a29b2b8f3dc423ec85c2e14cb0bc507 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:00:49 +0200 Subject: [PATCH 15/29] docs(react): explain resolveClassName routing and fix a doc comment --- packages/react/src/reconciler/renderer.ts | 3 +++ packages/react/src/testing.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index fa5979d0..15952afb 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -168,7 +168,10 @@ export function resetRender(): void { /** Mount the app. Under `bun --hot`, later calls remount on the same native window. */ export function render(node: ReactNode, options: RenderOptions = {}): Root { + // resolveClassName reaches createRoot through `options`. The destructure + // only keeps it out of `windowOptions`, which goes to the native window. const { onEvent, renderer: injected, debugFrameOverlay, resolveClassName, ...windowOptions } = options + void resolveClassName const slot = renderSlot() const remount = slot.root != null if (!slot.renderer) { diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index c6e416b5..c9c9c73a 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -524,7 +524,7 @@ export class TestRenderer implements NativeRenderer { return this.native.getSelectedText() } - /// The text on the clipboard after a copy, or null when nothing text is there. + /// The text on the clipboard after a copy, or null when the clipboard has no text. readClipboardText(): string | null { return this.native.readClipboardText() } From 140869ae53b724f587289f8eb05acfee879ac720 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 00:03:09 +0200 Subject: [PATCH 16/29] fix(examples): set windows.icon only when the ico file exists --- examples/compile.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/compile.ts b/examples/compile.ts index 4b050345..b1aeb075 100644 --- a/examples/compile.ts +++ b/examples/compile.ts @@ -148,13 +148,17 @@ async function compileBinary(withIcon: boolean): Promise { } if (WINDOWS) { compile.windows = { - icon: withIcon && process.platform === 'win32' ? ICO : undefined, hideConsole: true, title: APP_NAME, publisher: 'GPUIX', version: '0.1.0', description: `${APP_NAME}, a desktop app built with GPUIX`, } + // Only set the key when the file exists. Bun rejects `icon: undefined` + // with "windows.icon must be a valid path to an ico file". + if (withIcon && process.platform === 'win32' && existsSync(ICO)) { + compile.windows.icon = ICO + } } const result = await Bun.build({ entrypoints: [ENTRY], compile, minify: true }) From 42131c812b5930c8643414c776fb10921296ec7f Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 00:32:03 +0200 Subject: [PATCH 17/29] fix(ci): build before typecheck and give the alpha colour tests one 8-bit step --- packages/react/package.json | 2 +- .../src/__tests__/color-functions.test.tsx | 35 +++++++++++++++++-- packages/react/tsconfig.typecheck.json | 5 +++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 4350efe9..6cc6c80d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -69,7 +69,7 @@ "clean": "rm -rf dist tsconfig.tsbuildinfo", "dev": "tsc --watch", "test": "vitest run", - "typecheck": "tsc -p tsconfig.typecheck.json", + "typecheck": "tsc && tsc -p tsconfig.typecheck.json", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/react/src/__tests__/color-functions.test.tsx b/packages/react/src/__tests__/color-functions.test.tsx index ffb8eb31..1d9e1cd3 100644 --- a/packages/react/src/__tests__/color-functions.test.tsx +++ b/packages/react/src/__tests__/color-functions.test.tsx @@ -80,6 +80,32 @@ function expectColorsEqual(name: string, input: string, expected: string) { expectScreenshotsEqual(actualPath, expectedPath) } +/// Paint `color` over a white parent and read one pixel. The window itself is +/// black, and translucent black over black is black, so alpha only shows over +/// an explicit light background. +function paintedPixel(color: string) { + const testRoot = createTestRoot() + testRoot.render( +
+
+
+ ) + return testRoot.renderer.pixelAt(2, 2) +} + +/// lightningcss keeps the alpha of `rgb()`, `hsl()` and `hwb()` in 8 bits, so +/// 50% comes back as 128/255. The wider spaces keep the exact float 0.5. Over +/// white, the first paints 127 and the second 128, and which side of the +/// boundary the GPU takes differs between Metal and Direct3D. So this allows +/// one 8-bit step, the same tolerance the engine colour tests use. +function expectColorsClose(input: string, expected: string) { + const actual = paintedPixel(input) + const reference = paintedPixel(expected) + for (let channel = 0; channel < 4; channel++) { + expect(Math.abs(actual[channel]! - reference[channel]!)).toBeLessThanOrEqual(1) + } +} + describeNative("native color functions", () => { it.each(absoluteCases)( "paints absolute %s exactly like its canonical hex", @@ -88,9 +114,12 @@ describeNative("native color functions", () => { } ) - it.each(alphaCases)("paints %s alpha exactly like 50% black", (name, input) => { - expectColorsEqual(`color-alpha-${name}`, input, "rgba(0 0 0 / 50%)") - }) + it.each(alphaCases)( + "paints %s alpha like 50% black within one 8-bit step", + (_name, input) => { + expectColorsClose(input, "rgba(0 0 0 / 50%)") + } + ) it.each(relativeCases)( "paints relative %s exactly like its expected hex", diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json index 26d96b4a..55c5f205 100644 --- a/packages/react/tsconfig.typecheck.json +++ b/packages/react/tsconfig.typecheck.json @@ -3,6 +3,11 @@ // test files there carry type errors that predate this config. `files` // survives `exclude`, so this config adds back the files whose whole purpose // is to be typechecked. + // + // The `typecheck` script builds first. `jsx-runtime.d.ts` imports the tag + // props from `./dist/types/host.js`, and with no `dist` the import resolves + // to `any` under `skipLibCheck`. Every `@ts-expect-error` on a rejected + // prop then fails with TS2578. "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true }, "files": [ From bff9e3ac5be4eaef469831f21cb726efd03f6937 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 00:56:29 +0200 Subject: [PATCH 18/29] fix(ci): run the react typecheck after the build and make it clean first --- .github/workflows/ci.yml | 10 ++++++---- packages/react/package.json | 2 +- packages/react/tsconfig.typecheck.json | 10 ++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950e19ae..b6f6f239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,10 +170,6 @@ jobs: name: bindings-aarch64-apple-darwin path: packages/native/ - - name: Typecheck React package - run: bun run typecheck - working-directory: packages/react - - name: Run React tests run: bun run test working-directory: packages/react @@ -182,6 +178,12 @@ jobs: run: bun run build working-directory: packages/react + # After the build, never before it. The script cleans and rebuilds + # `dist`, and a plain `tsc` build fails with TS5055 when `dist` exists. + - name: Typecheck React package + run: bun run typecheck + working-directory: packages/react + - name: Run example tests run: bun run test working-directory: examples diff --git a/packages/react/package.json b/packages/react/package.json index 6cc6c80d..df0b3cb7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -69,7 +69,7 @@ "clean": "rm -rf dist tsconfig.tsbuildinfo", "dev": "tsc --watch", "test": "vitest run", - "typecheck": "tsc && tsc -p tsconfig.typecheck.json", + "typecheck": "bun run clean && tsc && tsc -p tsconfig.typecheck.json", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json index 55c5f205..60b424a9 100644 --- a/packages/react/tsconfig.typecheck.json +++ b/packages/react/tsconfig.typecheck.json @@ -4,10 +4,12 @@ // survives `exclude`, so this config adds back the files whose whole purpose // is to be typechecked. // - // The `typecheck` script builds first. `jsx-runtime.d.ts` imports the tag - // props from `./dist/types/host.js`, and with no `dist` the import resolves - // to `any` under `skipLibCheck`. Every `@ts-expect-error` on a rejected - // prop then fails with TS2578. + // The `typecheck` script cleans and builds first, because the check needs + // `dist` and the build refuses one. `jsx-runtime.d.ts` imports the tag + // props from `./dist/types/host.js`. With no `dist` that import resolves + // to `any` under `skipLibCheck`, and every `@ts-expect-error` on a + // rejected prop fails with TS2578. With a `dist` already present, `tsc` + // sees `dist/types/host.d.ts` as an input and fails with TS5055. "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true }, "files": [ From d6360d6109535f30d31a106af59d1ef72c22db90 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 01:33:10 +0200 Subject: [PATCH 19/29] chore(zed): move the submodule to the corner shapes branch tip --- zed | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zed b/zed index f21f41df..9fbd87c7 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit f21f41df372eddfeb7d8c946fcbd6d217684721e +Subproject commit 9fbd87c7101c1b473e5296f6b07bd3dbd2aaf819 From 2993b61e3609f9dd3305c528918d023440b09a31 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 01:44:44 +0200 Subject: [PATCH 20/29] chore(zed): move the submodule to the corner shapes branch tip --- zed | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zed b/zed index 9fbd87c7..7831ae67 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit 9fbd87c7101c1b473e5296f6b07bd3dbd2aaf819 +Subproject commit 7831ae6748e5e085b4420569970d4b0c26f9e575 From 498d036f03a40d65a868176b8d80116ec0055e90 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 16:06:58 +0200 Subject: [PATCH 21/29] fix(demo): import the test renderer from the testing entry --- examples/demo.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 93cfd63e..50bdea16 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -9,8 +9,8 @@ import fs from "fs" import React from "react" import { describe, expect, it } from "vitest" -import { createTestRoot, hasNativeTestRenderer } from "@gpuix/react" -import type { TestRoot } from "@gpuix/react" +import { createTestRoot, hasNativeTestRenderer } from "@gpuix/react/testing" +import type { TestRoot } from "@gpuix/react/testing" import { App, BASE, PALETTES } from "./demo/app" import { ClassNames } from "./demo/class-names" import { Colors } from "./demo/colors" From 3b1a5adf3c36c1888fbc89a0ba0930785027402a Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 16:06:58 +0200 Subject: [PATCH 22/29] fix(demo): window the long list through virtual-list --- examples/demo/perf.tsx | 59 ++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/examples/demo/perf.tsx b/examples/demo/perf.tsx index 9913bcb6..df0edcf6 100644 --- a/examples/demo/perf.tsx +++ b/examples/demo/perf.tsx @@ -13,8 +13,7 @@ /// move. import React, { useEffect, useState } from "react" -import type { NativeRenderer } from "@gpuix/react" -import { VirtualList } from "@gpuix/react" +import type { EventPayload, NativeRenderer } from "@gpuix/react" import { Button, Panel, Row } from "./ui.js" /// The part of a renderer this panel reads. The overlay methods are optional @@ -127,40 +126,56 @@ function Frames({ renderer }: { renderer: FrameOverlay }) { } const ROWS = 5000 +/// Rows built around the visible range, so a scroll never waits for React. +const OVERDRAW_ROWS = 12 +/// Windowing is the app's job: the list reports the visible range, and this +/// component renders that slice plus an overdraw. `windowStart` tells the +/// native list which logical index the first child is. function Rows() { + const [range, setRange] = useState({ start: 0, end: 30 }) + const start = Math.max(0, range.start - OVERDRAW_ROWS) + const end = Math.min(ROWS, range.end + OVERDRAW_ROWS) return (
- ( -
- {String(index).padStart(4, "0")} + onVisibleRange={(event: EventPayload) => { + setRange({ start: event.startIndex ?? 0, end: event.endIndex ?? 30 }) + }} + > + {Array.from({ length: end - start }, (_, offset) => { + const index = start + offset + return (
-
- )} - /> + > + {String(index).padStart(4, "0")} +
+
+ ) + })} +
) From c752c273e81ce89e7f32346570b0f0141645045c Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Fri, 28 Aug 2026 20:13:11 +0200 Subject: [PATCH 23/29] docs(css): revision 4 puts structural variants and child utilities in scope --- docs/css-and-classname-plan.md | 54 +++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/css-and-classname-plan.md b/docs/css-and-classname-plan.md index 9ba61eea..9f0d7c0a 100644 --- a/docs/css-and-classname-plan.md +++ b/docs/css-and-classname-plan.md @@ -84,6 +84,12 @@ before you design against them. | 7 | The steady-state gate asserts a counter, not a duration. | A 2% wall-clock band on a CI runner is noise. It would be muted, and a muted gate reads as coverage. | | 8 | The cascade lives in `packages/native/src/cascade.rs`. | `renderer.rs` is already 3,683 lines. | +Revision 4 widens layer 4. The goal is Tailwind in its entirety. `first:`, `last:`, `odd:`, +`even:` and `only:` become index conditions, and the walk evaluates them from the child index. +`space-x-*`, `divide-*`, `*:` and `**:` become child conditions, and they flow down with +`Inherited`. Every variant that still drops now names its prerequisite. See "What still +drops, and why". + ## Layer 0: the `gpuix-css` crate New crate at `packages/native/css`, named `gpuix-css`. It depends on `lightningcss` with @@ -240,6 +246,9 @@ for (condition, refinement) in &cached.variants { Condition::Group { name, state: GroupState::Active } => el.group_active(name, |_| refinement.clone()), // A media condition is not a GPUI variant. It is evaluated during the walk. Condition::Media { .. } => el.style().refine(refinement), + // An index condition is not one either. The walk knows the child index + // and the child count, and merges when the test holds. + Condition::Index { .. } => el.style().refine(refinement), }; } ``` @@ -250,6 +259,12 @@ unqualified one. A media condition is not a GPUI variant. Evaluate it against the window size during the walk and merge the refinement when it matches. +An index condition works the same way. The retained tree stores children in order, and the +walk visits each child with its index and the child count. `first`, `last`, `odd`, `even` and +`only` are tests on those two numbers, so they need no selector engine. A list mutation +changes the numbers, and the next frame re-evaluates them, the same way a resize re-evaluates +a media condition. + ### Delete `apply_styles` The 307-line function becomes the private body of `resolve`, converted to write into a @@ -739,18 +754,41 @@ Every variant becomes a `Condition`: | `group-active/name:` | `{ kind: "group", name, state: "active" }` | | `sm: md: lg:` | `{ kind: "media", query }` | | `max-lg: min-lg:` | `{ kind: "media", query }` | +| `first:` `last:` `only:` | `{ kind: "index", test }` | +| `odd:` `even:` | `{ kind: "index", test }` | An unnamed group uses `""`. Flatten `dark:` at resolve time from an `appearance: "dark" | "light"` option. Key the cache by appearance and clear it when the value flips. -Warn once and drop: `group-focus:`, `first:`, `last:`, `odd:`, `even:`, `has-`, `peer-*`, -`*`, `**`, `motion-safe:`, `print:`. +### Child conditions + +`space-x-*`, `divide-*` and the `*:` variant compile to a selector on the children. +`space-x-*` and `divide-*` produce `:where(& > :not(:last-child))`, and `*:` produces +`& > *`. The class sits on the parent, and the declarations apply to the children. + +`Condition::Children { except_last: bool }` holds them on the parent. The walk already +carries an environment down the tree (`Inherited`). The parent pushes the refinement there, +and each child that matches merges it before its own refinement. `:where()` has specificity +zero, so the child's own declarations must win, and this merge order gives exactly that. +`**:` targets every descendant, so its refinement stays in the environment for the whole +subtree. -`group-focus:` is dropped for a concrete reason. GPUI has `group_hover` (`div.rs:816`) and -`group_active` (`div.rs:1509`) but no `group_focus`. Supporting it means either tracking focus -state per group in GPUIX, or adding the method upstream. Both are follow-ups. +### What still drops, and why + +The target is the whole of Tailwind, because the goal of this plan is CSS, and Tailwind emits +CSS. A variant the plan cannot build yet gets a named follow-up with its prerequisite, never a +permanent drop. Until its follow-up ships, the resolver warns once and drops: `group-focus:`, +`peer-*`, `has-*`, `motion-safe:`, `print:`. + +| Variant | Prerequisite | +| --- | --- | +| `group-focus:` | GPUI has `group_hover` (`div.rs:816`) and `group_active` (`div.rs:1509`) but no `group_focus`. Track focus per group in GPUIX, or add the method upstream. | +| `peer-*` | The hover or focus state of an earlier sibling. The walk visits siblings in order, so it can carry the state of the peers it already passed. It needs the same state store as `group-focus:`. | +| `has-*` | The state of a descendant, which the walk has not reached yet. Read the state of the last frame, one frame late. A browser pays a comparable invalidation pass. | +| `motion-safe:` | The OS reduce-motion setting. GPUIX does not read it today. | +| `print:` | A print target. GPUIX does not print. | `group-hover` is Tailwind's spelling, but the meaning is plain CSS: an ancestor in `:hover` plus a descendant combinator. GPUI has `group(name)` and `group_hover(name, f)` natively. The @@ -767,7 +805,8 @@ unknown classes and declarations that no GPUI style can hold, so a test asserts List these in the package README as well. -- No specificity and no selector engine. Precedence is flat and last write wins. +- No specificity and no selector engine. Precedence is flat and last write wins. The + structural pseudo-classes do not need one. The walk reads the child index. - `calc()` cannot mix a percentage with a length. - A gradient has at most two colour stops. No radial or conic gradients. - No `radial-gradient()`, `conic-gradient()`, `env()`, `attr()` or `image-set()`. @@ -776,8 +815,7 @@ List these in the package README as well. - No `text-decoration`, no `z-index`. - `border` sets `border-width` only. There is no border style beyond a solid fill. - Percentage padding, margin and inset work. Percentage border width does not. -- Utilities that need a child or sibling selector do nothing. `space-x-*` and `divide-*` compile - to `:where(.x > :not(:last-child))`, which has no meaning without a selector engine. +- `peer-*` and `has-*` wait on sibling and descendant state. See "What still drops, and why". - Variant nesting is one level deep. - `style` keeps `hover` and `active`, which a CSS style attribute cannot express. They predate this plan. They gain no siblings, and they are candidates for removal in the release that From 47dfc8b054c5460337456f87ddc7ba092d7c6e9b Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Fri, 28 Aug 2026 21:12:01 +0200 Subject: [PATCH 24/29] feat(native): index and child selector conditions from a class --- packages/native/src/renderer.rs | 10 +- packages/native/src/renderer/frame.rs | 165 ++++++++++++++++++++--- packages/native/src/style.rs | 42 +++++- packages/native/src/style/resolve.rs | 183 +++++++++++++++++++++++++- 4 files changed, 374 insertions(+), 26 deletions(-) diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index bf6d8d23..7cda0726 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -2797,8 +2797,12 @@ impl GpuixView { highlight, highlights: &mut self.highlights, highlight_events: &mut highlight_events, + direct_rules: Vec::new(), + descendant_rules: Vec::new(), }; - let child = build_element(expected_child_id, &mut build_ctx, window, cx); + // A virtual row builds outside the tree walk, so it has no child + // position and the index states do not apply to it. + let child = build_element(expected_child_id, None, &mut build_ctx, window, cx); emit_highlight_events(&callback, &highlight_events); if motion_active { window.request_animation_frame(); @@ -3038,8 +3042,10 @@ impl gpui::Render for GpuixView { highlight: None, highlights: &mut self.highlights, highlight_events: &mut highlight_events, + direct_rules: Vec::new(), + descendant_rules: Vec::new(), }; - build_element(root_id, &mut ctx, window, cx) + build_element(root_id, None, &mut ctx, window, cx) } None => gpui::Empty.into_any_element(), }; diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index d6c2c48d..9b4aab33 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -48,12 +48,24 @@ pub(super) struct BuildCtx<'a> { /// would re-enter the build and emit again. They are flushed once the root /// build has returned. pub highlight_events: &'a mut Vec<(u64, usize)>, + /// Rules the parent puts on its direct children, `& > *` and + /// `& > :not(:last-child)`. They reach one depth only, so every element + /// swaps in its own set, possibly empty, before it builds its children. + /// The `bool` is the except-last flag. + pub direct_rules: Vec<(bool, std::sync::Arc)>, + /// Rules for a whole subtree, `& *`. Pushed going down, cut back on + /// return, so an element sees the rules of every ancestor above it. + pub descendant_rules: Vec>, } // ── Element builders ───────────────────────────────────────────────── pub(super) fn build_element( id: u64, + // The child index and the child count under the parent, for the index + // states. `None` at the root and under a virtual list, whose rows build + // outside this walk. + position: Option<(usize, usize)>, ctx: &mut BuildCtx, window: &mut gpui::Window, cx: &mut gpui::Context, @@ -127,11 +139,11 @@ pub(super) fn build_element( let built = match element.element_type.as_str() { "div" => { ctx.custom_registry.destroy(id); - build_div(element, style, resolved.clone(), motion.as_ref(), ctx, window, cx) + build_div(element, style, resolved.clone(), motion.as_ref(), position, ctx, window, cx) } "text" => { ctx.custom_registry.destroy(id); - build_text(element, style, resolved.clone(), motion.as_ref(), ctx, window, cx) + build_text(element, style, resolved.clone(), motion.as_ref(), position, ctx, window, cx) } "virtual-list" => { ctx.custom_registry.destroy(id); @@ -152,13 +164,24 @@ pub(super) fn build_element( declared }); let style = animated.as_ref().or(style); - let custom_children: Vec = element + // A custom element renders its own box, so a parent's direct + // child rules stop here, and its children start a new depth. + let saved_direct = std::mem::take(&mut ctx.direct_rules); + let present: Vec = element .children .iter() .copied() .filter(|child_id| ctx.tree.elements.contains_key(child_id)) - .map(|child_id| build_element(child_id, ctx, window, cx)) .collect(); + let count = present.len(); + let custom_children: Vec = present + .into_iter() + .enumerate() + .map(|(index, child_id)| { + build_element(child_id, Some((index, count)), ctx, window, cx) + }) + .collect(); + ctx.direct_rules = saved_direct; let cascade = ctx.cascade.clone(); let render_ctx = CustomRenderContext { id, @@ -351,6 +374,7 @@ pub(crate) fn build_div( style: Option<&StyleDesc>, resolved: Option>, motion: Option<&crate::motion::MotionFrame>, + position: Option<(usize, usize)>, ctx: &mut BuildCtx, window: &mut gpui::Window, cx: &mut gpui::Context, @@ -360,11 +384,13 @@ pub(crate) fn build_div( let element_id_str = format!("__gpuix_{}", element.id); let mut el = gpui::div().id(gpui::SharedString::from(element_id_str)); - if let Some(resolved) = resolved { + el = apply_child_rules(el, position, ctx); + + if let Some(resolved) = resolved.as_ref() { el = crate::style::resolve::apply_resolved(el, &resolved.base); - // State pseudo-classes. GPUI evaluates these itself, so none of them - // waits for React. Each takes a closure that receives a + // State pseudo-classes. GPUI evaluates hover and active itself, so + // neither waits for React. Each takes a closure that receives a // StyleRefinement and returns it, and the closure has to be 'static, // so each one holds a clone of the shared resolved style. // @@ -376,6 +402,20 @@ pub(crate) fn build_div( // whole refinement per state to read a one-byte tag. let states: Vec = resolved.states.iter().map(|(state, _)| *state).collect(); for state in states { + // An index state is a fact of the child position, decided here + // rather than through a GPUI variant. Without a position (the + // root, a virtual list row) there is nothing to decide against, + // so it does not apply. + if state.is_index() { + let holds = + position.is_some_and(|(index, count)| state.holds_at(index, count)); + if holds { + if let Some(declared) = resolved.state(state) { + el = crate::style::resolve::apply_resolved(el, declared); + } + } + continue; + } let held = resolved.clone(); let apply = move |refinement: gpui::StyleRefinement| match held.state(state) { Some(declared) => crate::style::resolve::apply_resolved(refinement, declared), @@ -384,6 +424,7 @@ pub(crate) fn build_div( el = match state { State::Hover => el.hover(apply), State::Active => el.active(apply), + State::First | State::Last | State::Odd | State::Even | State::Only => el, }; } } @@ -705,20 +746,92 @@ pub(crate) fn build_div( el = el.child(text_content(element, content, ctx)); } - // Children - let child_ids: Vec = element.children.clone(); - for child_id in child_ids { - let child = build_element(child_id, ctx, window, cx); + // Children. The parent's direct child rules reach this depth only, so + // the element swaps in its own set here, and its subtree rules join the + // descendant stack until the loop returns. + let (saved_direct, pushed) = push_child_rules(resolved.as_deref(), ctx); + let child_ids: Vec = element + .children + .iter() + .copied() + .filter(|child_id| ctx.tree.elements.contains_key(child_id)) + .collect(); + let count = child_ids.len(); + for (index, child_id) in child_ids.into_iter().enumerate() { + let child = build_element(child_id, Some((index, count)), ctx, window, cx); el = if overflow_x_only { el.child(gpui::div().flex_none().child(child)) } else { el.child(child) }; } + pop_child_rules(saved_direct, pushed, ctx); el.into_any_element() } +/// Merge the rules ancestors put on this element, under its own declarations. +/// +/// `:where()` has specificity zero, so these run before the element's own +/// refinement, and the element's own set fields win. Descendant rules come +/// first, then the parent's direct rules, so the nearer declaration wins a +/// conflict between the two. +fn apply_child_rules( + mut el: E, + position: Option<(usize, usize)>, + ctx: &BuildCtx, +) -> E { + for refinement in &ctx.descendant_rules { + el = crate::style::resolve::apply_resolved(el, refinement); + } + let Some((index, count)) = position else { + return el; + }; + let last = index + 1 == count; + for (except_last, refinement) in &ctx.direct_rules { + if *except_last && last { + continue; + } + el = crate::style::resolve::apply_resolved(el, refinement); + } + el +} + +/// Install this element's child rules for the walk below it. +/// +/// Returns the parent's direct rules to restore, and how many descendant +/// rules to cut back, both through `pop_child_rules`. +fn push_child_rules( + resolved: Option<&crate::style::resolve::Resolved>, + ctx: &mut BuildCtx, +) -> (Vec<(bool, std::sync::Arc)>, usize) { + use crate::style::resolve::ChildScope; + let mut direct = Vec::new(); + let mut pushed = 0; + let rules = resolved.map(|resolved| resolved.children.as_slice()).unwrap_or(&[]); + for (which, refinement) in rules { + match which { + ChildScope::All => direct.push((false, refinement.clone())), + ChildScope::ExceptLast => direct.push((true, refinement.clone())), + ChildScope::Descendants => { + ctx.descendant_rules.push(refinement.clone()); + pushed += 1; + } + } + } + (std::mem::replace(&mut ctx.direct_rules, direct), pushed) +} + +fn pop_child_rules( + saved_direct: Vec<(bool, std::sync::Arc)>, + pushed: usize, + ctx: &mut BuildCtx, +) { + ctx.direct_rules = saved_direct; + let keep = ctx.descendant_rules.len() - pushed; + ctx.descendant_rules.truncate(keep); +} + /// A selectable text run owned by `element`. Runs are left to gpui so the /// text keeps inheriting colour, weight and family from ancestor styles. /// @@ -758,6 +871,7 @@ pub(crate) fn build_text( style: Option<&StyleDesc>, resolved: Option>, motion: Option<&crate::motion::MotionFrame>, + position: Option<(usize, usize)>, ctx: &mut BuildCtx, window: &mut gpui::Window, cx: &mut gpui::Context, @@ -767,7 +881,12 @@ pub(crate) fn build_text( // Fast path: plain text leaf without style. It still goes through // `text_content` so the glyphs land in the selection registry — the old // raw-string return was the reason text was not selectable. - if style.is_none() && motion.is_none() && element.children.is_empty() { + if style.is_none() + && motion.is_none() + && element.children.is_empty() + && ctx.direct_rules.is_empty() + && ctx.descendant_rules.is_empty() + { let content = element.content.clone().unwrap_or_default(); return gpui::div() .relative() @@ -780,8 +899,16 @@ pub(crate) fn build_text( // text-only subset, so `padding`, `width` and every layout prop on a text // node were silently dropped — a hole with no error and no warning. let mut el = gpui::div(); + el = apply_child_rules(el, position, ctx); if let Some(resolved) = resolved.as_ref() { el = crate::style::resolve::apply_resolved(el, &resolved.base); + for (state, declared) in &resolved.states { + if state.is_index() + && position.is_some_and(|(index, count)| state.holds_at(index, count)) + { + el = crate::style::resolve::apply_resolved(el, declared); + } + } } if let Some(motion) = motion { el = crate::style::resolve::apply_motion(el, motion, style); @@ -798,10 +925,18 @@ pub(crate) fn build_text( el = el.child(text_content(element, content, ctx)); } - let child_ids: Vec = element.children.clone(); - for child_id in child_ids { - el = el.child(build_element(child_id, ctx, window, cx)); + let (saved_direct, pushed) = push_child_rules(resolved.as_deref(), ctx); + let child_ids: Vec = element + .children + .iter() + .copied() + .filter(|child_id| ctx.tree.elements.contains_key(child_id)) + .collect(); + let count = child_ids.len(); + for (index, child_id) in child_ids.into_iter().enumerate() { + el = el.child(build_element(child_id, Some((index, count)), ctx, window, cx)); } + pop_child_rules(saved_direct, pushed, ctx); el.into_any_element() } diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index eeab9c64..57fb8217 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -491,31 +491,52 @@ style_desc! { // Pseudo-selector styles, applied by GPUI natively (no JS round-trip). // Uses Box to avoid infinite-size struct (StyleDesc contains StyleDesc). // - // These two are the only conditions `style` carries, and they are here for - // history. A CSS `style` attribute holds declarations, not selectors. Any - // further condition belongs in a class, not here. + // These two named fields are here for history. A CSS `style` attribute + // holds declarations, not selectors, so the style prop gets no further + // condition. A class resolver sends every other condition through + // `selectors` below. hover: Option> = "hover", active: Option> = "active", + + // Conditioned blocks from a class resolver. The `style` prop type does + // not carry this field, because a style attribute cannot hold a selector. + selectors: Option> = "selectors", } pub use crate::color::{parse_color, parse_color_hex}; +/// One conditioned block from a class resolver. +/// +/// `on` is a canonical selector spelling, and `Selector::parse` names the +/// closed set. An entry with a spelling outside it warns once and drops. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SelectorRule { + pub on: String, + pub style: Box, +} + impl StyleDesc { /// The state blocks this style declares, in specification order. /// /// This is the one place that knows the `style` prop spells its states as - /// named fields. When the class channel lands, states arrive as parsed - /// selectors instead, and only this function changes. + /// named fields. The class channel sends states as parsed selectors in + /// `selectors`, and those follow the named fields here. pub(crate) fn states( &self, ) -> impl Iterator { - use crate::style::resolve::State; + use crate::style::resolve::{Selector, State}; [ (State::Hover, self.hover.as_deref()), (State::Active, self.active.as_deref()), ] .into_iter() .filter_map(|(state, declared)| declared.map(|declared| (state, declared))) + .chain(self.selectors.iter().flatten().filter_map(|rule| { + match Selector::parse(&rule.on) { + Some(Selector::State(state)) => Some((state, rule.style.as_ref())), + _ => None, + } + })) } } @@ -607,6 +628,15 @@ mod tests { assert!(should_occlude(&with_fill("not-a-color"))); } + #[test] + fn selectors_read_from_json() { + let json = r#"{ "selectors": [{ "on": ":first-child", "style": { "color": "red" } }] }"#; + let style: StyleDesc = serde_json::from_str(json).unwrap(); + let rules = style.selectors.unwrap(); + assert_eq!(rules[0].on, ":first-child"); + assert_eq!(rules[0].style.color.as_deref(), Some("red")); + } + #[test] fn every_name_the_writer_uses_is_a_name_the_reader_knows() { let written = serde_json::to_value(StyleDesc::default()).unwrap(); diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index cf0b2dd5..af5fb21e 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -41,9 +41,12 @@ pub(crate) fn reset_resolutions() { /// One state pseudo-class, which is one kind of condition. /// -/// GPUI evaluates these itself at paint, with no re-render and no second -/// resolve, so a pointer moving over an element costs nothing in this crate. -/// That is why states live beside the resolved style rather than inside it. +/// GPUI evaluates `Hover` and `Active` itself at paint, with no re-render and +/// no second resolve, so a pointer moving over an element costs nothing in +/// this crate. The index states have no GPUI counterpart. The walk knows the +/// child index and the child count when it builds an element, so it merges an +/// index refinement in place, and a list mutation re-evaluates it on the next +/// frame the way a resize re-evaluates a media condition. /// /// Conditions are an open set. Adding `:focus` is one variant here and one arm /// at the paint site, not a new field on every resolution in the tree. @@ -51,6 +54,82 @@ pub(crate) fn reset_resolutions() { pub(crate) enum State { Hover, Active, + First, + Last, + Odd, + Even, + Only, +} + +impl State { + /// Whether the child position decides this state. + pub(crate) fn is_index(self) -> bool { + !matches!(self, State::Hover | State::Active) + } + + /// Whether this index state holds at a child position. + /// + /// `index` is zero based. `:nth-child` counts from one, so the first + /// child is odd. + pub(crate) fn holds_at(self, index: usize, count: usize) -> bool { + match self { + State::Hover | State::Active => false, + State::First => index == 0, + State::Last => index + 1 == count, + State::Odd => index % 2 == 0, + State::Even => index % 2 == 1, + State::Only => count == 1, + } + } +} + +/// Which children of the declaring element a child rule styles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChildScope { + /// `& > *`: every direct child. + All, + /// `& > :not(:last-child)`: every direct child except the last, the + /// selector `space-x-*` and `divide-*` compile to. + ExceptLast, + /// `& *`: every element below, at any depth. + Descendants, +} + +/// What one `selectors` entry means. +pub(crate) enum Selector { + State(State), + Children(ChildScope), +} + +impl Selector { + /// Read the selector text the class resolver produced. + /// + /// The set is closed. The resolver writes these canonical spellings, and + /// anything else warns once and drops, so a typo never fails silently. + pub(crate) fn parse(on: &str) -> Option { + Some(match on { + ":first-child" => Selector::State(State::First), + ":last-child" => Selector::State(State::Last), + ":nth-child(odd)" => Selector::State(State::Odd), + ":nth-child(even)" => Selector::State(State::Even), + ":only-child" => Selector::State(State::Only), + "& > *" => Selector::Children(ChildScope::All), + "& > :not(:last-child)" => Selector::Children(ChildScope::ExceptLast), + "& *" => Selector::Children(ChildScope::Descendants), + _ => return None, + }) + } +} + +/// Warn about a selector the engine does not know, once per spelling. +pub(crate) fn warn_unknown_selector(on: &str) { + use std::collections::HashSet; + use std::sync::Mutex; + static WARNED: Mutex>> = Mutex::new(None); + let mut warned = WARNED.lock().unwrap(); + if warned.get_or_insert_with(HashSet::new).insert(on.to_owned()) { + log::warn!("unknown selector {on:?} in a style, dropped"); + } } /// A `StyleDesc` with every value turned into a GPUI value. @@ -68,6 +147,12 @@ pub(crate) struct Resolved { /// carried the full size of a refinement each whether or not anything used /// them. pub states: Vec<(State, StyleRefinement)>, + /// Rules this element puts on its children, from selectors such as + /// `& > :not(:last-child)`. The refinements sit behind an `Arc` because + /// the walk hands them down to every child in the scope, and a child + /// applies them under its own declarations, the zero specificity of + /// `:where()`. + pub children: Vec<(ChildScope, std::sync::Arc)>, /// The cascade this resolution read, or `None` when it read nothing /// inherited. /// @@ -87,9 +172,21 @@ impl Resolved { .states() .map(|(state, declared)| (state, resolve(declared, &scope))) .collect(); + let mut children = Vec::new(); + for rule in style.selectors.iter().flatten() { + match Selector::parse(&rule.on) { + Some(Selector::Children(which)) => { + children.push((which, std::sync::Arc::new(resolve(&rule.style, &scope)))); + } + // `states()` already read these. + Some(Selector::State(_)) => {} + None => warn_unknown_selector(&rule.on), + } + } Self { base, states, + children, cascade: scope.used_a_variable().then(|| cascade.clone()), } } @@ -664,6 +761,86 @@ mod tests { ); } + #[test] + fn index_selectors_resolve_as_states() { + use crate::style::SelectorRule; + let rule = |on: &str, color: &str| SelectorRule { + on: on.to_string(), + style: styled(color), + }; + let style = StyleDesc { + selectors: Some(vec![ + rule(":first-child", "#ff0000"), + rule(":last-child", "#00ff00"), + rule(":nth-child(odd)", "#0000ff"), + rule(":nth-child(even)", "#ffff00"), + rule(":only-child", "#00ffff"), + ]), + ..Default::default() + }; + let cascade = no_variables(); + let resolved = Resolved::build(&style, &cascade); + assert_eq!( + resolved.states.iter().map(|(s, _)| *s).collect::>(), + vec![State::First, State::Last, State::Odd, State::Even, State::Only] + ); + let plain = cascade.scope(); + assert_eq!( + resolved.state(State::First), + Some(&resolve(&styled("#ff0000"), &plain)) + ); + assert!(resolved.children.is_empty()); + } + + #[test] + fn child_selectors_resolve_as_child_rules_and_unknown_ones_drop() { + use crate::style::SelectorRule; + let rule = |on: &str, color: &str| SelectorRule { + on: on.to_string(), + style: styled(color), + }; + let style = StyleDesc { + selectors: Some(vec![ + rule("& > *", "#ff0000"), + rule("& > :not(:last-child)", "#00ff00"), + rule("& *", "#0000ff"), + rule(":focus", "#ffffff"), + ]), + ..Default::default() + }; + let resolved = Resolved::build(&style, &no_variables()); + assert_eq!( + resolved.children.iter().map(|(scope, _)| *scope).collect::>(), + vec![ChildScope::All, ChildScope::ExceptLast, ChildScope::Descendants] + ); + // `:focus` is not in the closed set yet: it warns once and drops, + // and it must not leak into the states either. + assert!(resolved.states.is_empty()); + } + + #[test] + fn an_index_state_reads_the_child_position() { + let table = [ + (State::First, 0, 3, true), + (State::First, 1, 3, false), + (State::Last, 2, 3, true), + (State::Last, 1, 3, false), + // `:nth-child` counts from one, so the first child is odd. + (State::Odd, 0, 3, true), + (State::Odd, 1, 3, false), + (State::Even, 1, 3, true), + (State::Even, 2, 3, false), + (State::Only, 0, 1, true), + (State::Only, 0, 2, false), + ]; + for (state, index, count, holds) in table { + assert_eq!(state.holds_at(index, count), holds, "{state:?} at {index} of {count}"); + assert!(state.is_index()); + } + assert!(!State::Hover.is_index()); + assert!(!State::Active.is_index()); + } + #[test] fn a_gradient_image_wins_over_the_colour() { let style = StyleDesc { From 3580b3dff95c9530fba9ea5bbd113f3b8fa3fdb9 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Fri, 28 Aug 2026 21:12:01 +0200 Subject: [PATCH 25/29] feat(react): selectors field on StyleDesc, merged per rule --- .changeset/index-and-child-selectors.md | 27 +++ .../react/src/__tests__/class-names.test.tsx | 183 +++++++++++++++++- .../src/__tests__/host-config-style.test.tsx | 73 ++++++- packages/react/src/reconciler/class-names.ts | 35 +++- packages/react/src/reconciler/host-config.ts | 7 +- packages/react/src/types/host.ts | 31 ++- 6 files changed, 345 insertions(+), 11 deletions(-) create mode 100644 .changeset/index-and-child-selectors.md diff --git a/.changeset/index-and-child-selectors.md b/.changeset/index-and-child-selectors.md new file mode 100644 index 00000000..1ac6c5f7 --- /dev/null +++ b/.changeset/index-and-child-selectors.md @@ -0,0 +1,27 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Index and child selector conditions from a class + +A class resolver can now return `selectors`, a list of `{ on, style }` rules. +The spellings form a closed set. `:first-child`, `:last-child`, +`:nth-child(odd)`, `:nth-child(even)` and `:only-child` read the position of +the element among its siblings. The walk knows that position at build time, so +they cost no event and no measurement. `& > *` and `& > :not(:last-child)` sit +on the parent and style its direct children, which is what `space-y-*` and +`divide-*` compile to. `& *` reaches the whole subtree. An unknown spelling +warns once and drops. + +Every rule applies with specificity zero, as `:where()` does on the web. A +declaration the child makes itself wins over a rule from the parent, and the +`style` prop wins over an index rule key by key. Two tokens on the same +selector merge into one rule, the later token winning. The `style` prop type +excludes `selectors`, because a style attribute holds declarations, not +selectors. + +Two places sit outside the tree walk. A virtual-list row builds on its own, so +it has no child position and the index conditions do not apply to it. A custom +element resolves its own style, so the rules of a parent stop at its border. +Verified on macOS. The Windows and Linux paths did not run here. diff --git a/packages/react/src/__tests__/class-names.test.tsx b/packages/react/src/__tests__/class-names.test.tsx index 9816aef7..ad363815 100644 --- a/packages/react/src/__tests__/class-names.test.tsx +++ b/packages/react/src/__tests__/class-names.test.tsx @@ -10,7 +10,7 @@ import React from "react" import { beforeAll, describe, expect, it } from "vitest" import { createTestRoot, hasNativeTestRenderer } from "../testing.js" import { expectScreenshotsEqual, SHOTS_DIR } from "./test-utils.js" -import type { ClassNameResolver } from "../types/host.js" +import type { ClassNameResolver, StyleDesc } from "../types/host.js" const describeNative = hasNativeTestRenderer ? describe : describe.skip @@ -97,3 +97,184 @@ describeNative("className", () => { expect(renderer.styleResolutions()).toBe(0) }) }) + +/// The index and child conditions, painted. Each test renders the class form +/// and the same picture written as inline styles, and compares the pixels. +const CONDITIONS: Record = { + stack: { width: 200, height: 160, display: "flex", flexDirection: "column" }, + cell: { height: 30, backgroundColor: "#222222" }, + "first-red": { + selectors: [{ on: ":first-child", style: { backgroundColor: "#ff0000" } }], + }, + "last-blue": { + selectors: [{ on: ":last-child", style: { backgroundColor: "#0000ff" } }], + }, + "odd-red": { + selectors: [{ on: ":nth-child(odd)", style: { backgroundColor: "#ff0000" } }], + }, + "even-blue": { + selectors: [{ on: ":nth-child(even)", style: { backgroundColor: "#0000ff" } }], + }, + spaced: { + selectors: [{ on: "& > :not(:last-child)", style: { marginBottom: 10 } }], + }, + "kids-green": { + selectors: [{ on: "& > *", style: { backgroundColor: "#00ff00" } }], + }, + "deep-green": { + selectors: [{ on: "& *", style: { backgroundColor: "#00ff00" } }], + }, +} + +const resolveCondition: ClassNameResolver = (token) => CONDITIONS[token] ?? null + +function paintConditions(name: string, tree: React.ReactElement, withResolver = true) { + const root = createTestRoot(withResolver ? { resolveClassName: resolveCondition } : {}) + root.render(tree) + root.renderer.captureScreenshot(shot(name)) + root.unmount() +} + +describeNative("selector conditions", () => { + const STACK = CONDITIONS.stack as Record + const CELL = { height: 30 } as const + + it("paints first and last from the child position", () => { + paintConditions( + "index", +
+
+
+
+
+ ) + paintConditions( + "index-direct", +
+
+
+
+
, + false + ) + expectScreenshotsEqual(shot("index"), shot("index-direct")) + }) + + it("stripes odd and even, counting from one", () => { + paintConditions( + "stripes", +
+
+
+
+
+ ) + paintConditions( + "stripes-direct", +
+
+
+
+
, + false + ) + expectScreenshotsEqual(shot("stripes"), shot("stripes-direct")) + }) + + it("re-evaluates the position when the list changes", () => { + const root = createTestRoot({ resolveClassName: resolveCondition }) + const rows = (count: number) => ( +
+ {Array.from({ length: count }, (_, at) => ( +
+ ))} +
+ ) + root.render(rows(2)) + root.render(rows(3)) + root.renderer.captureScreenshot(shot("grown")) + root.unmount() + + paintConditions( + "grown-direct", +
+
+
+
+
, + false + ) + expectScreenshotsEqual(shot("grown"), shot("grown-direct")) + }) + + it("spaces every child except the last from a rule on the parent", () => { + paintConditions( + "spaced", +
+
+
+
+
+ ) + paintConditions( + "spaced-direct", +
+
+
+
+
, + false + ) + expectScreenshotsEqual(shot("spaced"), shot("spaced-direct")) + }) + + it("lets a child's own declaration beat a rule from the parent", () => { + // `& > *` compiles from `:where()`, which has specificity zero. The first + // child declares no background, so the rule paints it. The second and the + // third declare their own, through a class and through the style prop, and + // each keeps it. + paintConditions( + "kids", +
+
+
+
+
+ ) + paintConditions( + "kids-direct", +
+
+
+
+
, + false + ) + expectScreenshotsEqual(shot("kids"), shot("kids-direct")) + }) + + it("reaches a grandchild through a descendant rule", () => { + // The wrapper declares no background, so the rule paints it. The cell + // declares its own, which wins. + paintConditions( + "deep", +
+
+
+
+
+
+ ) + paintConditions( + "deep-direct", +
+
+
+
+
+
, + false + ) + expectScreenshotsEqual(shot("deep"), shot("deep-direct")) + }) +}) diff --git a/packages/react/src/__tests__/host-config-style.test.tsx b/packages/react/src/__tests__/host-config-style.test.tsx index 548321f7..ae25c318 100644 --- a/packages/react/src/__tests__/host-config-style.test.tsx +++ b/packages/react/src/__tests__/host-config-style.test.tsx @@ -19,6 +19,7 @@ import type { HostContext, NativeRenderer, Props, + StyleDesc, } from "../types/host" interface StyleCall { @@ -119,7 +120,7 @@ describe("host config style routing", () => { }) /// A resolver over a fixed table, counting what it was asked. -function tableResolver(table: Record) { +function tableResolver(table: Record) { const asked: string[] = [] const resolve: ClassNameResolver = (token) => { asked.push(token) @@ -229,6 +230,76 @@ describe("className", () => { expect(last?.visibility).toBeUndefined() }) + it("merges two tokens on the same selector into one rule", () => { + const { resolve } = tableResolver({ + "first-pad": { selectors: [{ on: ":first-child", style: { padding: 8 } }] }, + "first-red": { + selectors: [{ on: ":first-child", style: { backgroundColor: "#ff0000" } }], + }, + spaced: { + selectors: [{ on: "& > :not(:last-child)", style: { marginBottom: 4 } }], + }, + }) + const props = { className: "first-pad first-red spaced" } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ + selectors: [ + { on: ":first-child", style: { padding: 8, backgroundColor: "#ff0000" } }, + { on: "& > :not(:last-child)", style: { marginBottom: 4 } }, + ], + }) + }) + + it("lets the style prop beat an index selector, and leaves child rules alone", () => { + // `:first-child` styles this element, so a key the style prop sets goes. + // `& > *` styles the children, and the inline style of this element says + // nothing about those. + const { resolve } = tableResolver({ + "first-red": { + selectors: [ + { on: ":first-child", style: { backgroundColor: "#ff0000", padding: 8 } }, + ], + }, + "children-red": { + selectors: [{ on: "& > *", style: { backgroundColor: "#ff0000" } }], + }, + }) + const props = { + className: "first-red children-red", + style: { backgroundColor: "#0000ff" }, + } + const { renderer, instance } = setup(props, resolve) + + hostConfig.commitUpdate(instance, "div", {}, props, null) + + expect(lastStyle(renderer)).toEqual({ + backgroundColor: "#0000ff", + selectors: [ + { on: ":first-child", style: { padding: 8 } }, + { on: "& > *", style: { backgroundColor: "#ff0000" } }, + ], + }) + }) + + it("drops the selector rules while the element is hidden", () => { + // An index selector that sets `visibility` would otherwise paint an + // element React asked to hide, the same hole `hover` had. + const { resolve } = tableResolver({ + "first-peek": { + padding: 16, + selectors: [{ on: ":first-child", style: { visibility: "visible" } }], + }, + }) + const { renderer, instance } = setup({ className: "first-peek" }, resolve) + + hostConfig.hideInstance(instance) + + expect(lastStyle(renderer)).toEqual({ padding: 16, visibility: "hidden" }) + }) + it("drops the hover style of a class while the element is hidden", () => { // A hover style that sets `visibility` would otherwise paint an element // React asked to hide. diff --git a/packages/react/src/reconciler/class-names.ts b/packages/react/src/reconciler/class-names.ts index dc3984e3..28b1d48c 100644 --- a/packages/react/src/reconciler/class-names.ts +++ b/packages/react/src/reconciler/class-names.ts @@ -15,6 +15,7 @@ import type { ClassNameCache, ClassNameResolver, + SelectorRule, StyleDeclarations, StyleDesc, } from "../types/host.js" @@ -84,11 +85,12 @@ type Mutable = Record function mergeInto(target: Mutable, source: StyleDesc): void { for (const [key, value] of Object.entries(source)) { - if (key === "hover" || key === "active") continue + if (key === "hover" || key === "active" || key === "selectors") continue target[key] = value } mergeState(target, "hover", source.hover) mergeState(target, "active", source.active) + mergeSelectors(target, source.selectors) } function mergeState( @@ -100,6 +102,23 @@ function mergeState( target[state] = { ...(target[state] as StyleDeclarations | undefined), ...source } } +/// Two tokens on the same selector merge into one rule, the way two +/// declarations in one CSS rule do, so `first:p-2 first:bg-red` sends one +/// `:first-child` block. +function mergeSelectors(target: Mutable, source: SelectorRule[] | undefined): void { + if (!source) return + const merged = [...((target.selectors as SelectorRule[] | undefined) ?? [])] + for (const rule of source) { + const at = merged.findIndex((held) => held.on === rule.on) + if (at === -1) { + merged.push({ on: rule.on, style: { ...rule.style } }) + } else { + merged[at] = { on: rule.on, style: { ...merged[at]!.style, ...rule.style } } + } + } + target.selectors = merged +} + /// The style prop laid over the style a class string declared. /// /// [CSS Style Attributes][spec] gives the attribute "a specificity higher than @@ -121,12 +140,26 @@ export function withInlineStyle( const active = fromClass.active ? { ...(fromClass.active as Mutable) } : undefined if (hover) merged.hover = hover if (active) merged.active = active + const selectors = fromClass.selectors?.map((rule) => ({ + on: rule.on, + style: { ...rule.style } as Mutable, + })) + if (selectors) merged.selectors = selectors for (const [key, value] of Object.entries(inline)) { if (key === "hover" || key === "active") continue merged[key] = value if (hover) delete hover[key] if (active) delete active[key] + // The style prop outranks every selector, so a key it sets leaves the + // conditioned blocks too. Only the element's own states though: a rule + // on the children (`& > *`) styles other elements, and the inline style + // of this one says nothing about those. + if (selectors) { + for (const rule of selectors) { + if (!rule.on.startsWith("&")) delete rule.style[key] + } + } } mergeState(merged, "hover", inline.hover) mergeState(merged, "active", inline.active) diff --git a/packages/react/src/reconciler/host-config.ts b/packages/react/src/reconciler/host-config.ts index 2afa9f85..8033763f 100644 --- a/packages/react/src/reconciler/host-config.ts +++ b/packages/react/src/reconciler/host-config.ts @@ -440,7 +440,12 @@ export const hostConfig = { // hidden. A hover style that sets `visibility` would otherwise paint an // element React asked to hide. const container = containerFor(instance) - const { hover: _hover, active: _active, ...base } = computeStyle(instance.props, container) + const { + hover: _hover, + active: _active, + selectors: _selectors, + ...base + } = computeStyle(instance.props, container) container.renderer.setStyle(instance.id, { ...base, visibility: "hidden" }) }, diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index c1b94762..aca395c0 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -267,12 +267,21 @@ export interface StyleDesc { // Pseudo-selector styles, applied by GPUI natively (no JS round-trip). // Nesting is one level deep: hover/active cannot contain hover/active. // - // These two are the only conditions `style` carries, and they are here for - // history. A CSS `style` attribute holds declarations, not selectors. Any - // further condition belongs in a class, not here. + // These two named fields are here for history. A CSS `style` attribute + // holds declarations, not selectors, so the style prop gets no further + // condition. A class resolver sends every other condition through + // `selectors` below. hover?: StyleDeclarations active?: StyleDeclarations + // Conditioned blocks from a class resolver. `on` takes a canonical + // selector spelling from the closed set the engine reads: + // `:first-child`, `:last-child`, `:nth-child(odd)`, `:nth-child(even)`, + // `:only-child` for the element's own position, and `& > *`, + // `& > :not(:last-child)`, `& *` for rules on its children. Anything + // else warns once in the engine and drops. + selectors?: SelectorRule[] + // Custom properties. A declaration here is in scope for `var()` on this // element and on everything below it, the same as in CSS. // @@ -283,7 +292,7 @@ export interface StyleDesc { } /** - * What `hover` and `active` may hold. + * What `hover`, `active` and a selector rule may hold. * * No nesting, and no custom properties. A declaration inside a state has * nothing to apply to, because the cascade reads variables from the element @@ -291,9 +300,15 @@ export interface StyleDesc { */ export type StyleDeclarations = Omit< StyleDesc, - "hover" | "active" | `--${string}` + "hover" | "active" | "selectors" | `--${string}` > +/** One conditioned block from a class resolver. */ +export interface SelectorRule { + on: string + style: StyleDeclarations +} + // Element types supported by GPUIX export type ElementType = | "div" @@ -482,7 +497,9 @@ export interface Props { // `DetailedHTMLProps` already carries `key`. Without this field every // `
` inside a `.map()` fails to typecheck. key?: React.Key | null - style?: StyleDesc + // A style attribute holds declarations, not selectors, so the prop cannot + // carry `selectors`. A class resolver is the only writer of that field. + style?: Omit /** * Class tokens, separated by spaces, read by the root's resolver. * @@ -570,7 +587,7 @@ export interface TextareaProps extends InputProps { type VirtualListShared = { // See the note on `Props.key`. key?: React.Key | null - style?: StyleDesc + style?: Omit children?: React.ReactNode ref?: React.Ref alignment?: "top" | "bottom" From bb33f83f7c664868478d716dd28196794a273d8d Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Fri, 28 Aug 2026 21:12:01 +0200 Subject: [PATCH 26/29] feat(demo): selector variants and a selectors panel --- examples/demo.test.tsx | 75 +++++++++++++++++++- examples/demo/app.tsx | 2 + examples/demo/classes.ts | 47 ++++++++++++- examples/demo/selectors.tsx | 134 ++++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 examples/demo/selectors.tsx diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 50bdea16..92bb1f62 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -18,6 +18,7 @@ import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" import { Motion } from "./demo/motion-panel" +import { Selectors } from "./demo/selectors" import { Variables } from "./demo/variables" import { resolveClassName } from "./demo/classes" @@ -35,6 +36,7 @@ const PANELS = [ ["variables", ], ["inheritance", ], ["classes", ], + ["selectors", ], ["motion", ], ] as const @@ -104,6 +106,77 @@ describeNative("a class and the style it stands for", () => { }) }) +describeNative("selector classes", () => { + const FRAME = { + ...BASE, + ...PALETTES.midnight, + width: "100%", + height: "100%", + backgroundColor: "var(--color-bg)", + } as const + + it("paints divide-y the same as borders written by hand", () => { + const viaClass = root() + viaClass.render( +
+
+
+
+
+
+
+ ) + viaClass.renderer.captureScreenshot(shot("divide")) + viaClass.unmount() + + const line = { borderBottomWidth: 1, borderColor: "var(--color-line)" } as const + const viaStyle = root() + viaStyle.render( +
+
+
+
+
+
+
+ ) + viaStyle.renderer.captureScreenshot(shot("divide-expected")) + viaStyle.unmount() + + expect(fs.readFileSync(shot("divide")).equals(fs.readFileSync(shot("divide-expected")))).toBe( + true + ) + }) + + it("paints last: on the row that is last right now", () => { + const viaClass = root() + viaClass.render( +
+
+
+
+
+
+ ) + viaClass.renderer.captureScreenshot(shot("last")) + viaClass.unmount() + + const viaStyle = root() + viaStyle.render( +
+
+
+
+
+
+ ) + viaStyle.renderer.captureScreenshot(shot("last-expected")) + viaStyle.unmount() + + expect(fs.readFileSync(shot("last")).equals(fs.readFileSync(shot("last-expected")))).toBe(true) + }) +}) + describeNative("height: auto", () => { const WORDS = "The measurement runs at the width the element really gets, so the same " + @@ -210,7 +283,7 @@ describeNative("the whole application", () => { test.render() expect(test.renderer.getPaintedText()).toContain("GPUIX") - for (const title of ["Lengths", "Variables", "Inheritance", "className", "Motion", "Performance", "Colours"]) { + for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Performance", "Colours"]) { const item = test.renderer.findByText(title) expect(item, `no sidebar item named ${title}`).toBeDefined() const bounds = test.renderer.getElementBounds(item!.id) diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx index 4c89e739..3ee49021 100644 --- a/examples/demo/app.tsx +++ b/examples/demo/app.tsx @@ -16,6 +16,7 @@ import { Inheritance } from "./inheritance.js" import { Lengths } from "./lengths.js" import { Motion } from "./motion-panel.js" import { frameOverlay, Perf } from "./perf.js" +import { Selectors } from "./selectors.js" import { Variables } from "./variables.js" /// The palette every panel reads. Exported so a test can mount one panel @@ -73,6 +74,7 @@ const SECTIONS = [ { id: "variables", title: "Variables", render: () => }, { id: "inheritance", title: "Inheritance", render: () => }, { id: "classes", title: "className", render: () => }, + { id: "selectors", title: "Selectors", render: () => }, { id: "motion", title: "Motion", render: () => }, ] as const diff --git a/examples/demo/classes.ts b/examples/demo/classes.ts index fb8b3509..a02c4262 100644 --- a/examples/demo/classes.ts +++ b/examples/demo/classes.ts @@ -153,6 +153,30 @@ function base(token: string): StyleDesc | null { } case "ring": return { borderWidth: 1, borderColor: color(value) ?? undefined } + case "space": { + // `space-y-2` puts a margin on every child except the last one. + const at = value.indexOf("-") + if (at !== 1) return null + const length = step(value.slice(2)) + if (!length) return null + const key = value[0] === "x" ? "marginRight" : value[0] === "y" ? "marginBottom" : null + if (!key) return null + return { selectors: [{ on: "& > :not(:last-child)", style: { [key]: length } }] } + } + case "divide": { + // `divide-y` puts a line under every child except the last one. + const key = + value === "x" ? "borderRightWidth" : value === "y" ? "borderBottomWidth" : null + if (!key) return null + return { + selectors: [ + { + on: "& > :not(:last-child)", + style: { [key]: 1, borderColor: "var(--color-line)" }, + }, + ], + } + } case "opacity": { const amount = Number(value) return Number.isFinite(amount) ? { opacity: amount / 100 } : null @@ -164,16 +188,37 @@ function base(token: string): StyleDesc | null { const STATES = ["hover", "active"] as const +/// The selector each variant prefix stands for, in the canonical spelling +/// the engine reads. +const SELECTORS: Record = { + first: ":first-child", + last: ":last-child", + odd: ":nth-child(odd)", + even: ":nth-child(even)", + only: ":only-child", + "*": "& > *", + "**": "& *", +} + /// The style one token declares, or `null` for a token this does not know. export function resolveClassName(token: string): StyleDesc | null { for (const state of STATES) { if (!token.startsWith(`${state}:`)) continue const inner = base(token.slice(state.length + 1)) - if (!inner) return null + if (!inner || inner.selectors) return null // A state holds no nesting and no custom properties, and `base` never // returns either, so this reads the same object under the narrower type. return state === "hover" ? { hover: inner as State } : { active: inner as State } } + const colon = token.indexOf(":") + if (colon > 0) { + const on = SELECTORS[token.slice(0, colon)] + if (!on) return null + const inner = base(token.slice(colon + 1)) + // One level deep, as with the states: a selector cannot hold a selector. + if (!inner || inner.selectors) return null + return { selectors: [{ on, style: inner as State }] } + } return base(token) } diff --git a/examples/demo/selectors.tsx b/examples/demo/selectors.tsx new file mode 100644 index 00000000..4885cba1 --- /dev/null +++ b/examples/demo/selectors.tsx @@ -0,0 +1,134 @@ +/// The index and child conditions. +/// +/// `first:`, `last:`, `odd:`, `even:` and `only:` read the position of the +/// element among its siblings. The walk knows that position at build time, so +/// no measurement and no event is involved. `*:` and `**:` sit on the parent +/// and style its children, the way `space-y-2` and `divide-y` do in Tailwind. +/// +/// Every rule here compiles as `:where()` does on the web, with specificity +/// zero. A declaration the child makes itself always wins over a rule from +/// the parent. + +import React, { useState } from "react" +import { Button, Grid, Panel, Row, Sample } from "./ui.js" + +const ROW = "px-3 py-2" + +function Names({ children }: { children: string[] }) { + return ( + <> + {children.map((name) => ( +
+ {name} +
+ ))} + + ) +} + +const FRUIT = ["apple", "pear", "plum", "fig"] + +function GrowingList() { + const [count, setCount] = useState(3) + return ( + + +