From e6d3b70ba5d69115bf57c7dc9960eabe84170eeb Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Tue, 25 Aug 2026 13:36:27 +0200 Subject: [PATCH 01/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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 11fe1ace63a3783f13bdb155506433034c5eb6bd Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:17:47 +0200 Subject: [PATCH 15/55] feat(native): ease the mix between two gradient stops --- .changeset/eased-gradients.md | 15 ++ examples/demo.test.tsx | 2 + examples/demo/gradients.tsx | 88 ++++++++++- packages/native/css/src/background.rs | 150 ++++++++++++++++++- packages/react/src/__tests__/styles.test.tsx | 24 +++ 5 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 .changeset/eased-gradients.md diff --git a/.changeset/eased-gradients.md b/.changeset/eased-gradients.md new file mode 100644 index 00000000..3060b72d --- /dev/null +++ b/.changeset/eased-gradients.md @@ -0,0 +1,15 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Ease the mix between two gradient stops. + +An `` between two colour stops bends the mix, following the +CSSWG proposal in csswg-drafts issue 1332: `linear-gradient(to top, black, +ease-in-out, transparent)`. `ease`, `ease-in`, `ease-out`, `ease-in-out` and +`cubic-bezier()` are read. The shader solves the curve per fragment, so an +eased scrim stays one quad. A straight fade to transparent looks dense near +the solid stop and thin near the clear one; an eased one reads as one smooth +fall-off. The Windows shader also catches up with eight stops and corner +shapes. diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 93cfd63e..7b579606 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -14,6 +14,7 @@ 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 { Gradients } from "./demo/gradients" import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" @@ -31,6 +32,7 @@ function root(): TestRoot { const PANELS = [ ["colors", ], + ["gradients", ], ["lengths", ], ["variables", ], ["inheritance", ], diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx index 702e73aa..876f51be 100644 --- a/examples/demo/gradients.tsx +++ b/examples/demo/gradients.tsx @@ -8,6 +8,77 @@ import React from "react" import { Grid, Panel, Sample, Swatch } from "./ui.js" +/// A sticky header over scrolling rows, with a progressive blur under it. +/// +/// The header box has `backdropFilter: blur() saturate()`, which blurs the +/// rows under it the way the iOS 26 navigation bar does, and a `maskImage` +/// gradient that fades that blur out toward the bottom of the box. The +/// easing on the mask keeps the fall-off smooth. There is no scrim, only a +/// faint tint at the top so the large title stays readable over bright +/// rows. `overscrollBehavior: "contain"` keeps the wheel inside the list, +/// so the page does not move with it. +export function StickyHeader() { + const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`) + return ( + +
+
+ {rows.map((row, i) => ( +
+ {row} +
+ ))} +
+
+
+ Inbox +
+
+ + ) +} + const DIRECTIONS: Array<[string, string]> = [ ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], @@ -32,6 +103,15 @@ const ALPHA: Array<[string, string]> = [ ["linear-gradient(to right, currentColor, transparent)", "currentColor as a stop"], ] +const EASING: Array<[string, string]> = [ + ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "no easing: a straight mix"], + ["linear-gradient(to right, #ff5c8a, ease-in-out, #5cc8ff)", "ease-in-out holds both ends longer"], + ["linear-gradient(to right, #ff5c8a, ease-in, #5cc8ff)", "ease-in keeps the first colour"], + ["linear-gradient(to right, #ff5c8a, cubic-bezier(0.7, 0, 0.3, 1), #5cc8ff)", "any cubic-bezier()"], + ["linear-gradient(to top, black, transparent)", "a straight scrim: dense at the bottom, a hard edge at the top"], + ["linear-gradient(to top, black, ease-in-out, transparent)", "the same scrim eased"], +] + function List({ title, note, entries }: { title: string note: string @@ -63,9 +143,15 @@ export function Gradients() { note="Up to eight stops. Missing positions spread evenly, and a position that steps back snaps to the one before it." entries={STOPS} /> + +
diff --git a/packages/native/css/src/background.rs b/packages/native/css/src/background.rs index 611099a7..f666fac0 100644 --- a/packages/native/css/src/background.rs +++ b/packages/native/css/src/background.rs @@ -19,6 +19,76 @@ use lightningcss::values::position::{HorizontalPositionKeyword, VerticalPosition use crate::color::{self, ColorContext, Rgba}; use crate::CssError; +/// An easing between two stops: the control points `[x1, y1, x2, y2]` of a +/// cubic bezier from (0, 0) to (1, 1). All zero means none, a straight mix. +/// +/// CSS has no easing in gradients yet. This is the syntax the CSSWG proposal +/// (csswg-drafts issue 1332) uses: an `` in the place of a +/// colour hint, between two colour stops. +pub type Easing = [f32; 4]; + +/// Read one `` from CSS Easing 1. `linear` reads as none. +pub fn easing(text: &str) -> Option { + let lower = text.trim().to_ascii_lowercase(); + match lower.as_str() { + "linear" => return Some([0.0; 4]), + "ease" => return Some([0.25, 0.1, 0.25, 1.0]), + "ease-in" => return Some([0.42, 0.0, 1.0, 1.0]), + "ease-out" => return Some([0.0, 0.0, 0.58, 1.0]), + "ease-in-out" => return Some([0.42, 0.0, 0.58, 1.0]), + _ => {} + } + let inner = lower.strip_prefix("cubic-bezier(")?.strip_suffix(')')?; + let numbers: Vec = inner + .split(',') + .map(|n| n.trim().parse::().ok().filter(|n| n.is_finite())) + .collect::>()?; + let [x1, y1, x2, y2] = numbers[..] else { return None }; + let unit = 0.0..=1.0; + (unit.contains(&x1) && unit.contains(&x2)).then_some([x1, y1, x2, y2]) +} + +/// Split at the commas outside parentheses. +fn split_top_level(text: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut start = 0; + for (i, c) in text.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = (depth - 1).max(0), + ',' if depth == 0 => { + out.push(&text[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&text[start..]); + out +} + +/// Pull the easings out of a `linear-gradient()` so lightningcss can read +/// the rest. Returns the value without them, how many arguments stay, and +/// each easing with the index of the argument that follows it. +fn split_easings(value: &str) -> Option<(String, usize, Vec<(usize, Easing)>)> { + let open = value.find('(')?; + let close = value.rfind(')')?; + let head = &value[..open]; + if !head.trim().eq_ignore_ascii_case("linear-gradient") { + return None; + } + let mut kept = Vec::new(); + let mut easings = Vec::new(); + for piece in split_top_level(&value[open + 1..close]) { + match easing(piece) { + Some(easing) => easings.push((kept.len(), easing)), + None => kept.push(piece.trim()), + } + } + Some((format!("{head}({})", kept.join(", ")), kept.len(), easings)) +} + /// Where the line of a linear gradient points. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Line { @@ -39,6 +109,8 @@ pub struct Stop { /// 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, + /// The easing to the next stop. All zero is none. + pub easing: Easing, } /// A `linear-gradient()` ready to paint. @@ -76,7 +148,9 @@ pub struct Reading { /// 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 (parsed, kept, easings) = + split_easings(value).unwrap_or_else(|| (value.to_string(), 0, Vec::new())); + let Ok(image) = Image::parse_string(&parsed) else { let reading = color::read(value, context)?; return Ok(Some(Reading { fill: Fill::Color(reading.color), @@ -88,7 +162,15 @@ pub fn read(value: &str, context: &ColorContext) -> Result, CssE 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)?; + // The direction, when written, is the one argument that is + // not an item. An easing sits after the item before it. + let offset = kept - linear.items.len(); + let easings = easings + .iter() + .map(|(index, easing)| (index.checked_sub(offset + 1), *easing)) + .collect::>(); + let (stops, read_current_color) = + fix_up(&linear.items, &easings, context, value)?; Ok(Some(Reading { fill: Fill::LinearGradient(LinearGradient { line, stops }), read_current_color, @@ -140,6 +222,7 @@ type Item = GradientItem; struct Pending { color: Option, position: Option, + easing: Easing, } /// Turn the parsed items into stops with positions, the way CSS Images 3 @@ -153,6 +236,7 @@ struct Pending { /// the stop after it. fn fix_up( items: &[Item], + easings: &[(Option, Easing)], context: &ColorContext, value: &str, ) -> Result<(Vec, bool), CssError> { @@ -169,19 +253,33 @@ fn fix_up( .as_ref() .map(|p| fraction(p, value)) .transpose()?, + easing: [0.0; 4], }); } GradientItem::Hint(position) => pending.push(Pending { color: None, position: Some(fraction(position, value)?), + easing: [0.0; 4], }), } } + let bad_value = || CssError::BadValue { + property: "background".to_string(), + value: value.to_string(), + }; if pending.len() < 2 { - return Err(CssError::BadValue { - property: "background".to_string(), - value: value.to_string(), - }); + return Err(bad_value()); + } + // An easing goes between two colour stops, one per pair, and not next + // to a hint, which already says where the half-way point is. + for (index, easing) in easings { + let Some(index) = *index else { return Err(bad_value()) }; + let both_colours = pending.get(index).is_some_and(|p| p.color.is_some()) + && pending.get(index + 1).is_some_and(|p| p.color.is_some()); + if !both_colours || pending[index].easing != [0.0; 4] { + return Err(bad_value()); + } + pending[index].easing = *easing; } let last = pending.len() - 1; @@ -216,7 +314,12 @@ fn fix_up( 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 }), + Some(color) => stops.push(Stop { + color, + position, + hint: 0.0, + easing: item.easing, + }), None => { let Some(previous) = stops.last_mut() else { continue }; let next = pending[i + 1..] @@ -293,6 +396,39 @@ mod tests { assert_eq!(gradient.stops[1].hint, 0.0); } + #[test] + fn reads_an_easing_between_two_stops() { + let read = gradient("linear-gradient(to right, red, ease-in-out, blue)"); + assert_eq!(read.stops.len(), 2); + assert_eq!(read.stops[0].easing, [0.42, 0.0, 0.58, 1.0]); + assert_eq!(read.stops[1].easing, [0.0; 4]); + + let read = + gradient("linear-gradient(red, cubic-bezier(0.5, 0, 1, 1.5), blue 80%, green)"); + assert_eq!(read.stops[0].easing, [0.5, 0.0, 1.0, 1.5]); + assert_eq!(read.stops[1].position, 0.8); + assert_eq!(read.stops[1].easing, [0.0; 4]); + + // `linear` is the straight mix, which is what no easing does. + let read = gradient("linear-gradient(red, linear, blue)"); + assert_eq!(read.stops[0].easing, [0.0; 4]); + } + + #[test] + fn an_easing_needs_a_stop_on_each_side() { + let context = ColorContext::default(); + for bad in [ + "linear-gradient(ease-in, red, blue)", + "linear-gradient(red, blue, ease-in)", + "linear-gradient(red, ease-in, ease-out, blue)", + "linear-gradient(red, ease-in, 30%, blue)", + "linear-gradient(red, 30%, ease-in, blue)", + "linear-gradient(red, cubic-bezier(2, 0, 1, 1), blue)", + ] { + assert!(read(bad, &context).is_err(), "{bad}"); + } + } + #[test] fn reads_every_direction() { assert_eq!(gradient("linear-gradient(red, blue)").line, Line::Angle(180.0)); diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index afe677c4..f11cecb2 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1954,6 +1954,30 @@ describeNative("motion", () => { expect(plainB).toBeLessThan(40) }) + it("eases the mix between two gradient stops", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+
+ ) + // A quarter of the way along, ease-in is still mostly red, while the + // straight mix has given up a quarter of it. cubic-bezier(0, 1, 0, 1) + // jumps toward blue at once. + const [straightR] = renderer.pixelAt(60, 30) + const [easedR] = renderer.pixelAt(60, 80) + const [jumpedR] = renderer.pixelAt(60, 130) + expect(straightR).toBeGreaterThan(160) + expect(straightR).toBeLessThan(220) + expect(easedR).toBeGreaterThan(straightR + 20) + expect(jumpedR).toBeLessThan(straightR - 40) + // Both ends still land on the stops. + expect(renderer.pixelAt(12, 80)[0]).toBeGreaterThan(220) + expect(renderer.pixelAt(208, 80)[2]).toBeGreaterThan(220) + }) + it("cuts corners to the declared shape", () => { const { render, renderer } = createTestRoot() const box = { width: 100, height: 100, backgroundColor: "#ff0000" } From 673def308c606d2c68d2ee5ec4bfa925a09cbd17 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:18:18 +0200 Subject: [PATCH 16/55] feat(native): bump zed for effect layers and rounded scroll clipping --- .changeset/clip-rounded-scroll-boxes.md | 14 ++++++++++++++ zed | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/clip-rounded-scroll-boxes.md diff --git a/.changeset/clip-rounded-scroll-boxes.md b/.changeset/clip-rounded-scroll-boxes.md new file mode 100644 index 00000000..a482eed0 --- /dev/null +++ b/.changeset/clip-rounded-scroll-boxes.md @@ -0,0 +1,14 @@ +--- +"@gpuix/native": patch +--- + +Clip a rounded scroll box at its corner arcs, and end the scroll range at the far edge of the content. + +A box that hides or scrolls its overflow used to clip its children to a +rectangle, so a child could poke out over a rounded corner by a few +pixels. Such a box now clips on the GPU with the same rounded shape it +paints. The scroll range used to count the start-side padding twice, +which let a padded scroll box scroll sideways past its content and show +a bar it did not need. The range now ends at the far edge of the +children plus the end-side padding, as in CSS, and an absolutely +positioned child ends the range at its own edge. diff --git a/zed b/zed index f1fc098a..66677f1d 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit f1fc098ad402dbda87d07d679b5d2e8f883ebf86 +Subproject commit 66677f1de0272505788bb711e3b78533732df144 From 623723c94915befc9d0a2a129c6b01a6c896661c Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:19:22 +0200 Subject: [PATCH 17/55] feat(native): map filter, backdrop-filter, mask-image and blend modes onto effect layers --- .changeset/layer-effects.md | 21 ++ examples/demo.test.tsx | 2 + examples/demo/app.tsx | 2 + examples/demo/effects.tsx | 109 ++++++ packages/native/css/src/effects.rs | 347 +++++++++++++++++++ packages/native/css/src/lib.rs | 1 + packages/native/src/style.rs | 14 + packages/native/src/style/resolve.rs | 186 ++++++++-- packages/native/src/style/vars.rs | 100 +++++- packages/react/src/__tests__/events.test.tsx | 40 +++ packages/react/src/__tests__/styles.test.tsx | 109 ++++++ packages/react/src/types/host.ts | 31 +- 12 files changed, 926 insertions(+), 36 deletions(-) create mode 100644 .changeset/layer-effects.md create mode 100644 examples/demo/effects.tsx create mode 100644 packages/native/css/src/effects.rs diff --git a/.changeset/layer-effects.md b/.changeset/layer-effects.md new file mode 100644 index 00000000..b51856d1 --- /dev/null +++ b/.changeset/layer-effects.md @@ -0,0 +1,21 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Add `filter`, `backdrop-filter`, `mask-image`, `mix-blend-mode`, `background-blend-mode` and `overscroll-behavior`. + +An element with any of the first four paints itself and its children into a +texture of its own, and the GPU paints that texture over the frame with the +effect. `blur()` is a two-pass Gaussian on a shrunk texture, so a wide blur +costs the same as a narrow one. The other filter functions fold into one +colour matrix. `mask-image` takes a gradient and keeps each pixel by its +alpha. Over a `backdrop-filter: blur()` the alpha scales the blur radius +instead, so an eased mask gives a progressive blur like the iOS 26 +navigation bar. CSS would fade a sharp copy over the blur there. All sixteen blend modes of Compositing and Blending 1 plus +`plus-lighter` are painted. `background-image` now paints over +`background-color` instead of replacing it. A scroll box keeps a wheel event +it can scroll with, and `overscroll-behavior: contain` keeps it at the end +of the box too. The Metal, DirectX and wgpu renderers all paint the +effects. Only WebGL, which has no storage buffers, paints the content of +an effect layer with no effect. diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 7b579606..3b358808 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -14,6 +14,7 @@ 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 { Effects } from "./demo/effects" import { Gradients } from "./demo/gradients" import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" @@ -33,6 +34,7 @@ function root(): TestRoot { const PANELS = [ ["colors", ], ["gradients", ], + ["effects", ], ["lengths", ], ["variables", ], ["inheritance", ], diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx index 4c89e739..f16934d8 100644 --- a/examples/demo/app.tsx +++ b/examples/demo/app.tsx @@ -12,6 +12,7 @@ import { ClassNames } from "./class-names.js" import { Colors } from "./colors.js" import { Gradients } from "./gradients.js" import { Corners } from "./corners.js" +import { Effects } from "./effects.js" import { Inheritance } from "./inheritance.js" import { Lengths } from "./lengths.js" import { Motion } from "./motion-panel.js" @@ -69,6 +70,7 @@ const SECTIONS = [ { id: "colors", title: "Colours", render: () => }, { id: "gradients", title: "Gradients", render: () => }, { id: "corners", title: "Corner shape", render: () => }, + { id: "effects", title: "Effects", render: () => }, { id: "lengths", title: "Lengths", render: () => }, { id: "variables", title: "Variables", render: () => }, { id: "inheritance", title: "Inheritance", render: () => }, diff --git a/examples/demo/effects.tsx b/examples/demo/effects.tsx new file mode 100644 index 00000000..840b978b --- /dev/null +++ b/examples/demo/effects.tsx @@ -0,0 +1,109 @@ +/// Filters, masks and blend modes. +/// +/// Each of these makes the element paint itself and its children into a +/// texture of its own. The GPU then paints that texture over the frame with +/// the effect, so the content under it is untouched. `filter` functions +/// other than `blur()` fold into one colour matrix. + +import React from "react" +import { Grid, Panel, Sample } from "./ui.js" + +const FILTERS: Array<[string, string]> = [ + ["blur(6px)", "a Gaussian, sigma in pixels"], + ["grayscale(1)", ""], + ["sepia(1)", ""], + ["invert(1)", ""], + ["hue-rotate(150deg)", ""], + ["saturate(3)", ""], + ["brightness(0.5) contrast(2)", "two functions, in order"], + ["opacity(0.4)", "same as opacity, as a filter"], +] + +const BLENDS = [ + "normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", + "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity", +] + +function Picture() { + return ( +
+ Aa +
+ ) +} + +function Filters() { + return ( + + + {FILTERS.map(([value, hint]) => ( + +
+ +
+
+ ))} +
+
+ ) +} + +function Blends() { + return ( + + + {BLENDS.map((mode) => ( + +
+
+
+ + ))} + + + ) +} + +function Masks() { + return ( + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ + + + ) +} + +export function Effects() { + return ( +
+ + + +
+ ) +} diff --git a/packages/native/css/src/effects.rs b/packages/native/css/src/effects.rs new file mode 100644 index 00000000..be870c88 --- /dev/null +++ b/packages/native/css/src/effects.rs @@ -0,0 +1,347 @@ +//! `filter`, `backdrop-filter` and ``. +//! +//! A filter list folds into one blur and one colour matrix. Every filter +//! function of Filter Effects 1 except `blur()`, `drop-shadow()` and `url()` +//! is a 4 by 5 matrix on straight rgba, and matrices multiply, so a list +//! costs one matrix on the GPU however long it is. Two blurs add in +//! quadrature, since a Gaussian of a Gaussian is a Gaussian. + +use lightningcss::properties::effects::{ + BlendMode as CssBlendMode, Filter as CssFilter, FilterList, +}; +use lightningcss::traits::Parse; +use lightningcss::values::percentage::NumberOrPercentage; + +use crate::CssError; + +/// A row-major 4 by 5 colour matrix. Rows are the output r, g, b and a. +/// Columns weigh the input r, g, b, a and a constant. +pub type ColorMatrix = [f32; 20]; + +/// The matrix that changes nothing. +pub const IDENTITY: ColorMatrix = [ + 1.0, 0.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, 0.0, +]; + +/// A filter list, ready for the GPU. +#[derive(Debug, Clone, PartialEq)] +pub struct Filter { + /// Standard deviation of the blur in CSS pixels. 0 is none. + pub blur: f32, + /// Every other function, multiplied in list order. + pub matrix: ColorMatrix, +} + +/// Read a `filter` or `backdrop-filter` value. `none` reads as `Ok(None)`. +pub fn filter(value: &str) -> Result, CssError> { + let list = FilterList::parse_string(value).map_err(|_| CssError::BadValue { + property: "filter".to_string(), + value: value.to_string(), + })?; + let functions = match list { + FilterList::None => return Ok(None), + FilterList::Filters(functions) => functions, + }; + let mut blur = 0.0f32; + let mut matrix = IDENTITY; + for function in functions.iter() { + match function { + CssFilter::Blur(length) => { + let sigma = length.to_px().ok_or_else(|| CssError::Unsupported { + feature: "a blur() in a unit that needs the font".to_string(), + value: value.to_string(), + })?; + blur = (blur * blur + sigma * sigma).sqrt(); + } + CssFilter::Brightness(amount) => { + matrix = then(&matrix, &scale(fraction(amount).max(0.0))); + } + CssFilter::Contrast(amount) => { + let amount = fraction(amount).max(0.0); + matrix = then(&matrix, &linear(amount, 0.5 - 0.5 * amount)); + } + CssFilter::Grayscale(amount) => { + matrix = then(&matrix, &grayscale(fraction(amount).clamp(0.0, 1.0))); + } + CssFilter::HueRotate(angle) => { + matrix = then(&matrix, &hue_rotate(angle.to_degrees())); + } + CssFilter::Invert(amount) => { + let amount = fraction(amount).clamp(0.0, 1.0); + matrix = then(&matrix, &linear(1.0 - 2.0 * amount, amount)); + } + CssFilter::Opacity(amount) => { + matrix = then(&matrix, &opacity(fraction(amount).clamp(0.0, 1.0))); + } + CssFilter::Saturate(amount) => { + matrix = then(&matrix, &saturate(fraction(amount).max(0.0))); + } + CssFilter::Sepia(amount) => { + matrix = then(&matrix, &sepia(fraction(amount).clamp(0.0, 1.0))); + } + CssFilter::DropShadow(_) => { + return Err(CssError::Unsupported { + feature: "drop-shadow()".to_string(), + value: value.to_string(), + }) + } + CssFilter::Url(_) => { + return Err(CssError::Unsupported { + feature: "url() filters".to_string(), + value: value.to_string(), + }) + } + } + } + Ok(Some(Filter { blur, matrix })) +} + +/// How a layer mixes with what is under it. Compositing and Blending 1, in +/// spec order, with `plus-lighter` from Compositing 2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlendMode { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, + PlusLighter, +} + +/// Read a ``. `plus-darker` is Apple only and reads as an error. +pub fn blend_mode(value: &str) -> Result { + let mode = CssBlendMode::parse_string(value).map_err(|_| CssError::BadValue { + property: "mix-blend-mode".to_string(), + value: value.to_string(), + })?; + Ok(match mode { + CssBlendMode::Normal => BlendMode::Normal, + CssBlendMode::Multiply => BlendMode::Multiply, + CssBlendMode::Screen => BlendMode::Screen, + CssBlendMode::Overlay => BlendMode::Overlay, + CssBlendMode::Darken => BlendMode::Darken, + CssBlendMode::Lighten => BlendMode::Lighten, + CssBlendMode::ColorDodge => BlendMode::ColorDodge, + CssBlendMode::ColorBurn => BlendMode::ColorBurn, + CssBlendMode::HardLight => BlendMode::HardLight, + CssBlendMode::SoftLight => BlendMode::SoftLight, + CssBlendMode::Difference => BlendMode::Difference, + CssBlendMode::Exclusion => BlendMode::Exclusion, + CssBlendMode::Hue => BlendMode::Hue, + CssBlendMode::Saturation => BlendMode::Saturation, + CssBlendMode::Color => BlendMode::Color, + CssBlendMode::Luminosity => BlendMode::Luminosity, + CssBlendMode::PlusLighter => BlendMode::PlusLighter, + CssBlendMode::PlusDarker => { + return Err(CssError::Unsupported { + feature: "plus-darker".to_string(), + value: value.to_string(), + }) + } + }) +} + +fn fraction(amount: &NumberOrPercentage) -> f32 { + match amount { + NumberOrPercentage::Number(number) => *number, + NumberOrPercentage::Percentage(percentage) => percentage.0, + } +} + +/// The matrix that applies `first` and then `second`. +pub fn then(first: &ColorMatrix, second: &ColorMatrix) -> ColorMatrix { + let mut out = [0.0; 20]; + for row in 0..4 { + for col in 0..5 { + let mut sum = 0.0; + for k in 0..4 { + sum += second[row * 5 + k] * first[k * 5 + col]; + } + if col == 4 { + sum += second[row * 5 + 4]; + } + out[row * 5 + col] = sum; + } + } + out +} + +/// A matrix from three colour rows. Alpha passes through. +fn rgb(rows: [[f32; 3]; 3]) -> ColorMatrix { + let mut out = IDENTITY; + for (row, weights) in rows.iter().enumerate() { + out[row * 5] = weights[0]; + out[row * 5 + 1] = weights[1]; + out[row * 5 + 2] = weights[2]; + } + out +} + +/// `c' = slope * c + intercept` on each colour channel. +fn linear(slope: f32, intercept: f32) -> ColorMatrix { + let mut out = rgb([[slope, 0.0, 0.0], [0.0, slope, 0.0], [0.0, 0.0, slope]]); + out[4] = intercept; + out[9] = intercept; + out[14] = intercept; + out +} + +fn scale(amount: f32) -> ColorMatrix { + linear(amount, 0.0) +} + +fn opacity(amount: f32) -> ColorMatrix { + let mut out = IDENTITY; + out[18] = amount; + out +} + +fn grayscale(amount: f32) -> ColorMatrix { + let a = 1.0 - amount; + rgb([ + [0.2126 + 0.7874 * a, 0.7152 - 0.7152 * a, 0.0722 - 0.0722 * a], + [0.2126 - 0.2126 * a, 0.7152 + 0.2848 * a, 0.0722 - 0.0722 * a], + [0.2126 - 0.2126 * a, 0.7152 - 0.7152 * a, 0.0722 + 0.9278 * a], + ]) +} + +fn sepia(amount: f32) -> ColorMatrix { + let a = 1.0 - amount; + rgb([ + [0.393 + 0.607 * a, 0.769 - 0.769 * a, 0.189 - 0.189 * a], + [0.349 - 0.349 * a, 0.686 + 0.314 * a, 0.168 - 0.168 * a], + [0.272 - 0.272 * a, 0.534 - 0.534 * a, 0.131 + 0.869 * a], + ]) +} + +fn saturate(s: f32) -> ColorMatrix { + rgb([ + [0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s], + [0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s], + [0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s], + ]) +} + +fn hue_rotate(degrees: f32) -> ColorMatrix { + let (sin, cos) = degrees.to_radians().sin_cos(); + rgb([ + [ + 0.213 + cos * 0.787 - sin * 0.213, + 0.715 - cos * 0.715 - sin * 0.715, + 0.072 - cos * 0.072 + sin * 0.928, + ], + [ + 0.213 - cos * 0.213 + sin * 0.143, + 0.715 + cos * 0.285 + sin * 0.140, + 0.072 - cos * 0.072 - sin * 0.283, + ], + [ + 0.213 - cos * 0.213 - sin * 0.787, + 0.715 - cos * 0.715 + sin * 0.715, + 0.072 + cos * 0.928 + sin * 0.072, + ], + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply(matrix: &ColorMatrix, c: [f32; 4]) -> [f32; 4] { + let mut out = [0.0; 4]; + for row in 0..4 { + out[row] = matrix[row * 5] * c[0] + + matrix[row * 5 + 1] * c[1] + + matrix[row * 5 + 2] * c[2] + + matrix[row * 5 + 3] * c[3] + + matrix[row * 5 + 4]; + } + out + } + + fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3) + } + + #[test] + fn none_reads_as_no_filter() { + assert_eq!(filter("none").unwrap(), None); + } + + #[test] + fn blurs_add_in_quadrature() { + let read = filter("blur(3px) blur(4px)").unwrap().unwrap(); + assert!((read.blur - 5.0).abs() < 1e-5); + assert_eq!(read.matrix, IDENTITY); + } + + #[test] + fn a_full_grayscale_drops_every_hue() { + let read = filter("grayscale(100%)").unwrap().unwrap(); + let red = apply(&read.matrix, [1.0, 0.0, 0.0, 1.0]); + assert!(close(red, [0.2126, 0.2126, 0.2126, 1.0]), "{red:?}"); + } + + #[test] + fn invert_flips_the_channels() { + let read = filter("invert(1)").unwrap().unwrap(); + let white = apply(&read.matrix, [1.0, 1.0, 1.0, 1.0]); + assert!(close(white, [0.0, 0.0, 0.0, 1.0]), "{white:?}"); + } + + #[test] + fn functions_apply_in_list_order() { + // Brightness first then invert: 0.5 * 1 = 0.5, then 1 - 0.5 = 0.5. + // Invert first then brightness: 1 - 1 = 0, then 0.5 * 0 = 0. + let a = filter("brightness(0.5) invert(1)").unwrap().unwrap(); + let b = filter("invert(1) brightness(0.5)").unwrap().unwrap(); + let white = [1.0, 1.0, 1.0, 1.0]; + assert!(close(apply(&a.matrix, white), [0.5, 0.5, 0.5, 1.0])); + assert!(close(apply(&b.matrix, white), [0.0, 0.0, 0.0, 1.0])); + } + + #[test] + fn opacity_scales_alpha_only() { + let read = filter("opacity(25%)").unwrap().unwrap(); + let c = apply(&read.matrix, [0.2, 0.4, 0.6, 1.0]); + assert!(close(c, [0.2, 0.4, 0.6, 0.25]), "{c:?}"); + } + + #[test] + fn a_half_turn_of_hue_keeps_the_luminance() { + let read = filter("hue-rotate(180deg)").unwrap().unwrap(); + let grey = apply(&read.matrix, [0.5, 0.5, 0.5, 1.0]); + assert!(close(grey, [0.5, 0.5, 0.5, 1.0]), "{grey:?}"); + } + + #[test] + fn drop_shadow_is_not_supported() { + assert!(matches!( + filter("drop-shadow(2px 2px red)"), + Err(CssError::Unsupported { .. }) + )); + assert!(matches!(filter("wobble(3)"), Err(CssError::BadValue { .. }))); + } + + #[test] + fn reads_every_blend_mode() { + assert_eq!(blend_mode("multiply").unwrap(), BlendMode::Multiply); + assert_eq!(blend_mode("plus-lighter").unwrap(), BlendMode::PlusLighter); + assert!(matches!(blend_mode("plus-darker"), Err(CssError::Unsupported { .. }))); + assert!(matches!(blend_mode("soft"), Err(CssError::BadValue { .. }))); + } +} diff --git a/packages/native/css/src/lib.rs b/packages/native/css/src/lib.rs index a9b596cc..39a5f429 100644 --- a/packages/native/css/src/lib.rs +++ b/packages/native/css/src/lib.rs @@ -10,6 +10,7 @@ pub mod background; pub mod color; +pub mod effects; pub mod length; use std::collections::HashMap; diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 2b4bf7d2..988759a1 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -401,6 +401,16 @@ style_desc! { background: Option = "background", background_color: Option = "backgroundColor", background_image: Option = "backgroundImage", + /// How `backgroundImage` mixes with `backgroundColor`, a ``. + background_blend_mode: Option = "backgroundBlendMode", + /// How the element mixes with what is under it, a ``. + mix_blend_mode: Option = "mixBlendMode", + /// A CSS filter list on the element and its children, or `none`. + filter: Option = "filter", + /// A CSS filter list on what is under the element, or `none`. + backdrop_filter: Option = "backdropFilter", + /// A gradient whose alpha keeps or drops each pixel, or `none`. + mask_image: Option = "maskImage", color: Option = "color", opacity: Option = "opacity", @@ -473,6 +483,10 @@ style_desc! { overflow: Option = "overflow", overflow_x: Option = "overflowX", overflow_y: Option = "overflowY", + /// `auto`, `contain` or `none`, one for both axes or one per axis. + overscroll_behavior: Option = "overscrollBehavior", + overscroll_behavior_x: Option = "overscrollBehaviorX", + overscroll_behavior_y: Option = "overscrollBehaviorY", // Cursor cursor: Option = "cursor", diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index d35a0fa7..938df240 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -255,25 +255,60 @@ fn dimension(value: crate::style::DimensionValue) -> gpui::Length { } } -/// The one fill an element paints, or none. +/// The colour and the image a box paints, image over colour, as a browser +/// paints `background-color` under `background-image`. /// -/// 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; +/// `background` is the shorthand. A colour in it is the colour, a gradient in +/// it is the image, and a declared longhand wins over it either way. A +/// longhand that reads as nothing, like `backgroundImage: "none"` or a value +/// this build cannot paint, still wins, so the shorthand does not show +/// through it. +fn background_fills( + style: &StyleDesc, + scope: &Scope, +) -> ( + Option, + Option, +) { + use gpuix_css::background::Fill; + let (mut color, mut image) = match style.background.as_deref().and_then(|t| scope.fill(t)) { + Some(Fill::Color(c)) => (Some(Fill::Color(c)), None), + Some(gradient) => (None, Some(gradient)), + None => (None, None), + }; + if let Some(text) = style.background_color.as_deref() { + color = scope.fill(text); + } + if let Some(text) = style.background_image.as_deref() { + image = scope.fill(text); + } + (color, image) +} + +/// The `overscroll-behavior` of one axis, from its longhand or the +/// shorthand. The shorthand takes one word for both axes or two, x first. +fn overscroll( + longhand: Option<&str>, + shorthand: Option<&str>, + axis: usize, +) -> Option { + let word = match longhand { + Some(word) => word.trim(), + None => { + let words: Vec<&str> = shorthand?.split_whitespace().collect(); + match words.len() { + 1 => words[0], + 2 => words[axis], + _ => return None, + } + } + }; + match word { + "auto" => Some(gpui::Overscroll::Auto), + "contain" => Some(gpui::Overscroll::Contain), + "none" => Some(gpui::Overscroll::None), + _ => None, } - 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 { @@ -429,9 +464,64 @@ 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(fill) = background_fill(style, scope) { + let (background_color, background_image) = background_fills(style, scope); + if let Some(fill) = background_color { el = el.bg(crate::color::to_background(&fill)); } + if let Some(fill) = background_image { + el = el.background_image(crate::color::to_background(&fill)); + } + if let Some(mode) = style + .background_blend_mode + .as_deref() + .and_then(|text| scope.blend_mode(text)) + { + el = el.background_blend_mode(mode); + } + if let Some(mode) = style + .mix_blend_mode + .as_deref() + .and_then(|text| scope.blend_mode(text)) + { + el = el.blend_mode(mode); + } + if let Some(filter) = style.filter.as_deref().and_then(|text| scope.filter(text)) { + el = el + .blur(gpui::px(filter.blur)) + .color_matrix(gpui::ColorMatrix(filter.matrix)); + } + if let Some(filter) = style + .backdrop_filter + .as_deref() + .and_then(|text| scope.filter(text)) + { + el = el + .backdrop_blur(gpui::px(filter.blur)) + .backdrop_matrix(gpui::ColorMatrix(filter.matrix)); + } + if let Some(mask) = style + .mask_image + .as_deref() + .and_then(|text| scope.fill(text)) + { + el = el.mask(crate::color::to_background(&mask)); + } + let overscroll_x = overscroll( + style.overscroll_behavior_x.as_deref(), + style.overscroll_behavior.as_deref(), + 0, + ); + let overscroll_y = overscroll( + style.overscroll_behavior_y.as_deref(), + style.overscroll_behavior.as_deref(), + 1, + ); + if overscroll_x.is_some() || overscroll_y.is_some() { + el = el.overscroll_behavior( + overscroll_x.unwrap_or_default(), + overscroll_y.unwrap_or_default(), + ); + } if let Some(color) = style.color.as_deref().and_then(|c| scope.color(c)) { el = el.text_color(crate::color::to_hsla(color)); } @@ -657,32 +747,72 @@ mod tests { ); } + fn image_of(style: &StyleDesc, cascade: &Inherited) -> Option { + Resolved::build(style, cascade).base.background_image + } + #[test] - fn a_gradient_image_wins_over_the_colour() { + fn a_gradient_image_paints_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:?}"); + assert_eq!(background_of(&style, &no_variables()), fill("#111111")); + let image = image_of(&style, &no_variables()).expect("an image"); + assert!( + image.as_solid().is_none(), + "should be a gradient: {image:?}" + ); - // `none` steps aside for the colour underneath. + // `none` leaves the colour on its own. 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()); + assert_eq!(background_of(&style, &no_variables()), fill("#111111")); + assert_eq!(image_of(&style, &no_variables()), None); - // The shorthand takes a gradient too. + // The shorthand takes a gradient too, and it is the image. 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()); + assert_eq!(background_of(&shorthand, &no_variables()), None); + assert!(image_of(&shorthand, &no_variables()).is_some()); + } + + #[test] + fn effects_reach_the_style() { + let style = StyleDesc { + filter: Some("blur(4px) grayscale(1)".to_string()), + backdrop_filter: Some("blur(20px)".to_string()), + mask_image: Some("linear-gradient(black, transparent)".to_string()), + mix_blend_mode: Some("multiply".to_string()), + background_blend_mode: Some("screen".to_string()), + overscroll_behavior: Some("contain auto".to_string()), + overscroll_behavior_y: Some("none".to_string()), + ..Default::default() + }; + let base = Resolved::build(&style, &no_variables()).base; + let effects = base.effects.expect("effects"); + assert_eq!(effects.blur, gpui::px(4.0)); + assert!(!effects.color_matrix.is_identity()); + assert_eq!(effects.backdrop_blur, gpui::px(20.0)); + assert!(effects.mask.is_some()); + assert_eq!(effects.blend_mode, gpui::BlendMode::Multiply); + assert_eq!(base.background_blend_mode, Some(gpui::BlendMode::Screen)); + assert_eq!(base.overscroll_behavior.x, Some(gpui::Overscroll::Contain)); + assert_eq!(base.overscroll_behavior.y, Some(gpui::Overscroll::None)); + + // `none` and a filter this build cannot paint both set nothing. + let style = StyleDesc { + filter: Some("drop-shadow(1px 1px red)".to_string()), + backdrop_filter: Some("none".to_string()), + ..Default::default() + }; + let base = Resolved::build(&style, &no_variables()).base; + assert!(base.effects.is_none()); } #[test] diff --git a/packages/native/src/style/vars.rs b/packages/native/src/style/vars.rs index d6e00cca..a9fe10d7 100644 --- a/packages/native/src/style/vars.rs +++ b/packages/native/src/style/vars.rs @@ -155,6 +155,40 @@ impl<'a> Scope<'a> { Some(reading.fill) } + /// The blur and colour matrix a `filter` or `backdrop-filter` names. + /// + /// `None` for `none`, and for a list this build cannot paint, such as + /// one with `drop-shadow()`. + pub fn filter(&self, text: &str) -> Option { + let text = self.value(text)?; + gpuix_css::effects::filter(&text).ok()? + } + + /// The blend mode a `mix-blend-mode` or `background-blend-mode` names. + pub fn blend_mode(&self, text: &str) -> Option { + use gpuix_css::effects::BlendMode as Css; + let text = self.value(text)?; + Some(match gpuix_css::effects::blend_mode(&text).ok()? { + Css::Normal => gpui::BlendMode::Normal, + Css::Multiply => gpui::BlendMode::Multiply, + Css::Screen => gpui::BlendMode::Screen, + Css::Overlay => gpui::BlendMode::Overlay, + Css::Darken => gpui::BlendMode::Darken, + Css::Lighten => gpui::BlendMode::Lighten, + Css::ColorDodge => gpui::BlendMode::ColorDodge, + Css::ColorBurn => gpui::BlendMode::ColorBurn, + Css::HardLight => gpui::BlendMode::HardLight, + Css::SoftLight => gpui::BlendMode::SoftLight, + Css::Difference => gpui::BlendMode::Difference, + Css::Exclusion => gpui::BlendMode::Exclusion, + Css::Hue => gpui::BlendMode::Hue, + Css::Saturation => gpui::BlendMode::Saturation, + Css::Color => gpui::BlendMode::Color, + Css::Luminosity => gpui::BlendMode::Luminosity, + Css::PlusLighter => gpui::BlendMode::PlusLighter, + }) + } + /// Whether resolving read a variable. pub fn used_a_variable(&self) -> bool { self.used.get() @@ -425,23 +459,77 @@ mod tests { #[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 })); + 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 })); + 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); + 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() diff --git a/packages/react/src/__tests__/events.test.tsx b/packages/react/src/__tests__/events.test.tsx index 022e164e..5e710a4f 100644 --- a/packages/react/src/__tests__/events.test.tsx +++ b/packages/react/src/__tests__/events.test.tsx @@ -1319,6 +1319,46 @@ describeNative("events", () => { expect(innerOffset).toEqual([0, 0]) }) + it("keeps a wheel event inside the list it scrolls", () => { + function Page({ overscroll }: { overscroll?: string }) { + return ( +
+
+
+ inner +
+
+
+ below +
+
+ ) + } + + testRoot.render() + const divs = () => testRoot.renderer.findByType("div").filter((d) => d.style.overflowY === "scroll") + const [page, list] = divs() + + // The list has room, so the page does not move. + testRoot.renderer.nativeSimulateScrollWheel(80, 40, 0, -30) + expect(testRoot.renderer.getScrollOffset(list.id)![1]).toBeLessThan(0) + expect(testRoot.renderer.getScrollOffset(page.id)).toEqual([0, 0]) + + // At the end of the list, `auto` hands the event to the page. + testRoot.renderer.nativeSimulateScrollWheel(80, 40, 0, -500) + testRoot.renderer.nativeSimulateScrollWheel(80, 40, 0, -30) + expect(testRoot.renderer.getScrollOffset(page.id)![1]).toBeLessThan(0) + + // `contain` keeps it even then. + testRoot.render() + const [page2, list2] = divs() + testRoot.renderer.nativeSimulateScrollWheel(80, 40, 0, -500) + const pageBefore = testRoot.renderer.getScrollOffset(page2.id)![1] + testRoot.renderer.nativeSimulateScrollWheel(80, 40, 0, -30) + expect(testRoot.renderer.getScrollOffset(list2.id)![1]).toBeLessThan(0) + expect(testRoot.renderer.getScrollOffset(page2.id)![1]).toBe(pageBefore) + }) + it("pans overflow-x when the child is wider than the viewport", () => { function WideRow() { return ( diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index f11cecb2..d632b439 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1978,6 +1978,115 @@ describeNative("motion", () => { expect(renderer.pixelAt(208, 80)[2]).toBeGreaterThan(220) }) + it("blurs the picture of an element", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+ ) + // The sharp box ends at x = 140. The blurred one starts at x = 180: its + // edge is half red and it bleeds red past the box, while its middle + // stays fully red. + expect(renderer.pixelAt(139, 90)[0]).toBeGreaterThan(240) + expect(renderer.pixelAt(146, 90)[0]).toBeLessThan(15) + const [edgeR] = renderer.pixelAt(180, 90) + expect(edgeR).toBeGreaterThan(80) + expect(edgeR).toBeLessThan(180) + expect(renderer.pixelAt(172, 90)[0]).toBeGreaterThan(15) + expect(renderer.pixelAt(230, 90)[0]).toBeGreaterThan(240) + }) + + it("runs the picture of an element through the filter functions", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+
+ ) + const [gr, gg, gb] = renderer.pixelAt(40, 40) + expect(Math.abs(gr - gg)).toBeLessThan(6) + expect(Math.abs(gg - gb)).toBeLessThan(6) + expect(renderer.pixelAt(110, 40)[0]).toBeLessThan(15) + const [hr, hg] = renderer.pixelAt(180, 40) + expect(hg).toBeGreaterThan(hr + 100) + }) + + it("masks an element with a gradient", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+ ) + expect(renderer.pixelAt(12, 30)[0]).toBeGreaterThan(240) + const [midR] = renderer.pixelAt(110, 30) + expect(midR).toBeGreaterThan(90) + expect(midR).toBeLessThan(170) + expect(renderer.pixelAt(208, 30)[0]).toBeLessThan(20) + }) + + it("blends an element with what is under it", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+
+
+
+
+
+ ) + // Yellow times cyan is green. Without the mode, yellow covers cyan. + const [r, g, b] = renderer.pixelAt(40, 40) + expect(r).toBeLessThan(15) + expect(g).toBeGreaterThan(240) + expect(b).toBeLessThan(15) + const [plainR, plainG] = renderer.pixelAt(110, 40) + expect(plainR).toBeGreaterThan(240) + expect(plainG).toBeGreaterThan(240) + const [bgR, bgG, bgB] = renderer.pixelAt(180, 40) + expect(bgR).toBeLessThan(15) + expect(bgG).toBeGreaterThan(240) + expect(bgB).toBeLessThan(15) + }) + + it("paints the background image over the background colour", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+ ) + // Red at the left, and the blue shows through where the gradient clears. + expect(renderer.pixelAt(12, 30)[0]).toBeGreaterThan(240) + expect(renderer.pixelAt(208, 30)[2]).toBeGreaterThan(240) + }) + + it("blurs what is under an element", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+ ) + // Above the glass the red edge at x = 100 is sharp. Under it the edge + // spreads over the black on both sides. + expect(renderer.pixelAt(96, 50)[0]).toBeLessThan(15) + expect(renderer.pixelAt(104, 50)[0]).toBeGreaterThan(240) + expect(renderer.pixelAt(92, 150)[0]).toBeGreaterThan(20) + const [underEdgeR] = renderer.pixelAt(100, 150) + expect(underEdgeR).toBeGreaterThan(70) + expect(underEdgeR).toBeLessThan(190) + expect(renderer.pixelAt(150, 150)[0]).toBeGreaterThan(240) + }) + it("cuts corners to the declared shape", () => { const { render, renderer } = createTestRoot() const box = { width: 100, height: 100, backgroundColor: "#ff0000" } diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index da47d75f..2365c5ab 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -128,9 +128,36 @@ export interface StyleDesc { * 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. */ + /** A `linear-gradient()` or `none`, painted over `backgroundColor` the + * way a browser paints it. Stop positions are percentages. + * + * An easing function between two stops bends the mix, which CSS cannot + * do yet: `linear-gradient(to top, black, ease-in-out, transparent)`. + * Takes `ease`, `ease-in`, `ease-out`, `ease-in-out` or `cubic-bezier()`, + * following csswg-drafts issue 1332. A straight mix looks dense near the + * solid stop and thin near the clear one, and an eased scrim does not. */ backgroundImage?: string + /** How `backgroundImage` mixes with `backgroundColor`: any CSS + * `` except `plus-darker`. */ + backgroundBlendMode?: string + /** How the element and its children mix with what is under them: any CSS + * `` except `plus-darker`. */ + mixBlendMode?: string + /** A CSS filter list on the element and its children, or `none`. `blur()`, + * `brightness()`, `contrast()`, `grayscale()`, `hue-rotate()`, `invert()`, + * `opacity()`, `saturate()` and `sepia()` are painted. `drop-shadow()` + * and `url()` drop the whole property. The functions other than `blur()` + * fold into one colour matrix, so a long list costs one pass. */ + filter?: string + /** The same list applied to what is under the element, clipped to its + * corners like CSS `backdrop-filter`. */ + backdropFilter?: string + /** A `linear-gradient()` whose alpha keeps or drops each pixel of the + * element and its children, over the border box. Over a `backdropFilter` + * blur the alpha scales the blur radius instead, unlike CSS, which fades + * a sharp copy over the blur. Add an easing between the stops for a + * progressive blur under a header. */ + maskImage?: string color?: string opacity?: number From c40a5679e65864f0e6c6c05124b5fe95090313fc Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:24:08 +0200 Subject: [PATCH 18/55] feat(native): paint scrollbars and scroll an element into view --- .changeset/scrollbars.md | 34 + packages/native/index.d.ts | 14 + packages/native/src/renderer.rs | 118 ++- packages/native/src/renderer/frame.rs | 52 +- .../native/src/renderer/scroll_into_view.rs | 213 +++++ packages/native/src/renderer/scrollbar.rs | 836 ++++++++++++++++++ packages/native/src/style.rs | 19 + packages/native/src/style/resolve.rs | 13 +- packages/native/src/test_renderer.rs | 35 +- .../react/src/__tests__/scrollbars.test.tsx | 157 ++++ packages/react/src/types/host.ts | 39 + 11 files changed, 1484 insertions(+), 46 deletions(-) create mode 100644 .changeset/scrollbars.md create mode 100644 packages/native/src/renderer/scroll_into_view.rs create mode 100644 packages/native/src/renderer/scrollbar.rs create mode 100644 packages/react/src/__tests__/scrollbars.test.tsx diff --git a/.changeset/scrollbars.md b/.changeset/scrollbars.md new file mode 100644 index 00000000..9bd86225 --- /dev/null +++ b/.changeset/scrollbars.md @@ -0,0 +1,34 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Paint scrollbars on scroll boxes, and add `scrollbar-width`, `scrollbar-color` and `scrollbar-gutter`. + +A box with `overflow: scroll` or `overflow: auto` now gets a scrollbar +on each axis it scrolls. The OS picks the kind of bar, as a browser does. +When the OS auto-hides scrollbars, an overlay bar floats over the content, +shows for a second after a scroll and fades out, and reserves no space. +Otherwise a classic bar sits in a 15px gutter that the layout reserves. +`overflow: scroll` keeps the classic bar at all times and `auto` shows it +only while the content overflows. A drag of the thumb scrolls, a click in +the track moves one page, and the thumb widens under the mouse. +`scrollbar-width: thin` narrows the bar and `none` removes it. +`scrollbar-color` sets the thumb and the track. `scrollbar-gutter: stable` +reserves the gutter of a classic bar even while the content fits, and +`stable both-edges` reserves one at the start of the axis too. +`overflow: auto` used to do nothing and `clip` now clips like `hidden`. +`GPUIX_SCROLLBARS=overlay` or `classic` in the environment overrides the +OS choice, for tests. + +A bar paints after the whole frame, above any effect a sibling of the +content paints, so a blurred sticky header does not cover it. When one +axis of `overflow` computes to `visible` or `clip` and the other axis +scrolls, the first becomes `auto` or `hidden`, as in CSS. + +`scrollIntoView(elementId, block, inline)` on the renderer scrolls every +scroll box around an element until the element shows. `block` and +`inline` take `start`, `center`, `end` or `nearest`, with the web +defaults. `scroll-margin` on the target keeps space around it, and +`scroll-padding` on a scroll box keeps space inside the box, each as one +value or as the one-to-four shorthand. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index e2bd9e4d..55bdc712 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -59,6 +59,15 @@ export declare class GpuixRenderer { getWindowSize(): WindowSize /** `"hidden"` | `"minimal"` | `"full"`. Paints into the scene after layout. */ setDebugFrameOverlay(mode: string): string + /** + * Scroll every ancestor scroll box so the element shows, like the + * web `scrollIntoView`. `block` places it on the y axis and + * `inline` on the x axis: `start`, `center`, `end` or `nearest`. + * The defaults match the web: `start` and `nearest`. The + * `scroll-margin` of the element and the `scroll-padding` of each + * box apply. + */ + scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void /** Hidden → minimal → full → hidden. */ cycleDebugFrameOverlay(): string getDebugFrameOverlay(): string @@ -260,6 +269,11 @@ export declare class TestGpuixRenderer { * Call flush() after to apply the offset and re-render. */ scrollTo(elementId: number, x: number, y: number): void + /** + * Scroll every ancestor scroll box so the element shows, like the + * web scrollIntoView. Call flush() after to apply and re-render. + */ + scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void /** * Scroll a child into view by its index in the children list. * Call flush() after to apply and re-render. diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index d5389e32..af78ea53 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -43,6 +43,8 @@ gpui::actions!(gpuix_focus, [FocusNext, FocusPrevious]); mod auto_height; mod batch; mod frame; +pub(crate) mod scroll_into_view; +pub(crate) mod scrollbar; mod virtual_list; pub(crate) use batch::apply_batch_to_tree; @@ -70,6 +72,17 @@ pub(crate) fn to_element_id(id: f64) -> Result { Ok(id as u64) } +/// A scroll offset as the two JS numbers `[x, y]`. Adding `0.0` turns a +/// negative zero into a plain zero. An unscrolled axis can carry `-0.0`, +/// and a JS caller that compares offsets with `Object.is` separates `-0` +/// from `0`. +pub(crate) fn offset_to_js(offset: gpui::Point) -> [f64; 2] { + [ + f64::from(f32::from(offset.x)) + 0.0, + f64::from(f32::from(offset.y)) + 0.0, + ] +} + thread_local! { #[cfg(target_os = "macos")] static MAC_PLATFORM: RefCell>> = const { RefCell::new(None) }; @@ -122,10 +135,7 @@ pub(crate) fn debug_frame_overlay_stats_js( } #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] -fn recv_ui_response( - receiver: std::sync::mpsc::Receiver, - operation: &str, -) -> Result { +fn recv_ui_response(receiver: std::sync::mpsc::Receiver, operation: &str) -> Result { match receiver.recv_timeout(Duration::from_secs(2)) { Ok(response) => Ok(response), Err(RecvTimeoutError::Timeout) => Err(Error::from_reason(format!( @@ -222,6 +232,11 @@ enum UiCommand { id: u64, index: usize, }, + ScrollIntoView { + id: u64, + block: scroll_into_view::Align, + inline: scroll_into_view::Align, + }, GetScrollOffset { id: u64, response: SyncSender>, @@ -343,25 +358,30 @@ async fn run_ui_commands( } refresh_ui_window(window, cx) } + UiCommand::ScrollIntoView { id, block, inline } => { + window + .update(cx, |view, _window, _cx| { + let tree = view.tree.lock().unwrap(); + scroll_into_view::scroll_into_view(&tree, id, block, inline, |id| { + SCROLL_HANDLES.with(|cell| cell.borrow().get(&id).cloned()) + }); + }) + .ok(); + refresh_ui_window(window, cx) + } UiCommand::GetScrollOffset { id, response } => { let offset = VIRTUAL_LIST_STATES .with(|cell| { cell.borrow().get(&id).map(|state| { let offset = state.scroll_px_offset_for_scrollbar(); - [ - f64::from(f32::from(offset.x)), - f64::from(f32::from(offset.y)), - ] + offset_to_js(offset) }) }) .or_else(|| { SCROLL_HANDLES.with(|cell| { cell.borrow().get(&id).map(|handle| { let offset = handle.offset(); - [ - f64::from(f32::from(offset.x)), - f64::from(f32::from(offset.y)), - ] + offset_to_js(offset) }) }) }); @@ -496,13 +516,10 @@ impl GpuixRenderer { input, response: response_sender, })?; - recv_ui_response(response_receiver, "the GPUI UI command")? - .map_err(Error::from_reason) + recv_ui_response(response_receiver, "the GPUI UI command")?.map_err(Error::from_reason) } - fn automation_bounds( - &self, - ) -> Result> { + fn automation_bounds(&self) -> Result> { #[cfg(target_os = "macos")] return Ok(crate::automation::all_bounds()); @@ -522,10 +539,7 @@ impl GpuixRenderer { Err(Error::from_reason("Unsupported operating system")) } - fn element_bounds( - &self, - id: u64, - ) -> Result> { + fn element_bounds(&self, id: u64) -> Result> { #[cfg(target_os = "macos")] return Ok(crate::automation::get_bounds(id)); @@ -1023,6 +1037,45 @@ impl GpuixRenderer { Err(Error::from_reason("Unsupported operating system")) } + /// Scroll every ancestor scroll box so the element shows, like the + /// web `scrollIntoView`. `block` places it on the y axis and + /// `inline` on the x axis: `start`, `center`, `end` or `nearest`. + /// The defaults match the web: `start` and `nearest`. The + /// `scroll-margin` of the element and the `scroll-padding` of each + /// box apply. + #[napi] + pub fn scroll_into_view( + &self, + element_id: f64, + block: Option, + inline: Option, + ) -> Result<()> { + let id = to_element_id(element_id)?; + let block = scroll_into_view::Align::parse(block.as_deref(), scroll_into_view::Align::Start); + let inline = + scroll_into_view::Align::parse(inline.as_deref(), scroll_into_view::Align::Nearest); + #[cfg(target_os = "macos")] + { + let tree = self.tree.lock().unwrap(); + scroll_into_view::scroll_into_view(&tree, id, block, inline, |id| { + SCROLL_HANDLES.with(|cell| cell.borrow().get(&id).cloned()) + }); + drop(tree); + return invalidate_window(); + } + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.send_ui_command(UiCommand::ScrollIntoView { id, block, inline }); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Err(Error::from_reason("Unsupported operating system")) + } + /// Hidden → minimal → full → hidden. #[napi] pub fn cycle_debug_frame_overlay(&self) -> Result { @@ -1299,10 +1352,7 @@ impl GpuixRenderer { .with(|cell| { cell.borrow().get(&id).map(|state| { let offset = state.scroll_px_offset_for_scrollbar(); - vec![ - f64::from(f32::from(offset.x)), - f64::from(f32::from(offset.y)), - ] + offset_to_js(offset).to_vec() }) }) .or_else(|| { @@ -1310,10 +1360,7 @@ impl GpuixRenderer { let handles = cell.borrow(); handles.get(&id).map(|handle| { let offset = handle.offset(); - vec![ - f64::from(f32::from(offset.x)), - f64::from(f32::from(offset.y)), - ] + offset_to_js(offset).to_vec() }) }) })); @@ -1323,8 +1370,7 @@ impl GpuixRenderer { let (response, receiver) = sync_channel(1); self.send_ui_command(UiCommand::GetScrollOffset { id, response })?; return Ok( - recv_ui_response(receiver, "the GPUI scroll query")? - .map(|[x, y]| vec![x, y]), + recv_ui_response(receiver, "the GPUI scroll query")?.map(|[x, y]| vec![x, y]) ); } @@ -1631,6 +1677,8 @@ pub(crate) struct GpuixView { pub(crate) scroll_handles: HashMap, /// Native animation clocks keyed by retained element ID. pub(crate) motion_states: HashMap, + /// What each scroll box's scrollbar remembers between frames. + pub(crate) scrollbars: scrollbar::States, /// Live text selection, shared with the paint closures and the napi methods. pub(crate) selection: SharedSelection, /// Keeps the cmd-c observer alive for as long as the view. @@ -1668,6 +1716,7 @@ impl GpuixView { custom_registry: CustomElementRegistry::with_defaults(), scroll_handles: HashMap::new(), motion_states: HashMap::new(), + scrollbars: HashMap::new(), selection, _copy_subscription: copy_subscription, virtual_lists: HashMap::new(), @@ -1746,6 +1795,7 @@ impl GpuixView { custom_registry: &mut self.custom_registry, virtual_lists: &mut self.virtual_lists, motion_states: &mut self.motion_states, + scrollbars: &mut self.scrollbars, now, motion_active: &mut motion_active, selection: self.selection.clone(), @@ -1800,10 +1850,7 @@ impl GpuixView { .get(&id)? .state .scroll_px_offset_for_scrollbar(); - Some([ - f64::from(f32::from(offset.x)), - f64::from(f32::from(offset.y)), - ]) + Some(offset_to_js(offset)) } pub(crate) fn reveal_virtual_list_ancestor(&self, id: u64) -> bool { @@ -1974,6 +2021,7 @@ impl gpui::Render for GpuixView { custom_registry: &mut self.custom_registry, virtual_lists: &mut self.virtual_lists, motion_states: &mut self.motion_states, + scrollbars: &mut self.scrollbars, now, motion_active: &mut motion_active, selection: self.selection.clone(), diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 8fbc9659..966a53ba 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -27,6 +27,7 @@ pub(super) struct BuildCtx<'a> { pub custom_registry: &'a mut CustomElementRegistry, pub virtual_lists: &'a mut HashMap, pub motion_states: &'a mut HashMap, + pub scrollbars: &'a mut super::scrollbar::States, pub now: std::time::Instant, pub motion_active: &'a mut bool, pub selection: SharedSelection, @@ -361,13 +362,15 @@ pub(crate) fn build_div( // 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; + let mut scrollbar = None; 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 (resolved_x, resolved_y) = super::scrollbar::used_overflow(resolved_x, resolved_y); - let needs_scroll_x = resolved_x == Some("scroll"); - let needs_scroll_y = resolved_y == Some("scroll"); + let needs_scroll_x = super::scrollbar::scrolls(resolved_x); + let needs_scroll_y = super::scrollbar::scrolls(resolved_y); if needs_scroll_x && needs_scroll_y { el = el.overflow_scroll(); @@ -391,13 +394,42 @@ pub(crate) fn build_div( .entry(element.id) .or_insert_with(gpui::ScrollHandle::new); el = el.track_scroll(handle); + + // The scrollbar. Classic bars reserve a gutter in the layout, + // which taffy takes as one width for both axes. + let mode = super::scrollbar::Mode::current(cx); + if let Some(spec) = super::scrollbar::Spec::from_style(style, mode) { + let state = ctx.scrollbars.entry(element.id).or_default().clone(); + let reserved = spec.reserved(state.borrow().overflowed); + let gutter = reserved.x.max(reserved.y); + if gutter > gpui::px(0.0) { + el = el.scrollbar_width(gutter); + if spec.both_edges() { + let padding = &mut el.style().padding; + if needs_scroll_y { + padding.left = Some(add_pixels(padding.left, gutter)); + } + if needs_scroll_x { + padding.top = Some(add_pixels(padding.top, gutter)); + } + } + } + scrollbar = Some(super::scrollbar::Scrollbar::new( + spec, + handle.clone(), + state, + ctx.now, + )); + } } else { // Element is no longer scrollable — remove stale handle. ctx.scroll_handles.remove(&element.id); + ctx.scrollbars.remove(&element.id); } } else { // No style at all — remove stale handle if it existed. ctx.scroll_handles.remove(&element.id); + ctx.scrollbars.remove(&element.id); } // If a FocusHandle was pre-created for this element (by sync_focus_handles), @@ -629,9 +661,25 @@ pub(crate) fn build_div( }; } + // Last, so it paints over the content and takes the mouse first. + if let Some(scrollbar) = scrollbar { + el = el.child(scrollbar); + } + el.into_any_element() } +/// `length` plus `extra`. A pixel length adds. Any other unit gives way, +/// because the sum would need the box's size to resolve. +fn add_pixels(length: Option, extra: gpui::Pixels) -> gpui::DefiniteLength { + match length { + Some(gpui::DefiniteLength::Absolute(gpui::AbsoluteLength::Pixels(pixels))) => { + (pixels + extra).into() + } + _ => extra.into(), + } +} + /// 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 { diff --git a/packages/native/src/renderer/scroll_into_view.rs b/packages/native/src/renderer/scroll_into_view.rs new file mode 100644 index 00000000..e2d3f747 --- /dev/null +++ b/packages/native/src/renderer/scroll_into_view.rs @@ -0,0 +1,213 @@ +//! CSS `scrollIntoView` with `scroll-margin` and `scroll-padding`. +//! +//! Each ancestor scroll box scrolls in turn, nearest first, the way a +//! browser walks the chain. The `scroll-margin` of the element grows +//! the rectangle a box brings into view. The `scroll-padding` of the +//! box shrinks the viewport the rectangle lands in. Both take a number +//! of pixels or `"Npx"` text, alone or as the CSS one-to-four +//! shorthand. The bounds come from the last painted frame. + +use gpui::{point, px, Point, ScrollHandle}; + +use crate::retained_tree::RetainedTree; +use crate::style::{Numeric, StyleDesc}; + +/// Where the element lands in the viewport on one axis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Align { + Start, + Center, + End, + Nearest, +} + +impl Align { + /// The `scrollIntoView` words `start`, `center`, `end` and + /// `nearest`. An unknown word falls back to `default`. + pub(crate) fn parse(word: Option<&str>, default: Align) -> Align { + match word.map(str::trim) { + Some("start") => Align::Start, + Some("center") => Align::Center, + Some("end") => Align::End, + Some("nearest") => Align::Nearest, + _ => default, + } + } +} + +/// An absolute rectangle, as two corners. +#[derive(Clone, Copy)] +struct Rect { + start: Point, + end: Point, +} + +impl Rect { + fn from_bounds(bounds: crate::automation::ElementBounds) -> Self { + let start = point(bounds.x as f32, bounds.y as f32); + Self { + start, + end: point( + start.x + bounds.width as f32, + start.y + bounds.height as f32, + ), + } + } +} + +/// One length: a number of pixels or `"Npx"`. Anything else is zero. +fn length(value: &str) -> f32 { + let value = value.trim(); + let value = value.strip_suffix("px").unwrap_or(value); + value.trim().parse().unwrap_or(0.0) +} + +/// The CSS one-to-four shorthand, as top, right, bottom and left. +fn shorthand(value: Option<&Numeric>) -> [f32; 4] { + let words: Vec = match value { + None => return [0.0; 4], + Some(Numeric::Number(number)) => return [*number as f32; 4], + Some(Numeric::Text(text)) => text.split_whitespace().map(length).collect(), + }; + match words[..] { + [all] => [all; 4], + [vertical, horizontal] => [vertical, horizontal, vertical, horizontal], + [top, horizontal, bottom] => [top, horizontal, bottom, horizontal], + [top, right, bottom, left] => [top, right, bottom, left], + _ => [0.0; 4], + } +} + +/// A per-side value over the shorthand. +fn side(long: Option<&Numeric>, short: f32) -> f32 { + match long { + None => short, + Some(Numeric::Number(number)) => *number as f32, + Some(Numeric::Text(text)) => length(text), + } +} + +/// The `scroll-margin` of the element, as top, right, bottom and left. +fn scroll_margin(style: Option<&StyleDesc>) -> [f32; 4] { + let Some(style) = style else { return [0.0; 4] }; + let base = shorthand(style.scroll_margin.as_ref()); + [ + side(style.scroll_margin_top.as_ref(), base[0]), + side(style.scroll_margin_right.as_ref(), base[1]), + side(style.scroll_margin_bottom.as_ref(), base[2]), + side(style.scroll_margin_left.as_ref(), base[3]), + ] +} + +/// The `scroll-padding` of a scroll box, in the same order. +fn scroll_padding(style: Option<&StyleDesc>) -> [f32; 4] { + let Some(style) = style else { return [0.0; 4] }; + let base = shorthand(style.scroll_padding.as_ref()); + [ + side(style.scroll_padding_top.as_ref(), base[0]), + side(style.scroll_padding_right.as_ref(), base[1]), + side(style.scroll_padding_bottom.as_ref(), base[2]), + side(style.scroll_padding_left.as_ref(), base[3]), + ] +} + +/// How far the content must move on one axis, in pixels. +fn axis_delta(align: Align, start: f32, end: f32, port_start: f32, port_end: f32) -> f32 { + match align { + Align::Start => start - port_start, + Align::End => end - port_end, + Align::Center => (start + end) / 2.0 - (port_start + port_end) / 2.0, + Align::Nearest => { + if start >= port_start && end <= port_end { + 0.0 + } else if (start - port_start).abs() <= (end - port_end).abs() { + start - port_start + } else { + end - port_end + } + } + } +} + +/// Scroll every ancestor scroll box of `target` so the element shows. +/// Returns true when an offset changed. +pub(crate) fn scroll_into_view( + tree: &RetainedTree, + target: u64, + block: Align, + inline: Align, + handle_for: impl Fn(u64) -> Option, +) -> bool { + let Some(bounds) = crate::automation::get_bounds(target) else { + return false; + }; + let mut rect = Rect::from_bounds(bounds); + let style = |id: u64| tree.elements.get(&id).and_then(|el| el.style.as_deref()); + let margin = scroll_margin(style(target)); + rect.start.x -= margin[3]; + rect.start.y -= margin[0]; + rect.end.x += margin[1]; + rect.end.y += margin[2]; + + let mut moved = false; + let mut current = tree.elements.get(&target).and_then(|el| el.parent); + while let Some(id) = current { + if let (Some(handle), Some(bounds)) = (handle_for(id), crate::automation::get_bounds(id)) { + let mut port = Rect::from_bounds(bounds); + let padding = scroll_padding(style(id)); + port.start.x += padding[3]; + port.start.y += padding[0]; + port.end.x -= padding[1]; + port.end.y -= padding[2]; + + let delta = point( + axis_delta(inline, rect.start.x, rect.end.x, port.start.x, port.end.x), + axis_delta(block, rect.start.y, rect.end.y, port.start.y, port.end.y), + ); + let old = handle.offset(); + let max = handle.max_offset(); + let new = point( + (old.x - px(delta.x)).max(-max.x).min(px(0.0)), + (old.y - px(delta.y)).max(-max.y).min(px(0.0)), + ); + if new != old { + handle.set_offset(new); + moved = true; + } + // The content of the box moves with the offset, and the + // rectangle of the element moves with the content. + rect.start.x += f32::from(new.x - old.x); + rect.end.x += f32::from(new.x - old.x); + rect.start.y += f32::from(new.y - old.y); + rect.end.y += f32::from(new.y - old.y); + } + current = tree.elements.get(&id).and_then(|el| el.parent); + } + moved +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shorthand_expands_the_css_way() { + let sides = |text: &str| shorthand(Some(&Numeric::Text(text.to_string()))); + assert_eq!(sides("8px"), [8.0; 4]); + assert_eq!(sides("8px 12px"), [8.0, 12.0, 8.0, 12.0]); + assert_eq!(sides("1px 2px 3px"), [1.0, 2.0, 3.0, 2.0]); + assert_eq!(sides("1px 2px 3px 4px"), [1.0, 2.0, 3.0, 4.0]); + assert_eq!(shorthand(Some(&Numeric::Number(6.0))), [6.0; 4]); + } + + #[test] + fn nearest_moves_the_short_way_or_not_at_all() { + let near = |start, end| axis_delta(Align::Nearest, start, end, 100.0, 200.0); + assert_eq!(near(120.0, 180.0), 0.0); + assert_eq!(near(40.0, 80.0), -60.0); + assert_eq!(near(240.0, 280.0), 80.0); + assert_eq!(axis_delta(Align::Start, 40.0, 80.0, 100.0, 200.0), -60.0); + assert_eq!(axis_delta(Align::End, 240.0, 280.0, 100.0, 200.0), 80.0); + assert_eq!(axis_delta(Align::Center, 90.0, 110.0, 100.0, 200.0), -50.0); + } +} diff --git a/packages/native/src/renderer/scrollbar.rs b/packages/native/src/renderer/scrollbar.rs new file mode 100644 index 00000000..d136456f --- /dev/null +++ b/packages/native/src/renderer/scrollbar.rs @@ -0,0 +1,836 @@ +//! Scrollbars for scroll boxes. +//! +//! GPUI clips and scrolls a box but paints no bar, so this element paints +//! one. It sits last among the children of the box, takes no layout space, +//! and reads the box's `ScrollHandle` for the viewport, the content size +//! and the offset. The OS picks the kind of bar. Overlay bars float over +//! the content and fade out after a scroll. Classic bars keep a track and +//! reserve a gutter in the layout. `cx.should_auto_hide_scrollbars()` +//! tells the two apart, and `GPUIX_SCROLLBARS=overlay|classic` in the +//! environment overrides it, which keeps tests the same on every machine. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gpui::{ + hsla, point, px, size, Along, App, Axis, BorderStyle, Bounds, Corners, Edges, Element, + ElementId, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, Style, Window, +}; + +use crate::style::StyleDesc; + +/// Overlay bars keep the mouse this long after the last scroll. +const HIDE_DELAY: Duration = Duration::from_secs(1); +/// Then they fade over this long. +const FADE: Duration = Duration::from_millis(400); +/// A thumb never gets shorter than this. +const MIN_THUMB: Pixels = px(20.0); + +/// Which kind of bar the OS asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + /// Floats over the content, fades out after a scroll, reserves nothing. + Overlay, + /// Always there, with a track, in a gutter reserved in the layout. + Classic, +} + +impl Mode { + /// The mode for this window, with the environment override on top. + pub(crate) fn current(cx: &App) -> Self { + match std::env::var("GPUIX_SCROLLBARS").as_deref() { + Ok("overlay") => Mode::Overlay, + Ok("classic") => Mode::Classic, + _ if cx.should_auto_hide_scrollbars() => Mode::Overlay, + _ => Mode::Classic, + } + } +} + +/// CSS `scrollbar-width`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Thickness { + Auto, + Thin, + None, +} + +/// CSS `scrollbar-gutter`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Gutter { + Auto, + Stable, + StableBothEdges, +} + +/// What one scroll box asks of its bars, resolved from its style. +#[derive(Clone, Debug)] +pub(crate) struct Spec { + mode: Mode, + thickness: Thickness, + gutter: Gutter, + thumb: Option, + track: Option, + /// Whether each axis scrolls at all. + scrolls: Point, + /// `overflow: scroll` on the axis, so a classic bar shows even when + /// the content fits. `auto` shows one only when it overflows. + always: Point, +} + +/// The overflow words that make a scroll box on an axis. +/// The used per-axis `overflow` words. CSS never mixes `visible` or +/// `clip` on one axis with a scrolling word on the other: when one axis +/// is neither `visible` nor `clip`, `visible` on the other computes to +/// `auto` and `clip` computes to `hidden`. +pub(crate) fn used_overflow<'a>( + x: Option<&'a str>, + y: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a str>) { + let keeps_word = |word: Option<&str>| { + matches!(word, None | Some("visible") | Some("clip")) + }; + let coerce = |word: Option<&'a str>, other: Option<&'a str>| { + if keeps_word(other) { + return word; + } + match word { + None | Some("visible") => Some("auto"), + Some("clip") => Some("hidden"), + word => word, + } + }; + (coerce(x, y), coerce(y, x)) +} + +pub(crate) fn scrolls(word: Option<&str>) -> bool { + matches!(word, Some("scroll") | Some("auto")) +} + +impl Spec { + /// The spec for a box, or `None` when no axis scrolls. + pub(crate) fn from_style(style: &StyleDesc, mode: Mode) -> Option { + let x = style.overflow_x.as_deref().or(style.overflow.as_deref()); + let y = style.overflow_y.as_deref().or(style.overflow.as_deref()); + let (x, y) = used_overflow(x, y); + let scrolls = point(scrolls(x), scrolls(y)); + if !scrolls.x && !scrolls.y { + return None; + } + let thickness = match style.scrollbar_width.as_deref().map(str::trim) { + Some("thin") => Thickness::Thin, + Some("none") => Thickness::None, + _ => Thickness::Auto, + }; + let gutter = match style.scrollbar_gutter.as_deref().map(str::trim) { + Some("stable") => Gutter::Stable, + Some("stable both-edges") | Some("both-edges stable") => Gutter::StableBothEdges, + _ => Gutter::Auto, + }; + let (thumb, track) = style + .scrollbar_color + .as_deref() + .map(scrollbar_colors) + .unwrap_or((None, None)); + Some(Self { + mode, + thickness, + gutter, + thumb, + track, + scrolls, + always: point(x == Some("scroll"), y == Some("scroll")), + }) + } + + /// The width of a classic gutter, or zero for overlay bars and + /// `scrollbar-width: none`. + fn gutter_width(&self) -> Pixels { + match (self.mode, self.thickness) { + (Mode::Overlay, _) | (_, Thickness::None) => px(0.0), + (Mode::Classic, Thickness::Auto) => px(15.0), + (Mode::Classic, Thickness::Thin) => px(8.0), + } + } + + /// The gutter to reserve at the end of each axis this frame, given + /// which axes overflowed at the last one. `overflow: scroll` and + /// `scrollbar-gutter: stable` reserve it at all times, `auto` only + /// while a bar shows. Both are zero for overlay bars, as in CSS. + pub(crate) fn reserved(&self, overflowed: Point) -> Point { + let width = self.gutter_width(); + let reserve = |axis: Axis| { + let scrolls = self.scrolls.along(axis); + let stable = self.always.along(axis) || self.gutter != Gutter::Auto; + if scrolls && (stable || overflowed.along(axis)) { + width + } else { + px(0.0) + } + }; + point(reserve(Axis::Horizontal), reserve(Axis::Vertical)) + } + + /// Whether `scrollbar-gutter: stable both-edges` asks for a second + /// gutter at the start of the axes. + pub(crate) fn both_edges(&self) -> bool { + self.gutter == Gutter::StableBothEdges && self.gutter_width() > px(0.0) + } + + /// The thickness of the thumb, wider while the mouse is on the bar. + fn thumb_thickness(&self, hovered: bool) -> Pixels { + match (self.mode, self.thickness, hovered) { + (_, Thickness::None, _) => px(0.0), + (Mode::Overlay, Thickness::Auto, false) => px(7.0), + (Mode::Overlay, Thickness::Auto, true) => px(11.0), + (Mode::Overlay, Thickness::Thin, false) => px(4.0), + (Mode::Overlay, Thickness::Thin, true) => px(6.0), + (Mode::Classic, Thickness::Auto, _) => px(9.0), + (Mode::Classic, Thickness::Thin, _) => px(6.0), + } + } + + /// The strip along one edge that a bar lives in. For classic bars it + /// is the gutter. For overlay bars it is the widest the thumb gets plus + /// its inset from the edge. + fn strip_thickness(&self) -> Pixels { + match self.mode { + Mode::Classic => self.gutter_width(), + Mode::Overlay => self.thumb_thickness(true) + px(2.0) * 2.0, + } + } + + fn thumb_color(&self, state: ThumbLook) -> Hsla { + let base = self.thumb.unwrap_or(hsla(0.0, 0.0, 0.5, 0.55)); + let alpha = match state { + ThumbLook::Rest => base.a, + ThumbLook::Hovered => (base.a * 1.3).min(1.0), + ThumbLook::Dragged => (base.a * 1.5).min(1.0), + }; + Hsla { a: alpha, ..base } + } + + fn track_color(&self) -> Hsla { + self.track.unwrap_or(hsla(0.0, 0.0, 0.5, 0.12)) + } +} + +#[derive(Clone, Copy)] +enum ThumbLook { + Rest, + Hovered, + Dragged, +} + +/// `scrollbar-color: `, or `auto`. +fn scrollbar_colors(value: &str) -> (Option, Option) { + let words = split_top_level(value); + let color = |index: usize| { + words + .get(index) + .and_then(|word| crate::color::parse_color_rgba(word)) + .map(Hsla::from) + }; + (color(0), color(1)) +} + +/// Splits on spaces outside parentheses, so `rgb(0 0 0 / 0.5) white` is +/// two words. +fn split_top_level(value: &str) -> Vec<&str> { + let mut words = Vec::new(); + let mut depth = 0usize; + let mut start = None; + for (index, ch) in value.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ => {} + } + if ch.is_whitespace() && depth == 0 { + if let Some(from) = start.take() { + words.push(&value[from..index]); + } + } else if start.is_none() { + start = Some(index); + } + } + if let Some(from) = start { + words.push(&value[from..]); + } + words +} + +/// What a bar remembers between frames. +#[derive(Default)] +pub(crate) struct State { + /// The offset at the last frame, to notice a scroll. + last_offset: Option>, + /// When the box last scrolled. Overlay bars show for a while after. + last_scroll: Option, + /// Which axes had more content than room at the last frame. + pub(crate) overflowed: Point, + /// The axis whose strip the mouse is on. + hovered: Option, + /// A drag of the thumb: the axis and where the mouse took hold of + /// the thumb, from its start. + drag: Option<(Axis, Pixels)>, +} + +/// The shared states, one per scroll box, kept on the view. +pub(crate) type States = HashMap>>; + +/// Where a bar's parts are this frame. +#[derive(Clone, Copy)] +struct Geometry { + axis: Axis, + /// The strip along the edge that takes the mouse. + strip: Bounds, + /// The part of the strip a thumb can move in. + track: Bounds, + /// The thumb, or `None` when the content fits. + thumb: Option>, +} + +impl Geometry { + /// The offset for a thumb start at `along` in the track. + fn offset_for_thumb_start(&self, along: Pixels, max_offset: Pixels) -> Pixels { + let Some(thumb) = self.thumb else { + return px(0.0); + }; + let room = self.track.size.along(self.axis) - thumb.size.along(self.axis); + if room <= px(0.0) { + return px(0.0); + } + let fraction = (f32::from(along - self.track.origin.along(self.axis)) / f32::from(room)) + .clamp(0.0, 1.0); + -max_offset * fraction + } +} + +/// The element the box adopts as its last child. It takes no layout +/// space and hands the bar to a deferred draw, which paints after the +/// whole tree. A sibling that overlaps the box, such as a blurred +/// header, then cannot cover or blur the bar. The deferred draw keeps +/// the content mask of the box, so an ancestor still clips the bar. +pub(crate) struct Scrollbar { + bar: Option, +} + +impl Scrollbar { + pub(crate) fn new( + spec: Spec, + handle: ScrollHandle, + state: Rc>, + now: Instant, + ) -> Self { + Self { + bar: Some( + Bar { + spec, + handle, + state, + now, + } + .into_any_element(), + ), + } + } +} + +impl Element for Scrollbar { + 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, ()) { + (self.bar.as_mut().unwrap().request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + window: &mut Window, + _cx: &mut App, + ) { + let bar = self.bar.take().unwrap(); + let mask = window.content_mask(); + window.defer_draw(bar, window.element_offset(), 0, Some(mask)); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + _window: &mut Window, + _cx: &mut App, + ) { + } +} + +/// The bar itself: the strips, the thumbs and the mouse handling. +struct Bar { + spec: Spec, + handle: ScrollHandle, + state: Rc>, + now: Instant, +} + +impl Bar { + + /// How much of an overlay bar shows, from 0 to 1. Classic bars are + /// always 1. + fn opacity(&self, state: &State) -> f32 { + if self.spec.mode == Mode::Classic || state.drag.is_some() || state.hovered.is_some() { + return 1.0; + } + let Some(last) = state.last_scroll else { + return 0.0; + }; + let since = self.now.saturating_duration_since(last); + if since < HIDE_DELAY { + 1.0 + } else if since < HIDE_DELAY + FADE { + 1.0 - (since - HIDE_DELAY).as_secs_f32() / FADE.as_secs_f32() + } else { + 0.0 + } + } + + /// Whether the bar on `axis` takes any part in this frame. + fn shows(&self, axis: Axis, overflowed: Point) -> bool { + if !self.spec.scrolls.along(axis) || self.spec.thickness == Thickness::None { + return false; + } + match self.spec.mode { + Mode::Classic => overflowed.along(axis) || self.spec.always.along(axis), + Mode::Overlay => overflowed.along(axis), + } + } + + /// The parts of the bar on `axis`, given whether the other axis also + /// shows one and takes the corner. + fn geometry(&self, axis: Axis, other_shows: bool, hovered: bool) -> Geometry { + let bounds = self.handle.bounds(); + let offset = self.handle.offset(); + let max_offset = self.handle.max_offset(); + let strip_thickness = self.spec.strip_thickness(); + let thumb_thickness = self.spec.thumb_thickness(hovered); + // Where the thumb sits across the axis. Classic thumbs centre in + // the gutter. Overlay thumbs keep a 2px inset from the edge and + // grow inward when hovered. + let inset = match self.spec.mode { + Mode::Classic => (strip_thickness - thumb_thickness) / 2.0, + Mode::Overlay => px(2.0), + }; + let corner = if other_shows { + strip_thickness + } else { + px(0.0) + }; + let end_inset = match self.spec.mode { + Mode::Classic => px(0.0), + Mode::Overlay => px(2.0), + }; + let (strip, track) = match axis { + Axis::Vertical => { + let strip = Bounds::new( + point(bounds.right() - strip_thickness, bounds.top()), + size(strip_thickness, bounds.size.height - corner), + ); + let track = Bounds::new( + point( + bounds.right() - inset - thumb_thickness, + strip.top() + end_inset, + ), + size(thumb_thickness, strip.size.height - end_inset * 2.0), + ); + (strip, track) + } + Axis::Horizontal => { + let strip = Bounds::new( + point(bounds.left(), bounds.bottom() - strip_thickness), + size(bounds.size.width - corner, strip_thickness), + ); + let track = Bounds::new( + point( + strip.left() + end_inset, + bounds.bottom() - inset - thumb_thickness, + ), + size(strip.size.width - end_inset * 2.0, thumb_thickness), + ); + (strip, track) + } + }; + let max = max_offset.along(axis); + let thumb = (max > px(0.0)).then(|| { + let viewport = bounds.size.along(axis); + let content = viewport + max; + let track_len = track.size.along(axis); + let len = (track_len * (f32::from(viewport) / f32::from(content))) + .max(MIN_THUMB) + .min(track_len); + let scrolled = (f32::from(-offset.along(axis)) / f32::from(max)).clamp(0.0, 1.0); + let start = (track_len - len) * scrolled; + Bounds::new( + track.origin.apply_along(axis, |origin| origin + start), + track.size.apply_along(axis, |_| len), + ) + }); + Geometry { + axis, + strip, + track, + thumb, + } + } +} + +/// The mouse listeners for one frame. They hold the frame's geometry, so +/// a hit test is a `contains` on the bounds. The scroll offset they set +/// is clamped by the box at its next prepaint. +fn register_mouse( + bars: Vec, + handle: ScrollHandle, + state: Rc>, + window: &mut Window, +) { + let viewport = handle.bounds(); + let strips: Vec<(Axis, Bounds)> = bars.iter().map(|g| (g.axis, g.strip)).collect(); + + // Take hold of a thumb, or jump a page on a click in the track. + let down_bars = bars.clone(); + let down_handle = handle.clone(); + let down_state = state.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if !phase.bubble() || event.button != MouseButton::Left { + return; + } + for bar in &down_bars { + if !bar.strip.contains(&event.position) { + continue; + } + let along = event.position.along(bar.axis); + let offset = down_handle.offset(); + if let Some(thumb) = bar.thumb.filter(|thumb| thumb.contains(&event.position)) { + down_state.borrow_mut().drag = + Some((bar.axis, along - thumb.origin.along(bar.axis))); + } else if let Some(thumb) = bar.thumb { + let page = viewport.size.along(bar.axis) * 0.9; + let max = down_handle.max_offset().along(bar.axis); + let current = offset.along(bar.axis); + let next = if along < thumb.origin.along(bar.axis) { + current + page + } else { + current - page + }; + let offset = offset.apply_along(bar.axis, |_| next.clamp(-max, px(0.0))); + down_handle.set_offset(offset); + } + cx.stop_propagation(); + window.refresh(); + return; + } + }); + + // Move the thumb, and notice the mouse coming onto or leaving a strip. + let move_bars = bars; + let move_strips = strips.clone(); + let move_handle = handle.clone(); + let move_state = state.clone(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, _cx| { + if !phase.bubble() { + return; + } + let drag = move_state.borrow().drag; + if let Some((axis, grab)) = drag { + if let Some(bar) = move_bars.iter().find(|bar| bar.axis == axis) { + let start = event.position.along(axis) - grab; + let max = move_handle.max_offset().along(axis); + let offset = move_handle + .offset() + .apply_along(axis, |_| bar.offset_for_thumb_start(start, max)); + move_handle.set_offset(offset); + window.refresh(); + } + return; + } + let hovered = move_strips + .iter() + .find(|(_, strip)| strip.contains(&event.position)) + .map(|(axis, _)| *axis); + let mut state = move_state.borrow_mut(); + if state.hovered != hovered { + state.hovered = hovered; + window.refresh(); + } + }); + + window.on_mouse_event(move |event: &MouseUpEvent, phase, window, _cx| { + if phase.bubble() && event.button == MouseButton::Left { + let mut state = state.borrow_mut(); + if state.drag.take().is_some() { + state.hovered = strips + .iter() + .find(|(_, strip)| strip.contains(&event.position)) + .map(|(axis, _)| *axis); + window.refresh(); + } + } + }); +} + +impl Element for Bar { + 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, ()) { + // Out of the flow and empty, so the box lays out as if the bar + // were not there. The bar paints from the handle's bounds instead. + let mut style = Style::default(); + style.position = gpui::Position::Absolute; + style.inset.top = px(0.0).into(); + style.inset.left = px(0.0).into(); + style.size = size(px(0.0).into(), px(0.0).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 box set its bounds and max offset on the handle just before + // its children prepaint, so they are this frame's. + let offset = self.handle.offset(); + let max_offset = self.handle.max_offset(); + let mut state = self.state.borrow_mut(); + if state.last_offset.is_some_and(|last| last != offset) { + state.last_scroll = Some(self.now); + } + state.last_offset = Some(offset); + state.overflowed = point(max_offset.x > px(0.0), max_offset.y > px(0.0)); + let opacity = self.opacity(&state); + if self.spec.mode == Mode::Overlay && opacity > 0.0 && opacity < 1.0 { + window.request_animation_frame(); + } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + _cx: &mut App, + ) { + let state = self.state.borrow(); + let opacity = self.opacity(&state); + if opacity <= 0.0 { + return; + } + let overflowed = state.overflowed; + let shows = point( + self.shows(Axis::Horizontal, overflowed), + self.shows(Axis::Vertical, overflowed), + ); + let mut bars = Vec::new(); + for axis in [Axis::Vertical, Axis::Horizontal] { + if !shows.along(axis) { + continue; + } + let other = match axis { + Axis::Vertical => Axis::Horizontal, + Axis::Horizontal => Axis::Vertical, + }; + let hovered = + state.hovered == Some(axis) || matches!(state.drag, Some((a, _)) if a == axis); + let bar = self.geometry(axis, shows.along(other), hovered); + + // The track. Classic bars always have one. Overlay bars show + // one while the mouse is on the strip, like macOS. + let track_shows = self.spec.mode == Mode::Classic || hovered; + if track_shows { + let mut color = self.spec.track_color(); + color.a *= opacity; + let (radius, border) = match self.spec.mode { + Mode::Classic => (px(0.0), px(1.0)), + Mode::Overlay => (bar.strip.size.along(other) / 2.0, px(0.0)), + }; + let mut border_widths = Edges::default(); + match axis { + Axis::Vertical => border_widths.left = border, + Axis::Horizontal => border_widths.top = border, + } + let mut border_color = color; + border_color.a = (border_color.a * 1.5).min(1.0); + window.paint_quad(gpui::quad( + bar.strip, + Corners::all(radius), + color, + border_widths, + border_color, + BorderStyle::default(), + )); + } + + if let Some(thumb) = bar.thumb { + let look = match state.drag { + Some((a, _)) if a == axis => ThumbLook::Dragged, + _ if hovered => ThumbLook::Hovered, + _ => ThumbLook::Rest, + }; + let mut color = self.spec.thumb_color(look); + color.a *= opacity; + let radius = thumb.size.along(other) / 2.0; + window.paint_quad(gpui::fill(thumb, color).corner_radii(Corners::all(radius))); + } + bars.push(bar); + } + // The square where two classic bars meet, in the track colour, + // as a browser paints it. + if self.spec.mode == Mode::Classic && shows.x && shows.y { + let bounds = self.handle.bounds(); + let thickness = self.spec.strip_thickness(); + let corner = Bounds::new( + point(bounds.right() - thickness, bounds.bottom() - thickness), + size(thickness, thickness), + ); + window.paint_quad(gpui::fill(corner, self.spec.track_color())); + } + drop(state); + if !bars.is_empty() { + register_mouse(bars, self.handle.clone(), self.state.clone(), window); + } + } +} + +impl IntoElement for Scrollbar { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl IntoElement for Bar { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn colors_split_outside_parentheses() { + let words = split_top_level("rgb(0 0 0 / 0.5) white"); + assert_eq!(words, vec!["rgb(0 0 0 / 0.5)", "white"]); + let (thumb, track) = scrollbar_colors("rgb(0 0 0 / 0.5) white"); + assert!(thumb.is_some_and(|c| (c.a - 0.5).abs() < 0.01)); + assert!(track.is_some_and(|c| c.l > 0.99)); + assert_eq!(scrollbar_colors("auto"), (None, None)); + } + + fn spec(overflow: &str, extra: impl FnOnce(&mut StyleDesc), mode: Mode) -> Spec { + let mut style = StyleDesc::default(); + style.overflow = Some(overflow.to_string()); + extra(&mut style); + Spec::from_style(&style, mode).expect("a scroll box") + } + + #[test] + fn classic_gutter_follows_overflow_and_gutter_words() { + let no = point(false, false); + let yes = point(true, true); + assert_eq!( + spec("scroll", |_| {}, Mode::Classic).reserved(no), + point(px(15.0), px(15.0)) + ); + assert_eq!( + spec("auto", |_| {}, Mode::Classic).reserved(no), + point(px(0.0), px(0.0)) + ); + assert_eq!( + spec("auto", |_| {}, Mode::Classic).reserved(yes), + point(px(15.0), px(15.0)) + ); + let stable = spec( + "auto", + |s| s.scrollbar_gutter = Some("stable".into()), + Mode::Classic, + ); + assert_eq!(stable.reserved(no), point(px(15.0), px(15.0))); + let thin = spec( + "scroll", + |s| s.scrollbar_width = Some("thin".into()), + Mode::Classic, + ); + assert_eq!(thin.reserved(no), point(px(8.0), px(8.0))); + let none = spec( + "scroll", + |s| s.scrollbar_width = Some("none".into()), + Mode::Classic, + ); + assert_eq!(none.reserved(yes), point(px(0.0), px(0.0))); + assert_eq!( + spec("scroll", |_| {}, Mode::Overlay).reserved(yes), + point(px(0.0), px(0.0)) + ); + } + + #[test] + fn a_hidden_axis_has_no_bar() { + let mut style = StyleDesc::default(); + style.overflow_y = Some("scroll".into()); + style.overflow_x = Some("hidden".into()); + let spec = Spec::from_style(&style, Mode::Classic).unwrap(); + assert_eq!(spec.reserved(point(true, true)), point(px(0.0), px(15.0))); + style.overflow_y = Some("clip".into()); + assert!(Spec::from_style(&style, Mode::Classic).is_none()); + } +} diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 988759a1..efc54553 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -487,6 +487,25 @@ style_desc! { overscroll_behavior: Option = "overscrollBehavior", overscroll_behavior_x: Option = "overscrollBehaviorX", overscroll_behavior_y: Option = "overscrollBehaviorY", + /// `auto`, `thin` or `none`. + scrollbar_width: Option = "scrollbarWidth", + /// `auto`, or a thumb colour and a track colour. + scrollbar_color: Option = "scrollbarColor", + /// `auto`, `stable` or `stable both-edges`. + scrollbar_gutter: Option = "scrollbarGutter", + /// Space `scrollIntoView` keeps around the element, a number of + /// pixels or `"Npx"`, alone or as the one-to-four shorthand. + scroll_margin: Option = "scrollMargin", + scroll_margin_top: Option = "scrollMarginTop", + scroll_margin_right: Option = "scrollMarginRight", + scroll_margin_bottom: Option = "scrollMarginBottom", + scroll_margin_left: Option = "scrollMarginLeft", + /// Space `scrollIntoView` keeps inside this scroll box. + scroll_padding: Option = "scrollPadding", + scroll_padding_top: Option = "scrollPaddingTop", + scroll_padding_right: Option = "scrollPaddingRight", + scroll_padding_bottom: Option = "scrollPaddingBottom", + scroll_padding_left: Option = "scrollPaddingLeft", // Cursor cursor: Option = "cursor", diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index 938df240..70a7cb75 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -637,17 +637,22 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope: el = el.cursor(cursor); } // Overflow: hidden is on the Styled trait, so we handle it here. - // overflow: "scroll" requires StatefulInteractiveElement — handled in build_div(). + // overflow: "scroll" and "auto" need StatefulInteractiveElement, so build_div() handles them. // 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()); + let (resolved_x, resolved_y) = + crate::renderer::scrollbar::used_overflow(resolved_x, resolved_y); // Only apply hidden here — scroll is handled in build_div. - if resolved_x == Some("hidden") && resolved_y == Some("hidden") { + // `clip` clips like `hidden`. The difference in CSS, that `clip` + // is not a scroll container for `scrollTo`, has no meaning here. + let hidden = |word: Option<&str>| matches!(word, Some("hidden") | Some("clip")); + if hidden(resolved_x) && hidden(resolved_y) { el = el.overflow_hidden(); - } else if resolved_x == Some("hidden") { + } else if hidden(resolved_x) { el = el.overflow_x_hidden(); - } else if resolved_y == Some("hidden") { + } else if hidden(resolved_y) { el = el.overflow_y_hidden(); } } diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs index 630749d6..e19f9eb4 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -23,7 +23,7 @@ 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, - to_element_id, EventCallback, GpuixView, + offset_to_js, to_element_id, EventCallback, GpuixView, }; use crate::retained_tree::RetainedTree; use crate::style::StyleDesc; @@ -578,6 +578,34 @@ impl TestGpuixRenderer { }) } + /// Scroll every ancestor scroll box so the element shows, like the + /// web scrollIntoView. Call flush() after to apply and re-render. + #[napi] + pub fn scroll_into_view( + &self, + element_id: f64, + block: Option, + inline: Option, + ) -> Result<()> { + use crate::renderer::scroll_into_view::{scroll_into_view, Align}; + let id = to_element_id(element_id)?; + let block = Align::parse(block.as_deref(), Align::Start); + let inline = Align::parse(inline.as_deref(), Align::Nearest); + with_test_state(|cx, window, view| { + let view = view.clone(); + cx.update_window(window, |_, _window, app| { + view.update(app, |view, _cx| { + let tree = view.tree.lock().unwrap(); + scroll_into_view(&tree, id, block, inline, |id| { + view.scroll_handles.get(&id).cloned() + }); + }); + }) + .map_err(|e| Error::from_reason(e.to_string()))?; + Ok(()) + }) + } + /// Scroll a child into view by its index in the children list. /// Call flush() after to apply and re-render. #[napi] @@ -674,10 +702,7 @@ impl TestGpuixRenderer { } view.scroll_handles.get(&id).map(|handle| { let offset = handle.offset(); - vec![ - f64::from(f32::from(offset.x)), - f64::from(f32::from(offset.y)), - ] + offset_to_js(offset).to_vec() }) }) }) diff --git a/packages/react/src/__tests__/scrollbars.test.tsx b/packages/react/src/__tests__/scrollbars.test.tsx new file mode 100644 index 00000000..ff50e2a9 --- /dev/null +++ b/packages/react/src/__tests__/scrollbars.test.tsx @@ -0,0 +1,157 @@ +/** + * Scrollbars on scroll boxes. `GPUIX_SCROLLBARS` picks the kind of bar, + * so the tests do not depend on the machine's setting. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import React from "react" +import { createTestRoot, hasNativeTestRenderer, type TestRoot } from "../testing" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +type Style = React.CSSProperties & Record + +function Page({ box, content = 400 }: { box: Style; content?: number }) { + return ( +
+
+ inner +
+
+ ) +} + +describeNative("scrollbars", () => { + let root: TestRoot + beforeEach(() => { + root = createTestRoot({}) + }) + afterEach(() => { + root.unmount() + delete process.env.GPUIX_SCROLLBARS + }) + + const box = () => root.renderer.findByType("div")[0]! + const inner = () => root.renderer.findByType("div")[1]! + /** The inner box's rectangle after a settled frame. */ + const innerBounds = () => { + root.renderer.getElementBounds(inner().id) + return root.renderer.getElementBounds(inner().id)! + } + + describe("classic bars", () => { + beforeEach(() => { + process.env.GPUIX_SCROLLBARS = "classic" + }) + + it("reserve a 15px gutter for overflow: scroll", () => { + root.render() + expect(innerBounds()[2]).toBe(185) + }) + + it("reserve the gutter for overflow: auto only when the content overflows", () => { + root.render() + expect(innerBounds()[2]).toBe(200) + root.render() + expect(innerBounds()[2]).toBe(185) + }) + + it("follow scrollbar-width and scrollbar-gutter", () => { + root.render() + expect(innerBounds()[2]).toBe(192) + root.render() + expect(innerBounds()[2]).toBe(200) + root.render() + expect(innerBounds()[2]).toBe(185) + root.render( + + ) + const [x, , width] = innerBounds() + expect(x).toBe(15) + expect(width).toBe(170) + }) + + it("scroll with a thumb drag and a track click", () => { + root.render() + const id = box().id + // The thumb is a quarter of the track, at the top. Take it 40px down. + root.renderer.nativeSimulateMouseDown(192, 10) + root.renderer.nativeSimulateMouseMove(192, 50, 0) + root.renderer.nativeSimulateMouseUp(192, 50) + const dragged = root.renderer.getScrollOffset(id)![1] + expect(dragged).toBeCloseTo(-160, 0) + + // A click in the track below the thumb pages down. + root.renderer.nativeSimulateMouseDown(192, 98) + root.renderer.nativeSimulateMouseUp(192, 98) + expect(root.renderer.getScrollOffset(id)![1]).toBeCloseTo(-250, 0) + }) + }) + + describe("overlay bars", () => { + beforeEach(() => { + process.env.GPUIX_SCROLLBARS = "overlay" + }) + + it("reserve nothing, even with scrollbar-gutter: stable", () => { + root.render() + expect(innerBounds()[2]).toBe(200) + }) + + it("take a thumb drag after a scroll", () => { + root.render() + const id = box().id + root.renderer.nativeSimulateScrollWheel(100, 50, 0, -10) + const before = root.renderer.getScrollOffset(id)![1] + expect(before).toBeLessThan(0) + root.renderer.nativeSimulateMouseDown(195, 12) + root.renderer.nativeSimulateMouseMove(195, 60, 0) + root.renderer.nativeSimulateMouseUp(195, 60) + expect(root.renderer.getScrollOffset(id)![1]).toBeLessThan(before - 100) + }) + }) + + it("overflow: auto scrolls like overflow: scroll", () => { + root.render() + root.renderer.nativeSimulateScrollWheel(100, 50, 0, -30) + expect(root.renderer.getScrollOffset(box().id)![1]).toBeLessThan(0) + }) +}) + +describeNative("scrollIntoView", () => { + let root: TestRoot + beforeEach(() => { + root = createTestRoot({}) + }) + afterEach(() => root.unmount()) + + function List({ margin, padding }: { margin?: number; padding?: number }) { + return ( +
+ {Array.from({ length: 10 }, (_, i) => ( +
+ {`row ${i}`} +
+ ))} +
+ ) + } + + it("brings the target to the start and honours the margins", () => { + root.render() + const divs = root.renderer.findByType("div") + root.renderer.scrollIntoView(divs[7]!.id) + // The target's top is at 240. scroll-padding keeps 10 inside the + // box and scroll-margin keeps 4 around the target: 240 - 10 - 4. + expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(-226) + }) + + it("nearest leaves a visible target alone", () => { + root.render() + const divs = root.renderer.findByType("div") + root.renderer.scrollIntoView(divs[2]!.id, "nearest") + expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(0) + root.renderer.scrollIntoView(divs[7]!.id, "end") + // The target's bottom is at 280 and the viewport is 100 tall. + expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(-180) + }) +}) diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 2365c5ab..234c33fb 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -240,9 +240,43 @@ export interface StyleDesc { textOverflow?: "ellipsis" | "ellipsis-start" lineClamp?: Numeric + /** `visible`, `hidden`, `clip`, `scroll` or `auto`. `scroll` and `auto` + * make a scroll box with a scrollbar. The OS picks the kind of bar: an + * overlay bar that fades out after a scroll, or a classic bar in a + * gutter. A classic bar shows at all times for `scroll` and only while + * the content overflows for `auto`. */ overflow?: string overflowX?: string overflowY?: string + /** `auto`, `thin` or `none`. `none` paints no bar and reserves no gutter. */ + scrollbarWidth?: string + /** `auto`, or the thumb colour then the track colour, as in CSS. */ + scrollbarColor?: string + /** `auto`, `stable` or `stable both-edges`. `stable` reserves the gutter + * of a classic bar even while the content fits, and `both-edges` adds + * the same gutter at the start of the axis. Overlay bars reserve + * nothing, as in CSS. */ + scrollbarGutter?: string + /** Space scrollIntoView keeps around this element, a number of pixels + * or "Npx", alone or as the CSS one-to-four shorthand. */ + scrollMargin?: number | string + scrollMarginTop?: number | string + scrollMarginRight?: number | string + scrollMarginBottom?: number | string + scrollMarginLeft?: number | string + /** Space scrollIntoView keeps inside this scroll box. */ + scrollPadding?: number | string + scrollPaddingTop?: number | string + scrollPaddingRight?: number | string + scrollPaddingBottom?: number | string + scrollPaddingLeft?: number | string + /** `auto`, `contain` or `none`, one word for both axes or two with the x + * axis first. A scroll box keeps a wheel event it can scroll with. At its + * end, `auto` hands the event to the nearest scroll box around it and + * `contain` or `none` keeps it. */ + overscrollBehavior?: string + overscrollBehaviorX?: string + overscrollBehaviorY?: string /** * A CSS cursor keyword: `default`, `pointer`, `text`, `vertical-text`, @@ -606,6 +640,11 @@ export interface NativeRenderer { scrollTo?(elementId: number, x: number, y: number): void /** Scroll a child into view by its index in the children list. */ scrollToItem?(elementId: number, index: number): void + /** Scroll every ancestor scroll box so the element shows, like the web + * scrollIntoView. block places it on the y axis and inline on the x + * axis: "start", "center", "end" or "nearest". The defaults match the + * web: "start" and "nearest". */ + scrollIntoView?(elementId: number, block?: string, inline?: string): void /** Get the current scroll offset [x, y] or null if element is not scrollable. */ getScrollOffset?(elementId: number): Array | null From e63040c08882b96c8ac329e65982ebb6c1701d0c Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:14 +0200 Subject: [PATCH 19/55] fix(native): select text across wrapped rows --- .changeset/select-across-wrapped-rows.md | 11 +++++++++++ packages/native/src/text/paint.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/select-across-wrapped-rows.md diff --git a/.changeset/select-across-wrapped-rows.md b/.changeset/select-across-wrapped-rows.md new file mode 100644 index 00000000..e7c906ec --- /dev/null +++ b/.changeset/select-across-wrapped-rows.md @@ -0,0 +1,11 @@ +--- +"@gpuix/native": patch +--- + +Cover the first glyph of a wrapped row in the selection wash. + +The wash walks the visual rows of a paragraph with `position_for_index`. +The index at a soft-wrap boundary reports its position on the earlier row, +so each walk started one glyph into the next row and the wash missed that +glyph. A continuation row now stretches back to the leading edge of the +layout. diff --git a/packages/native/src/text/paint.rs b/packages/native/src/text/paint.rs index 8634d7c8..9c2cb3e4 100644 --- a/packages/native/src/text/paint.rs +++ b/packages/native/src/text/paint.rs @@ -562,11 +562,19 @@ pub fn range_rects( // Walk the range one visual row at a time: binary search for the furthest // index that still sits on the current row. let mut guard = 0; + let mut row_is_continuation = false; while cur < range.end && guard < 256 { guard += 1; - let Some(p1) = layout.position_for_index(cur) else { + let Some(mut p1) = layout.position_for_index(cur) else { break; }; + // A continuation row starts at the index AFTER the wrap boundary, + // because the boundary index reports its position on the earlier + // row. That index sits one glyph into the row, so the wash must + // stretch back to the row's leading edge or it misses that glyph. + if row_is_continuation { + p1.x = layout.bounds().origin.x; + } // `seg_end` closes the wash on this row; `next` is the first index on the // following row. They differ because a row-end index's position still // reports the earlier row, and we need strict progress. @@ -599,6 +607,7 @@ pub fn range_rects( break; } cur = next; + row_is_continuation = true; } rects } From 05fb9c9f2cf5b18999c702d7ba969c6d2399cc6f Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:15 +0200 Subject: [PATCH 20/55] fix(react): survive a hot remount of the root --- .changeset/survive-a-hot-remount.md | 13 +++++++++ .../react/src/reconciler/event-registry.ts | 27 +++++++++++++++---- packages/react/src/reconciler/renderer.ts | 6 ++++- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 .changeset/survive-a-hot-remount.md diff --git a/.changeset/survive-a-hot-remount.md b/.changeset/survive-a-hot-remount.md new file mode 100644 index 00000000..524cccb0 --- /dev/null +++ b/.changeset/survive-a-hot-remount.md @@ -0,0 +1,13 @@ +--- +"@gpuix/react": patch +--- + +Keep events alive across a `bun --hot` remount. + +The map from renderer to container lived in the module, and the native +event callback keeps the module instance that created it. A hot reload +evaluates the module again, so the new tree registered its handlers in a +new map while native events searched the old one, and every click died. +The map now lives on `globalThis`, so both module instances share it. The +`onEvent` option also follows the latest `render()` call instead of the +first one. diff --git a/packages/react/src/reconciler/event-registry.ts b/packages/react/src/reconciler/event-registry.ts index 90c9cd31..12c2ff1c 100644 --- a/packages/react/src/reconciler/event-registry.ts +++ b/packages/react/src/reconciler/event-registry.ts @@ -1,22 +1,39 @@ import type { EventPayload } from "@gpuix/native" import type { Container, EventHandlerMap, NativeRenderer } from "../types/host.js" -const containersByRenderer = new WeakMap() +/// The map from renderer to container lives on globalThis, not in this +/// module. Under `bun --hot` a reload evaluates this module again, but the +/// native renderer keeps the event callback from the first evaluation. +/// That old callback must find the container the new evaluation attached, +/// so both evaluations have to share one map. +const CONTAINERS_KEY = "__gpuixEventContainers" + +function containersByRenderer(): WeakMap { + const existing = Reflect.get(globalThis, CONTAINERS_KEY) as + | WeakMap + | undefined + if (existing) { + return existing + } + const created = new WeakMap() + Reflect.set(globalThis, CONTAINERS_KEY, created) + return created +} export function attachRoot(renderer: NativeRenderer, container: Container): void { - containersByRenderer.set(renderer, container) + containersByRenderer().set(renderer, container) } export function detachRoot(renderer: NativeRenderer): void { - containersByRenderer.delete(renderer) + containersByRenderer().delete(renderer) } export function containerForRenderer(renderer: NativeRenderer): Container | undefined { - return containersByRenderer.get(renderer) + return containersByRenderer().get(renderer) } export function handleGpuixEvent(payload: EventPayload, renderer: NativeRenderer): void { - const container = containersByRenderer.get(renderer) + const container = containersByRenderer().get(renderer) if (!container) return const elementHandlers = container.eventHandlers.get(payload.elementId) if (!elementHandlers) return diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index 4849696b..313aedab 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -117,6 +117,9 @@ type RenderSlot = { renderer?: NativeRenderer root?: Root loop?: FrameLoop + /// The `onEvent` of the latest `render()` call. The native callback closes + /// over the slot, not over the option, so a hot reload can swap it. + onEvent?: (event: EventPayload) => void } function renderSlot(): RenderSlot { @@ -148,11 +151,12 @@ export function render(node: ReactNode, options: RenderOptions = {}): Root { const { onEvent, renderer: injected, debugFrameOverlay, resolveClassName, ...windowOptions } = options const slot = renderSlot() const remount = slot.root != null + slot.onEvent = onEvent if (!slot.renderer) { if (injected) { slot.renderer = injected } else { - const renderer = createRenderer(onEvent) + const renderer = createRenderer((event) => slot.onEvent?.(event)) renderer.init(windowOptions) slot.renderer = renderer console.log("[gpuix] created native window") From 63ffa67a54377701f21f788f14f2443e453aed0d Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:30 +0200 Subject: [PATCH 21/55] fix(native): drive the live scroll wheel from automation --- .changeset/live-scroll-wheel.md | 10 ++++++ packages/native/index.d.ts | 1 + packages/native/src/automation.rs | 25 +++++++++++++- packages/native/src/renderer.rs | 45 +++++++++++++++++++++++++ packages/react/src/automation/client.ts | 6 ++-- 5 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 .changeset/live-scroll-wheel.md diff --git a/.changeset/live-scroll-wheel.md b/.changeset/live-scroll-wheel.md new file mode 100644 index 00000000..c40e8e96 --- /dev/null +++ b/.changeset/live-scroll-wheel.md @@ -0,0 +1,10 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Make the automation `scrollWheel` method work on the live app. + +The live renderer threw "scrollWheel is not live yet". It now dispatches a +real `ScrollWheelEvent` with a pixel delta through the window, the same +path a physical wheel takes. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 55bdc712..26fb28b3 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -102,6 +102,7 @@ export declare class GpuixRenderer { simulateMouseDown(x: number, y: number, button?: number | undefined | null): void simulateMouseUp(x: number, y: number, button?: number | undefined | null): void simulateMouseMove(x: number, y: number, pressedButton?: number | undefined | null): void + simulateScrollWheel(x: number, y: number, deltaX: number, deltaY: number): void clockPause(): number clockSet(nowMs: number): number clockFastForward(deltaMs: number): number diff --git a/packages/native/src/automation.rs b/packages/native/src/automation.rs index 320084eb..2ff5475a 100644 --- a/packages/native/src/automation.rs +++ b/packages/native/src/automation.rs @@ -14,7 +14,8 @@ use std::time::{Duration, Instant}; use gpui::{ canvas, point, px, App, Bounds, InputEvent, IntoElement, Modifiers, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Styled, Window, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, ScrollDelta, ScrollWheelEvent, Styled, + TouchPhase, Window, }; #[derive(Clone, Copy, Debug)] @@ -79,6 +80,8 @@ pub fn bounds_tracker(id: u64, selection_start: Option) -> impl IntoElemen }, ) .absolute() + .top_0() + .left_0() .size_full() } @@ -250,6 +253,26 @@ pub fn dispatch_mouse_move( ); } +pub fn dispatch_scroll_wheel( + window: &mut Window, + cx: &mut App, + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, +) { + window.dispatch_event( + ScrollWheelEvent { + position: point(px(x as f32), px(y as f32)), + delta: ScrollDelta::Pixels(point(px(delta_x as f32), px(delta_y as f32))), + modifiers: Modifiers::default(), + touch_phase: TouchPhase::Moved, + } + .to_platform_input(), + cx, + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index af78ea53..3bedc1f1 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -198,6 +198,12 @@ enum MouseInput { y: f64, pressed_button: Option, }, + Scroll { + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, + }, } #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] @@ -446,6 +452,16 @@ async fn run_ui_commands( } => { crate::automation::dispatch_mouse_move(window, cx, x, y, pressed_button); } + MouseInput::Scroll { + x, + y, + delta_x, + delta_y, + } => { + crate::automation::dispatch_scroll_wheel( + window, cx, x, y, delta_x, delta_y, + ); + } }); response .send( @@ -1522,6 +1538,35 @@ impl GpuixRenderer { } } + #[napi] + pub fn simulate_scroll_wheel(&self, x: f64, y: f64, delta_x: f64, delta_y: f64) -> Result<()> { + #[cfg(target_os = "macos")] + return update_window(move |_view, window, cx| { + crate::automation::dispatch_scroll_wheel(window, cx, x, y, delta_x, delta_y); + }); + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.dispatch_mouse_input(MouseInput::Scroll { + x, + y, + delta_x, + delta_y, + }); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + { + let _ = (x, y, delta_x, delta_y); + Err(Error::from_reason( + "The production GPUIX renderer does not support this operating system", + )) + } + } + #[napi] pub fn clock_pause(&self) -> Result { #[cfg(target_os = "macos")] diff --git a/packages/react/src/automation/client.ts b/packages/react/src/automation/client.ts index 3020bef4..74fc39a1 100644 --- a/packages/react/src/automation/client.ts +++ b/packages/react/src/automation/client.ts @@ -505,6 +505,7 @@ export interface LiveAutomationRenderer { simulateMouseDown(x: number, y: number, button?: number): void simulateMouseUp(x: number, y: number, button?: number): void simulateMouseMove(x: number, y: number, pressedButton?: number): void + simulateScrollWheel(x: number, y: number, deltaX: number, deltaY: number): void tick?(): void focusElement(elementId: number): void blur(): void @@ -546,8 +547,9 @@ export function liveRendererAsTest( renderer.simulateMouseMove(x, y, pressedButton) afterInput() }, - nativeSimulateScrollWheel() { - throw new AutomationError("Unsupported", "scrollWheel is not live yet") + nativeSimulateScrollWheel(x, y, deltaX, deltaY) { + renderer.simulateScrollWheel(x, y, deltaX, deltaY) + afterInput() }, simulateKeystrokes() { throw new AutomationError("Unsupported", "keystrokes are not live yet") From 0c68f132777cf010d8e97d0233986ee29f56fd2b Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:53 +0200 Subject: [PATCH 22/55] fix(native): sync env overrides into the addon under bun --- .changeset/sync-env-overrides-under-bun.md | 12 +++++++ packages/native/index.d.ts | 10 ++++++ packages/native/index.js | 1 + packages/native/src/renderer.rs | 14 ++++++++ packages/react/src/testing.ts | 38 ++++++++++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 .changeset/sync-env-overrides-under-bun.md diff --git a/.changeset/sync-env-overrides-under-bun.md b/.changeset/sync-env-overrides-under-bun.md new file mode 100644 index 00000000..3719e8bf --- /dev/null +++ b/.changeset/sync-env-overrides-under-bun.md @@ -0,0 +1,12 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Push `process.env` overrides through to the Rust side under Bun. + +Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`. Node +writes a `process.env` assignment through to `setenv`, but Bun only updates +its JS snapshot, so a test that set the variable after start had no effect +under `bun test`. The native module now exports `syncEnvVar`, and the test +renderer copies the known overrides across before every frame flush. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 26fb28b3..89d953a4 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -443,6 +443,16 @@ export interface EventPayload { modifiers?: EventModifiers } +/** + * Copies one `process.env` entry into the real process environment. + * + * Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, + * which reads the C environment. Node writes a `process.env` assignment + * through to `setenv`, but Bun only updates its JS snapshot. A caller on + * Bun must push the value across with this function. + */ +export declare function syncEnvVar(key: string, value?: string | undefined | null): void + export interface WindowOptions { title?: string width?: number diff --git a/packages/native/index.js b/packages/native/index.js index 1f34b865..e6301a22 100644 --- a/packages/native/index.js +++ b/packages/native/index.js @@ -578,3 +578,4 @@ if (!nativeBinding) { module.exports = nativeBinding module.exports.GpuixRenderer = nativeBinding.GpuixRenderer module.exports.TestGpuixRenderer = nativeBinding.TestGpuixRenderer +module.exports.syncEnvVar = nativeBinding.syncEnvVar diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 3bedc1f1..c00193a7 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -491,6 +491,20 @@ fn panic_message(payload: Box) -> String { .unwrap_or_else(|| "unknown panic".to_string()) } +/// Copies one `process.env` entry into the real process environment. +/// +/// Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, +/// which reads the C environment. Node writes a `process.env` assignment +/// through to `setenv`, but Bun only updates its JS snapshot. A caller on +/// Bun must push the value across with this function. +#[napi] +pub fn sync_env_var(key: String, value: Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } +} + /// The main GPUI renderer exposed to Node.js. #[napi] pub struct GpuixRenderer { diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index a0a854f6..5aecf732 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -45,6 +45,7 @@ interface NativeTestRendererApi extends NativeRenderer { getAllText(): string[] scrollTo(elementId: number, x: number, y: number): void scrollToItem(elementId: number, index: number): void + scrollIntoView(elementId: number, block?: string, inline?: string): void getScrollOffset(elementId: number): number[] | null setDebugFrameOverlay(mode: DebugFrameOverlayMode): string getDebugFrameOverlay(): string @@ -84,6 +85,31 @@ try { /** Whether the native TestGpuixRenderer is available (for conditional test registration). */ export const hasNativeTestRenderer = NativeTestRenderer != null +/// The env overrides the Rust side reads with `std::env::var`. +const NATIVE_ENV_OVERRIDES = ["GPUIX_SCROLLBARS"] as const + +/** + * Copies the env overrides from `process.env` into the real environment. + * + * Node writes a `process.env` assignment through to `setenv`, but Bun only + * updates its JS snapshot, so under `bun test` the Rust side cannot see a + * `process.env.GPUIX_SCROLLBARS = "classic"` from a test. This runs before + * every frame flush to push the current values across. + */ +function syncEnvOverrides(): void { + let syncEnvVar: ((key: string, value?: string) => void) | undefined + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + syncEnvVar = (require("@gpuix/native") as { syncEnvVar?: typeof syncEnvVar }).syncEnvVar + } catch { + return + } + if (!syncEnvVar) return + for (const key of NATIVE_ENV_OVERRIDES) { + syncEnvVar(key, process.env[key]) + } +} + export const MAC_CPU_THROTTLES = ["utility", "background", "maintenance"] as const export type MacCpuThrottle = (typeof MAC_CPU_THROTTLES)[number] @@ -213,6 +239,7 @@ export class TestRenderer implements NativeRenderer { /** Trigger the real GPUI rendering pipeline (GpuixView::render() → * build_element() → apply_styles() → layout). */ flush(): void { + syncEnvOverrides() this.native.flush() } @@ -468,6 +495,17 @@ export class TestRenderer implements NativeRenderer { this.native.flush() } + /** Scroll every ancestor scroll box so the element shows, like the web + * scrollIntoView. block places it on the y axis and inline on the x + * axis: "start", "center", "end" or "nearest". The defaults match the + * web: "start" and "nearest". scrollMargin on the element and + * scrollPadding on a box apply. */ + scrollIntoView(elementId: number, block?: string, inline?: string): void { + this.native.flush() + this.native.scrollIntoView(elementId, block, inline) + this.native.flush() + } + /** Get the current scroll offset [x, y] or null if element is not scrollable. */ getScrollOffset(elementId: number): [number, number] | null { this.native.flush() From b0a1374ea3f2f7e4baf37b398c5a9d110a8605a6 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:53 +0200 Subject: [PATCH 23/55] fix(react): run the automation stdio test under the bun runner --- packages/react/src/__tests__/automation-stdio.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/react/src/__tests__/automation-stdio.test.ts b/packages/react/src/__tests__/automation-stdio.test.ts index 5ea46ad7..634269ac 100644 --- a/packages/react/src/__tests__/automation-stdio.test.ts +++ b/packages/react/src/__tests__/automation-stdio.test.ts @@ -98,11 +98,16 @@ describe("automation stdio", () => { closes += 1 } ) - const pending = backend.call("blur", {}) - const rejection = expect(pending).rejects.toMatchObject({ code: "Closed" }) + // Convert the rejection into a value before the assertion. Bun's test + // runner stalls on a `rejects` matcher that it gets while the promise + // is still pending. + const pending = backend.call("blur", {}).then( + () => undefined, + (error: unknown) => error + ) await backend.close() - await rejection + await expect(pending).resolves.toMatchObject({ code: "Closed" }) await backend.close() expect(closes).toBe(1) From 3de5b80bc3266774eddf3c2af3160eb14cc04c1b Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:27:07 +0200 Subject: [PATCH 24/55] chore(native): tidy comments and docs after the css value work --- packages/native/src/color.rs | 22 +++++++++++-- packages/native/src/motion.rs | 28 +++++++++++----- packages/native/src/renderer.rs | 7 +--- packages/native/src/renderer/frame.rs | 29 ++++++++++++----- packages/native/src/style.rs | 22 ++++++++++--- packages/native/src/style/resolve.rs | 14 ++++++-- packages/native/src/style/vars.rs | 47 +++++++++++++++++++-------- 7 files changed, 121 insertions(+), 48 deletions(-) diff --git a/packages/native/src/color.rs b/packages/native/src/color.rs index 0ff1d0f5..f580f7da 100644 --- a/packages/native/src/color.rs +++ b/packages/native/src/color.rs @@ -9,7 +9,12 @@ 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 } + gpui::Rgba { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + } } /// Turn a GPUI colour into engine channels. @@ -18,7 +23,12 @@ pub(crate) fn to_gpui(color: Rgba) -> gpui::Rgba { /// 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 } + Rgba { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + } } /// Turn engine channels into GPUI's HSL paint type. @@ -52,6 +62,7 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro color: to_hsla(stop.color), percentage: stop.position, hint: stop.hint, + easing: stop.easing, }) .collect(); gpui::linear_gradient_stops(line, &stops) @@ -216,7 +227,12 @@ 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 }, + current_color: Rgba { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, dark: false, }; assert_eq!( diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index c1c3fbb2..4e8fface 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -433,9 +433,9 @@ impl MotionState { (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() - }) { + 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 @@ -623,7 +623,7 @@ mod tests { "animate": { "cornerShape": "square" }, "transition": { "duration": 1.0, "ease": "linear" } }); - let mut state = MotionState::new(&spec, started).unwrap(); + let 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); @@ -706,8 +706,14 @@ mod tests { 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)); + 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] @@ -729,7 +735,10 @@ mod tests { 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_millis(500))), + Some(100.0) + ); assert_eq!(at(state.frame(settled + Duration::from_secs(1))), Some(0.0)); } @@ -753,7 +762,10 @@ mod tests { // 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)); + assert_eq!( + at(state.frame(turned + Duration::from_millis(500))), + Some(50.0) + ); } #[test] diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index c00193a7..82f63f1f 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -425,9 +425,7 @@ async fn run_ui_commands( let now_ms = match control { ClockControl::Pause => view.clock.pause(), ClockControl::Set(now_ms) => view.clock.set_ms(now_ms), - ClockControl::FastForward(delta_ms) => { - view.clock.fast_forward_ms(delta_ms) - } + ClockControl::FastForward(delta_ms) => view.clock.fast_forward_ms(delta_ms), ClockControl::Resume => view.clock.resume(), }; cx.notify(); @@ -1942,7 +1940,6 @@ impl GpuixView { } } - impl GpuixView { /// Sync focus handles with the current element tree. /// Creates handles for new focusable elements, subscribes on_focus/on_blur, @@ -2132,7 +2129,6 @@ impl gpui::Render for GpuixView { } } - // ── Event emission ─────────────────────────────────────────────────── /// Helper to convert a GPUI Point to (f64, f64). @@ -2171,7 +2167,6 @@ pub(crate) fn emit_event_full( } } - // ── Types ──────────────────────────────────────────────────────────── #[derive(Debug, Clone)] diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 966a53ba..4f2b05ac 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -94,11 +94,27 @@ 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(), + 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(), + ctx, + window, + cx, + ) } "virtual-list" => { ctx.custom_registry.destroy(id); @@ -136,13 +152,8 @@ pub(super) fn build_element( 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.custom_registry + .render(custom_type, &element.custom_props, render_ctx, window, cx) } }; diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index efc54553..ca42dedd 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -610,7 +610,10 @@ mod tests { 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!( + FIELDS.contains(&name.as_str()), + "`{name}` is written but never read" + ); } assert_eq!(written.len(), FIELDS.len()); } @@ -633,7 +636,10 @@ mod tests { assert_eq!(style.gap, Some(Numeric::Text("var(--gap)".to_owned()))); 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.font_weight, + Some(FontWeightValue::Str("bold".to_owned())) + ); assert_eq!(style.line_clamp, None); assert_eq!(style.hover.unwrap().color.as_deref(), Some("red")); } @@ -659,12 +665,16 @@ mod tests { 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())]); + 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 }"#; + 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() @@ -683,7 +693,9 @@ mod tests { font_size: Some(Numeric::Number(14.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(), + custom: [("--pad".to_owned(), serde_json::json!("8px"))] + .into_iter() + .collect(), hover: Some(Box::new(StyleDesc { background_color: Some("#fff".to_owned()), ..Default::default() diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index 70a7cb75..7021b82b 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -854,7 +854,9 @@ mod tests { let scope = variables(&[("--brand", "#ff0000")]); let resolved = Resolved::build(&style, &scope); assert_eq!( - resolved.state(State::Hover).and_then(|h| h.background.clone()), + resolved + .state(State::Hover) + .and_then(|h| h.background.clone()), fill("#ff0000") ); } @@ -940,7 +942,10 @@ mod tests { line_height: Some(crate::style::Numeric::Text(text.to_string())), ..Default::default() }; - Resolved::build(&style, &no_variables()).base.text.line_height + Resolved::build(&style, &no_variables()) + .base + .text + .line_height } #[test] @@ -953,7 +958,10 @@ mod tests { ..Default::default() }; assert_eq!( - Resolved::build(&numeric, &no_variables()).base.text.line_height, + Resolved::build(&numeric, &no_variables()) + .base + .text + .line_height, Some(gpui::relative(1.5)) ); } diff --git a/packages/native/src/style/vars.rs b/packages/native/src/style/vars.rs index a9fe10d7..daaddbd9 100644 --- a/packages/native/src/style/vars.rs +++ b/packages/native/src/style/vars.rs @@ -46,12 +46,7 @@ pub(crate) struct Scope<'a> { } impl<'a> Scope<'a> { - pub fn new( - variables: &'a Variables, - current_color: Rgba, - dark: bool, - rem_size: f32, - ) -> Self { + pub fn new(variables: &'a Variables, current_color: Rgba, dark: bool, rem_size: f32) -> Self { Self { variables, current_color, @@ -541,7 +536,10 @@ mod tests { 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_eq!( + scope.number(&Some(crate::style::Numeric::Number(8.0))), + Some(8.0) + ); assert!(!scope.used_a_variable()); } @@ -612,10 +610,22 @@ mod tests { 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(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)) @@ -627,10 +637,16 @@ mod tests { 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("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)); + assert_eq!( + dimension(text("var(--w)"), &[("--w", "auto")]), + Some(DimensionValue::Auto) + ); } #[test] @@ -649,6 +665,9 @@ mod tests { #[test] fn an_absent_declaration_stays_absent() { let variables = scope_of(&[]); - assert_eq!(Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None), None); + assert_eq!( + Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None), + None + ); } } From dfb25ea2634dbe075369ea18c0dbb2293f016dda Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 20:03:21 +0200 Subject: [PATCH 25/55] build(native): speed up png encoding in debug builds --- packages/native/Cargo.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index ce205fb3..9d3061e4 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -69,6 +69,24 @@ path = "examples/hello.rs" [profile.release] lto = true +# capture_screenshot encodes a png on every visual test. The encoder is +# too slow at opt-level 0 and vitest kills the worker, so build the +# image crates with opt-level 3 in debug builds too. +[profile.dev.package.png] +opt-level = 3 + +[profile.dev.package.image] +opt-level = 3 + +[profile.dev.package.fdeflate] +opt-level = 3 + +[profile.dev.package.flate2] +opt-level = 3 + +[profile.dev.package.miniz_oxide] +opt-level = 3 + # The style parse benchmark. `harness = false` because it prints its own # numbers rather than running as a test. [[bench]] From 43a92ec72fd2287c95668c6b691325f5f6a3d281 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 22:39:17 +0200 Subject: [PATCH 26/55] fix(native): paint a gradient easing as its half-point hint --- .changeset/eased-gradients.md | 10 ++--- packages/native/src/color.rs | 77 ++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/.changeset/eased-gradients.md b/.changeset/eased-gradients.md index 3060b72d..6afbb122 100644 --- a/.changeset/eased-gradients.md +++ b/.changeset/eased-gradients.md @@ -8,8 +8,8 @@ Ease the mix between two gradient stops. An `` between two colour stops bends the mix, following the CSSWG proposal in csswg-drafts issue 1332: `linear-gradient(to top, black, ease-in-out, transparent)`. `ease`, `ease-in`, `ease-out`, `ease-in-out` and -`cubic-bezier()` are read. The shader solves the curve per fragment, so an -eased scrim stays one quad. A straight fade to transparent looks dense near -the solid stop and thin near the clear one; an eased one reads as one smooth -fall-off. The Windows shader also catches up with eight stops and corner -shapes. +`cubic-bezier()` are read. The easing paints as the GPUI colour hint whose +curve crosses one half at the same place, so the paint agrees with the easing +at both ends and at the half-way point. A straight fade to transparent looks +dense near the solid stop and thin near the clear one. An eased one reads as +one smooth fall-off. diff --git a/packages/native/src/color.rs b/packages/native/src/color.rs index 0ff1d0f5..3884d246 100644 --- a/packages/native/src/color.rs +++ b/packages/native/src/color.rs @@ -51,7 +51,7 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro .map(|stop| gpui::LinearColorStop { color: to_hsla(stop.color), percentage: stop.position, - hint: stop.hint, + hint: hint_for(stop), }) .collect(); gpui::linear_gradient_stops(line, &stops) @@ -59,6 +59,54 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro } } +/// The hint GPUI paints for one stop. +/// +/// GPUI has no easing in a gradient, but its hint moves the half-way point of +/// the mix along the CSS exponential curve. An easing becomes the hint where +/// its own curve crosses one half. The two curves then agree at both ends and +/// at the half-way point, which is as close as one number gets. +fn hint_for(stop: &gpuix_css::background::Stop) -> f32 { + if stop.hint != 0.0 || stop.easing == [0.0; 4] { + return stop.hint; + } + easing_half_point(stop.easing) +} + +/// The x at which a cubic bezier easing's output crosses one half. +/// +/// The curve runs from (0, 0) to (1, 1) with the control points +/// `[x1, y1, x2, y2]`. The output can overshoot, so this walks to the first +/// crossing and then bisects. +fn easing_half_point(easing: [f32; 4]) -> f32 { + let [x1, y1, x2, y2] = easing; + let at = |a: f32, b: f32, t: f32| { + let u = 1.0 - t; + 3.0 * u * u * t * a + 3.0 * u * t * t * b + t * t * t + }; + let y = |t: f32| at(y1, y2, t); + let mut low = 0.0f32; + let mut high = 1.0f32; + for step in 1..=64 { + let t = step as f32 / 64.0; + if y(t) >= 0.5 { + high = t; + low = t - 1.0 / 64.0; + break; + } + } + for _ in 0..24 { + let mid = (low + high) / 2.0; + if y(mid) < 0.5 { + low = mid; + } else { + high = mid; + } + } + let x = at(x1, x2, (low + high) / 2.0); + // The shader reads a hint of 0 or 1 as none, so keep the value inside. + x.clamp(0.001, 0.999) +} + /// Read a colour that depends on the element or the window. /// /// `currentColor` and `light-dark()` both need context, so this is the entry @@ -237,4 +285,31 @@ mod tests { Some(u32::from(rgba)) ); } + + #[test] + fn an_easing_becomes_the_hint_at_its_half_point() { + // ease-in-out is symmetric, so its half point is the middle, which is + // the same paint as no hint at all. + let middle = easing_half_point([0.42, 0.0, 0.58, 1.0]); + assert!((middle - 0.5).abs() < 0.01, "got {middle}"); + + // ease-in holds the first colour longer, so the half point sits late. + let late = easing_half_point([0.42, 0.0, 1.0, 1.0]); + assert!(late > 0.6, "got {late}"); + + // cubic-bezier(0, 1, 0, 1) jumps toward the second colour at once. + let early = easing_half_point([0.0, 1.0, 0.0, 1.0]); + assert!(early < 0.05, "got {early}"); + } + + #[test] + fn an_explicit_hint_wins_over_the_easing() { + let stop = gpuix_css::background::Stop { + color: Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + position: 0.0, + hint: 0.25, + easing: [0.42, 0.0, 1.0, 1.0], + }; + assert_eq!(hint_for(&stop), 0.25); + } } From 5aa479966a29b2b8f3dc423ec85c2e14cb0bc507 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:00:49 +0200 Subject: [PATCH 27/55] 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 fe9f050fe2d806cc659f03107282c09f1978e562 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:02:55 +0200 Subject: [PATCH 28/55] fix(demo): move the blur header demo off the gradients branch --- examples/demo/gradients.tsx | 76 +------------------------------------ 1 file changed, 2 insertions(+), 74 deletions(-) diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx index 876f51be..49530231 100644 --- a/examples/demo/gradients.tsx +++ b/examples/demo/gradients.tsx @@ -8,77 +8,6 @@ import React from "react" import { Grid, Panel, Sample, Swatch } from "./ui.js" -/// A sticky header over scrolling rows, with a progressive blur under it. -/// -/// The header box has `backdropFilter: blur() saturate()`, which blurs the -/// rows under it the way the iOS 26 navigation bar does, and a `maskImage` -/// gradient that fades that blur out toward the bottom of the box. The -/// easing on the mask keeps the fall-off smooth. There is no scrim, only a -/// faint tint at the top so the large title stays readable over bright -/// rows. `overscrollBehavior: "contain"` keeps the wheel inside the list, -/// so the page does not move with it. -export function StickyHeader() { - const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`) - return ( - -
-
- {rows.map((row, i) => ( -
- {row} -
- ))} -
-
-
- Inbox -
-
- - ) -} - const DIRECTIONS: Array<[string, string]> = [ ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], @@ -145,13 +74,12 @@ export function Gradients() { /> -
From 86cfa6b6115e3e1d22f22cba00df269115aa44ff Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:06:02 +0200 Subject: [PATCH 29/55] feat(demo): show the blur header on the effect layers branch --- examples/demo/gradients.tsx | 74 ++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx index 49530231..97f13ae8 100644 --- a/examples/demo/gradients.tsx +++ b/examples/demo/gradients.tsx @@ -8,6 +8,77 @@ import React from "react" import { Grid, Panel, Sample, Swatch } from "./ui.js" +/// A sticky header over scrolling rows, with a progressive blur under it. +/// +/// The header box has `backdropFilter: blur() saturate()`, which blurs the +/// rows under it the way the iOS 26 navigation bar does, and a `maskImage` +/// gradient that fades that blur out toward the bottom of the box. The +/// easing on the mask keeps the fall-off smooth. There is no scrim, only a +/// faint tint at the top so the large title stays readable over bright +/// rows. `overscrollBehavior: "contain"` keeps the wheel inside the list, +/// so the page does not move with it. +export function StickyHeader() { + const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`) + return ( + +
+
+ {rows.map((row, i) => ( +
+ {row} +
+ ))} +
+
+
+ Inbox +
+
+ + ) +} + const DIRECTIONS: Array<[string, string]> = [ ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], @@ -77,9 +148,10 @@ export function Gradients() { note="An easing function between two stops bends the mix. CSS has no such thing yet, so this follows the CSSWG proposal (issue 1332). A straight fade to transparent looks dense near the solid stop and thin near the clear one. An eased one reads as one smooth fall-off." entries={EASING} /> +
From bdc28a650ed683b30197767fb137f8fa39b03bde Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:06:02 +0200 Subject: [PATCH 30/55] fix(styles): type overscrollBehavior and split its shorthand without a Vec --- packages/native/src/style/resolve.rs | 43 ++++++++++++++++++++++++---- packages/react/src/types/host.ts | 5 ++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index ee087513..973da2a7 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -295,11 +295,20 @@ fn overscroll( let word = match longhand { Some(word) => word.trim(), None => { - let words: Vec<&str> = shorthand?.split_whitespace().collect(); - match words.len() { - 1 => words[0], - 2 => words[axis], - _ => return None, + // The shorthand has one or two words. Walk it without a Vec, + // because this runs for every element on every render. + let mut words = shorthand?.split_whitespace(); + let first = words.next()?; + match (words.next(), words.next()) { + (None, _) => first, + (Some(second), None) => { + if axis == 0 { + first + } else { + second + } + } + (Some(_), Some(_)) => return None, } } }; @@ -693,6 +702,30 @@ pub(crate) fn parse_font_weight(value: &crate::style::FontWeightValue) -> gpui:: mod tests { use super::*; + #[test] + fn an_overscroll_shorthand_splits_by_axis() { + // One word covers both axes. Two words are x then y, as in CSS. + // Three words declare nothing. + assert_eq!( + overscroll(None, Some("contain"), 1), + Some(gpui::Overscroll::Contain) + ); + assert_eq!( + overscroll(None, Some("contain none"), 0), + Some(gpui::Overscroll::Contain) + ); + assert_eq!( + overscroll(None, Some("contain none"), 1), + Some(gpui::Overscroll::None) + ); + assert_eq!(overscroll(None, Some("contain none auto"), 0), None); + // The longhand wins over the shorthand. + assert_eq!( + overscroll(Some("auto"), Some("contain"), 0), + Some(gpui::Overscroll::Auto) + ); + } + fn styled(color: &str) -> Box { Box::new(StyleDesc { background_color: Some(color.to_string()), diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index f23418b5..0904df1e 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -278,6 +278,11 @@ export interface StyleDesc { overflow?: string overflowX?: string overflowY?: string + /** `"contain"` and `"none"` stop the wheel from reaching the parent + * scroll box when this one hits its edge. `"auto"` lets it through. */ + overscrollBehavior?: string + overscrollBehaviorX?: string + overscrollBehaviorY?: string cursor?: CursorValue /** `"auto"` blocks hits behind this element **and its wheel**. `"none"` never From bd58a5e0d65ac1ce2882afb9638312c7b73cf139 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:08:18 +0200 Subject: [PATCH 31/55] fix(scrollbars): take auto or exactly two colors for scrollbar-color --- packages/native/src/renderer/scrollbar.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/native/src/renderer/scrollbar.rs b/packages/native/src/renderer/scrollbar.rs index d136456f..00dfdc84 100644 --- a/packages/native/src/renderer/scrollbar.rs +++ b/packages/native/src/renderer/scrollbar.rs @@ -226,15 +226,25 @@ enum ThumbLook { } /// `scrollbar-color: `, or `auto`. +/// +/// CSS takes `auto` or exactly two colours. One colour, three words or a +/// word that is not a colour drops the whole declaration, the way a browser +/// drops a value it cannot parse. fn scrollbar_colors(value: &str) -> (Option, Option) { let words = split_top_level(value); + if words.len() != 2 { + return (None, None); + } let color = |index: usize| { words .get(index) .and_then(|word| crate::color::parse_color_rgba(word)) .map(Hsla::from) }; - (color(0), color(1)) + match (color(0), color(1)) { + (Some(thumb), Some(track)) => (Some(thumb), Some(track)), + _ => (None, None), + } } /// Splits on spaces outside parentheses, so `rgb(0 0 0 / 0.5) white` is @@ -776,6 +786,15 @@ mod tests { assert_eq!(scrollbar_colors("auto"), (None, None)); } + #[test] + fn scrollbar_color_takes_auto_or_exactly_two_colors() { + // One colour is not valid CSS, so the declaration drops. + assert_eq!(scrollbar_colors("red"), (None, None)); + assert_eq!(scrollbar_colors("red white blue"), (None, None)); + // A word that is not a colour drops both, not just itself. + assert_eq!(scrollbar_colors("red nonsense"), (None, None)); + } + fn spec(overflow: &str, extra: impl FnOnce(&mut StyleDesc), mode: Mode) -> Spec { let mut style = StyleDesc::default(); style.overflow = Some(overflow.to_string()); From 2d189227d46f5e1fc2640f269d23a82580d1377f Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:11:09 +0200 Subject: [PATCH 32/55] fix(native): keep env overrides in a map instead of setenv --- .changeset/sync-env-overrides-under-bun.md | 12 ++++--- packages/native/index.d.ts | 10 +++--- packages/native/src/renderer.rs | 33 ++++++++++++++----- packages/native/src/renderer/scrollbar.rs | 6 ++-- .../react/src/reconciler/event-registry.ts | 10 +++--- packages/react/src/testing.ts | 23 ++++++++----- 6 files changed, 58 insertions(+), 36 deletions(-) diff --git a/.changeset/sync-env-overrides-under-bun.md b/.changeset/sync-env-overrides-under-bun.md index 3719e8bf..c1eaf3ad 100644 --- a/.changeset/sync-env-overrides-under-bun.md +++ b/.changeset/sync-env-overrides-under-bun.md @@ -5,8 +5,10 @@ Push `process.env` overrides through to the Rust side under Bun. -Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`. Node -writes a `process.env` assignment through to `setenv`, but Bun only updates -its JS snapshot, so a test that set the variable after start had no effect -under `bun test`. The native module now exports `syncEnvVar`, and the test -renderer copies the known overrides across before every frame flush. +Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a +`process.env` assignment through to `setenv`, but Bun only updates its JS +snapshot, so a test that set the variable after start had no effect under +`bun test`. The native module now exports `syncEnvVar`, and the test +renderer copies the known overrides across before every frame flush. The +values land in an override map, not in the real environment, because +`setenv` races `getenv` on the dedicated UI thread of Windows and Linux. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 788f43bd..860bb16b 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -520,12 +520,12 @@ export interface HighlightRect { } /** - * Copies one `process.env` entry into the real process environment. + * Records one `process.env` entry for `env_var` readers. * - * Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, - * which reads the C environment. Node writes a `process.env` assignment - * through to `setenv`, but Bun only updates its JS snapshot. A caller on - * Bun must push the value across with this function. + * Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a + * `process.env` assignment through to `setenv`, but Bun only updates its + * JS snapshot. A caller on Bun must push the value across with this + * function. */ export declare function syncEnvVar(key: string, value?: string | undefined | null): void diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 50d75d1c..6bafaf23 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -601,18 +601,33 @@ fn panic_message(payload: Box) -> String { .unwrap_or_else(|| "unknown panic".to_string()) } -/// Copies one `process.env` entry into the real process environment. +/// JS-side values for environment overrides such as `GPUIX_SCROLLBARS`. /// -/// Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, -/// which reads the C environment. Node writes a `process.env` assignment -/// through to `setenv`, but Bun only updates its JS snapshot. A caller on -/// Bun must push the value across with this function. +/// A map instead of `std::env::set_var`, because `setenv` races `getenv` +/// on another thread, and Windows and Linux paint on a dedicated UI thread. +fn env_overrides() -> &'static std::sync::Mutex>> { + static OVERRIDES: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + OVERRIDES.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// Reads an override, or the real process environment when JS never set one. +pub(crate) fn env_var(key: &str) -> Option { + if let Some(value) = env_overrides().lock().unwrap().get(key) { + return value.clone(); + } + std::env::var(key).ok() +} + +/// Records one `process.env` entry for `env_var` readers. +/// +/// Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a +/// `process.env` assignment through to `setenv`, but Bun only updates its +/// JS snapshot. A caller on Bun must push the value across with this +/// function. #[napi] pub fn sync_env_var(key: String, value: Option) { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } + env_overrides().lock().unwrap().insert(key, value); } /// The main GPUI renderer exposed to Node.js. diff --git a/packages/native/src/renderer/scrollbar.rs b/packages/native/src/renderer/scrollbar.rs index 00dfdc84..7f2be60a 100644 --- a/packages/native/src/renderer/scrollbar.rs +++ b/packages/native/src/renderer/scrollbar.rs @@ -41,9 +41,9 @@ pub(crate) enum Mode { impl Mode { /// The mode for this window, with the environment override on top. pub(crate) fn current(cx: &App) -> Self { - match std::env::var("GPUIX_SCROLLBARS").as_deref() { - Ok("overlay") => Mode::Overlay, - Ok("classic") => Mode::Classic, + match crate::renderer::env_var("GPUIX_SCROLLBARS").as_deref() { + Some("overlay") => Mode::Overlay, + Some("classic") => Mode::Classic, _ if cx.should_auto_hide_scrollbars() => Mode::Overlay, _ => Mode::Classic, } diff --git a/packages/react/src/reconciler/event-registry.ts b/packages/react/src/reconciler/event-registry.ts index 12c2ff1c..84c895fa 100644 --- a/packages/react/src/reconciler/event-registry.ts +++ b/packages/react/src/reconciler/event-registry.ts @@ -9,11 +9,11 @@ import type { Container, EventHandlerMap, NativeRenderer } from "../types/host.j const CONTAINERS_KEY = "__gpuixEventContainers" function containersByRenderer(): WeakMap { - const existing = Reflect.get(globalThis, CONTAINERS_KEY) as - | WeakMap - | undefined - if (existing) { - return existing + const existing = Reflect.get(globalThis, CONTAINERS_KEY) + // Take the slot only when it really holds a WeakMap. Another value there + // (from user code or a second bundle copy) would throw on .get later. + if (existing instanceof WeakMap) { + return existing as WeakMap } const created = new WeakMap() Reflect.set(globalThis, CONTAINERS_KEY, created) diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index 5354ba46..e8a870de 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -121,17 +121,22 @@ const NATIVE_ENV_OVERRIDES = ["GPUIX_SCROLLBARS"] as const * `process.env.GPUIX_SCROLLBARS = "classic"` from a test. This runs before * every frame flush to push the current values across. */ +// Resolved once. The module registry caches the require, but this also +// skips the try/catch and the property read on every flush. +let nativeSyncEnvVar: ((key: string, value?: string) => void) | undefined +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + nativeSyncEnvVar = ( + require("@gpuix/native") as { syncEnvVar?: typeof nativeSyncEnvVar } + ).syncEnvVar +} catch { + // Native module not available. Nothing to sync. +} + function syncEnvOverrides(): void { - let syncEnvVar: ((key: string, value?: string) => void) | undefined - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - syncEnvVar = (require("@gpuix/native") as { syncEnvVar?: typeof syncEnvVar }).syncEnvVar - } catch { - return - } - if (!syncEnvVar) return + if (!nativeSyncEnvVar) return for (const key of NATIVE_ENV_OVERRIDES) { - syncEnvVar(key, process.env[key]) + nativeSyncEnvVar(key, process.env[key]) } } From 587a34c341f0ff82f160020ac85266b15beb753b Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:15:13 +0200 Subject: [PATCH 33/55] test(react): wash covers the first glyph of a wrapped row --- .../src/__tests__/selection-layout.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react/src/__tests__/selection-layout.test.tsx b/packages/react/src/__tests__/selection-layout.test.tsx index 1e263969..86375164 100644 --- a/packages/react/src/__tests__/selection-layout.test.tsx +++ b/packages/react/src/__tests__/selection-layout.test.tsx @@ -215,4 +215,34 @@ describe("standard events on native elements", () => { renderer.nativeSimulateClick(200, 18) expect(onClick).toHaveBeenCalled() }) + + it("washes the first glyph of a wrapped row", () => { + const { render, renderer } = createTestRoot() + render( +
+ + OOOOO OOOOO + +
+ ) + + // The 100px box wraps the text after the space, so the second word + // starts a new visual row at the left edge. Sample a strip through the + // middle of that row's first glyph cell before and after the drag. The + // wash on a continuation row started one glyph late, so nothing under + // the first glyph changed. + const strip = () => + Array.from({ length: 12 }, (_, x) => renderer.pixelAt(x + 1, 60)) + const before = strip() + const black = ([r, g, b]: number[]) => r < 30 && g < 30 && b < 30 + + const selected = renderer.dragSelect(1, 20, 99, 60) + expect(selected).toBe("OOOOO OOOOO") + + const after = strip() + const washed = before.some( + (pixel, index) => black(pixel) && !black(after[index]!) + ) + expect(washed).toBe(true) + }) }) From 140869ae53b724f587289f8eb05acfee879ac720 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 00:03:09 +0200 Subject: [PATCH 34/55] 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 35/55] 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 36/55] 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 37/55] 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 38/55] 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 1f105d032de1b26d26c1837515fab09b4f5fc5e2 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 15:59:41 +0200 Subject: [PATCH 39/55] feat(demo): add a scrollbars panel --- examples/demo.test.tsx | 39 ++++++- examples/demo/app.tsx | 2 + examples/demo/scrollbars.tsx | 195 +++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 examples/demo/scrollbars.tsx diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 3b358808..60249e6c 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -20,6 +20,7 @@ import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" import { Motion } from "./demo/motion-panel" +import { IntoView, Scrollbars } from "./demo/scrollbars" import { Variables } from "./demo/variables" import { resolveClassName } from "./demo/classes" @@ -40,6 +41,7 @@ const PANELS = [ ["inheritance", ], ["classes", ], ["motion", ], + ["scrollbars", ], ] as const describeNative("demo panels", () => { @@ -205,6 +207,41 @@ describeNative("height: auto", () => { }) }) +describeNative("the scrollbars panel", () => { + it("scrollIntoView honours scroll-padding and scroll-margin", () => { + const test = root() + test.render( +
+ +
+ ) + const box = test.renderer.findByTestId("into-view-box")! + expect(test.renderer.getScrollOffset(box.id)![1]).toBe(0) + + const start = test.renderer.findByText("start")! + const [x, y] = test.renderer.getElementBounds(start.id)! + test.renderer.nativeSimulateClick(x + 4, y + 4) + + expect(test.renderer.getScrollOffset(box.id)![1]).toBeLessThan(0) + const [, boxY] = test.renderer.getElementBounds(box.id)! + const row = test.renderer.findByTestId("into-view-target")! + const [, rowY] = test.renderer.getElementBounds(row.id)! + // 12px of scroll-padding plus 16px of scroll-margin, inside the border. + expect(rowY - boxY).toBeGreaterThanOrEqual(28) + expect(rowY - boxY).toBeLessThanOrEqual(30) + 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 @@ -214,7 +251,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", "Motion", "Scrollbars", "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 f16934d8..42ca5f08 100644 --- a/examples/demo/app.tsx +++ b/examples/demo/app.tsx @@ -17,6 +17,7 @@ import { Inheritance } from "./inheritance.js" import { Lengths } from "./lengths.js" import { Motion } from "./motion-panel.js" import { frameOverlay, Perf } from "./perf.js" +import { Scrollbars } from "./scrollbars.js" import { Variables } from "./variables.js" /// The palette every panel reads. Exported so a test can mount one panel @@ -76,6 +77,7 @@ const SECTIONS = [ { id: "inheritance", title: "Inheritance", render: () => }, { id: "classes", title: "className", render: () => }, { id: "motion", title: "Motion", render: () => }, + { id: "scrollbars", title: "Scrollbars", render: () => }, ] as const type SectionId = (typeof SECTIONS)[number]["id"] | "perf" diff --git a/examples/demo/scrollbars.tsx b/examples/demo/scrollbars.tsx new file mode 100644 index 00000000..2cb11f9c --- /dev/null +++ b/examples/demo/scrollbars.tsx @@ -0,0 +1,195 @@ +/// Scroll boxes, the bars they paint, and scrollIntoView. +/// +/// The OS picks the kind of bar. An overlay bar floats over the content and +/// fades out after a scroll. A classic bar keeps a track and reserves a +/// gutter in the layout. Every box here also scrolls with the wheel, with a +/// drag on the thumb, and with a click in the track, which moves one page. + +import React, { useRef } from "react" +import { useGpuix } from "@gpuix/react" +import type { StyleDesc } from "@gpuix/react" +import { Button, Grid, Panel, Row, Sample } from "./ui.js" + +/// Rows tall enough to overflow the box, so a bar shows. +function Rows({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+ {`row ${i + 1}`} +
+ ))} +
+ ) +} + +function ScrollBox({ style, count = 14 }: { style: StyleDesc; count?: number }) { + return ( +
+ +
+ ) +} + +function Bars() { + return ( + + + + + + + + + + + + + + + + + ) +} + +/// The content fits, so only the reserved gutter tells the boxes apart. +/// The full-width band paints the content area, and the gutter is the strip +/// the band does not cover. +function Gutters() { + const band: StyleDesc = { + height: 100, + margin: 8, + borderRadius: 6, + backgroundColor: "var(--color-track)", + } + return ( + + + +
+
+
+ + +
+
+
+ + +
+
+
+ + + + ) +} + +function BothAxes() { + return ( + +
+
+ 900 x 400 of content in a smaller box. +
+
+
+ ) +} + +export function IntoView() { + const { renderer } = useGpuix() + const target = useRef<{ id: number } | null>(null) + const show = (block: string) => { + if (renderer && target.current) { + renderer.scrollIntoView?.(target.current.id, block) + } + } + return ( + + +