diff --git a/.changeset/collect-garbage-when-idle.md b/.changeset/collect-garbage-when-idle.md new file mode 100644 index 000000000..af8758dfe --- /dev/null +++ b/.changeset/collect-garbage-when-idle.md @@ -0,0 +1,6 @@ +--- +'@gpuix/native': patch +'@gpuix/react': patch +--- + +`collectGarbageWhenIdle()` runs one full garbage collection once the app has stopped committing for a while, so the heap of a long session comes back down after a burst of work. It watches the new `appliedBatchCount()` and the process's CPU, and returns a function that stops it. diff --git a/.changeset/create-portal.md b/.changeset/create-portal.md new file mode 100644 index 000000000..8cc5d016f --- /dev/null +++ b/.changeset/create-portal.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': minor +--- + +Added `createPortal(children, target)`, which mounts children into an element that is already mounted. Take `target` from a `ref`. The portal's children are appended to the target and kept in key order, so a toast layer, a drag preview or an overlay root can live in another part of the tree than the component that renders it. See "Portals" in the README. diff --git a/.changeset/elements-at.md b/.changeset/elements-at.md new file mode 100644 index 000000000..947ce717f --- /dev/null +++ b/.changeset/elements-at.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +`getElementsAt(x, y)` lists the elements under a window point, topmost first, like the DOM's `elementsFromPoint`. It clips at `overflow: hidden | scroll`, skips `pointer-events: none`, and puts a deferred `` layer above the rest. The test renderer has it too. diff --git a/.changeset/focusable-ids.md b/.changeset/focusable-ids.md new file mode 100644 index 000000000..a48312d08 --- /dev/null +++ b/.changeset/focusable-ids.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +Every frame no longer walks every element to find the ones that take focus. The tree now keeps that set up to date as props and listeners change, so an idle frame of a tree with thousands of elements costs about a third less CPU. Focus, tab order, `autoFocus`, and `onKeyDown`/`onFocus`/`onBlur` behave as before. diff --git a/.changeset/highlight-search-needle.md b/.changeset/highlight-search-needle.md new file mode 100644 index 000000000..02097a01e --- /dev/null +++ b/.changeset/highlight-search-needle.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +Typing in a find-in-page query no longer rebuilds the search for every block of text. The query is lowercased and compiled once per keystroke and shared by all the text it is matched against, so each keystroke over a long document costs a few percent less CPU. Matches are the same as before: leftmost, non-overlapping, case-insensitive by default. diff --git a/.changeset/hover-ends-at-window-edge.md b/.changeset/hover-ends-at-window-edge.md new file mode 100644 index 000000000..716a6f933 --- /dev/null +++ b/.changeset/hover-ends-at-window-edge.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +Leaving the window ends hover. The `hover` style of the element the pointer last touched used to stay painted, and the cursor and tooltips stayed with it, until the pointer came back. `onMouseLeave` already fired. The test renderer gains `simulateMouseExit` and `nativeSimulateMouseExit`. diff --git a/.changeset/invalidate-only-on-change.md b/.changeset/invalidate-only-on-change.md new file mode 100644 index 000000000..a70c3ce09 --- /dev/null +++ b/.changeset/invalidate-only-on-change.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +`applyBatch` asks the window to repaint only when the batch changed something a frame would draw. A batch that only resends the style, text, listeners or custom props an element already has no longer rebuilds and re-lays-out the whole element tree for a frame that comes out identical. diff --git a/.changeset/more-css-layout-styles.md b/.changeset/more-css-layout-styles.md new file mode 100644 index 000000000..5e645f1df --- /dev/null +++ b/.changeset/more-css-layout-styles.md @@ -0,0 +1,13 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +New styles for what gpui already draws and GPUIX did not expose: + +- `aspectRatio` sizes the axis that has no definite size. +- `borderStyle: "dashed"` draws every border side dashed. +- `boxShadow` accepts an array of shadows, and each shadow takes `inset: true`. +- `alignItems` accepts `"stretch"` and `"baseline"`, which `alignSelf` already did. +- `justifyContent` accepts `"space-evenly"`, which `alignContent` already did. diff --git a/.changeset/native-scrollbars.md b/.changeset/native-scrollbars.md new file mode 100644 index 000000000..25d715b7a --- /dev/null +++ b/.changeset/native-scrollbars.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +`scrollbarColor` and `scrollbarWidth` paint a native scrollbar thumb over a container with `overflow: "scroll"`, on each axis that has more to show. The thumb follows the scroll offset in the frame it paints, with no JavaScript in the loop and no extra frame, and it takes no pointer input. diff --git a/.changeset/on-layout.md b/.changeset/on-layout.md new file mode 100644 index 000000000..b465419d0 --- /dev/null +++ b/.changeset/on-layout.md @@ -0,0 +1,9 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +`onLayout` on `
` and `` reports the element's border box after its first paint and each time its size changes, with no polling of `getElementBounds`. The event payload gains `width` and `height`. + +`visibility: "hidden"` elements have never reported bounds, because gpui paints nothing for them and nothing inside them. The canvas fallback that claimed otherwise could not run and is gone. diff --git a/.changeset/on-scroll-change.md b/.changeset/on-scroll-change.md new file mode 100644 index 000000000..98092b8e0 --- /dev/null +++ b/.changeset/on-scroll-change.md @@ -0,0 +1,7 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +`onScrollChange` reports the scroll offset of an `overflow: "scroll"` element after it moves, whatever moved it: the wheel, a scrollbar drag, `scrollTo` or `scrollIntoView`. `onScroll` still reports only wheel input. The event payload gains `scrollX` and `scrollY`. diff --git a/.changeset/react-reconciler-0-33.md b/.changeset/react-reconciler-0-33.md new file mode 100644 index 000000000..10ad56717 --- /dev/null +++ b/.changeset/react-reconciler-0-33.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': minor +--- + +`@gpuix/react` now uses `react-reconciler@^0.33.0` and peers on `react@^19.2.0`. React 19.2 pairs with reconciler 0.33, and the old `^0.31.0` pin forced every app on 19.2 to override it. The `^18.0.0` half of the old peer range was never true, since reconciler 0.31 already required React 19. diff --git a/.changeset/register-font.md b/.changeset/register-font.md new file mode 100644 index 000000000..18b45e5b7 --- /dev/null +++ b/.changeset/register-font.md @@ -0,0 +1,7 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +'@gpuix/solid': minor +--- + +`registerFont(bytes)` loads a TrueType or OpenType font so `fontFamily` can name it. Text that asked for the family before it was registered picks the font up on the next frame. Fonts GPUI would abort on, or cannot use, are rejected with an error instead. diff --git a/.changeset/root-context-stable-value.md b/.changeset/root-context-stable-value.md new file mode 100644 index 000000000..378387f2b --- /dev/null +++ b/.changeset/root-context-stable-value.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': patch +--- + +`root.render()` no longer re-renders every `useGpuix()` consumer. The root's context value was a new object on each call, so calling `render()` again (a hot reload, a test, an app that renders the root with new props) counted as a context change and re-rendered each consumer and, through its new callbacks, every `memo` row below it. In the chat example this was 305 row renders and about 9 ms per keystroke of a find bar; it is now 0.2 ms. diff --git a/.changeset/skip-unchanged-style-updates.md b/.changeset/skip-unchanged-style-updates.md new file mode 100644 index 000000000..87bd84890 --- /dev/null +++ b/.changeset/skip-unchanged-style-updates.md @@ -0,0 +1,11 @@ +--- +'@gpuix/react': patch +'@gpuix/native': patch +'@gpuix/solid': patch +--- + +A re-render no longer resends styles and custom props that did not change. React calls `commitUpdate` for every element a parent re-rendered, and the reconciler used to serialize and send each one's `style` every time, which cost a JSON round trip and a style intern per element per commit. It now compares values, so `style={{ ... }}` rebuilt with the same contents sends nothing. The custom prop diff is linear instead of quadratic in the number of props, and the event handler diff walks the props an element has instead of every event GPUIX knows. + +A style change on an element React has hidden (Suspense, Activity) no longer makes it visible again: it keeps `visibility: hidden` until React unhides it. + +`@gpuix/native/host` exports `sameMutationValue(left, right)` and `EVENT_TYPE_BY_PROP` for adapters that diff the same payloads. diff --git a/.changeset/track-bounds-without-a-child.md b/.changeset/track-bounds-without-a-child.md new file mode 100644 index 000000000..f4789738a --- /dev/null +++ b/.changeset/track-bounds-without-a-child.md @@ -0,0 +1,7 @@ +--- +'@gpuix/native': patch +--- + +`
` and `` no longer add a hidden child element to record their bounds. Each host element used to carry an extra `canvas` node, which doubled the nodes gpui laid out, prepainted and painted every frame. The bounds now come from the element's own paint, so a frame over the same tree is about 15% cheaper. An element with `visibility: hidden` keeps the child, because gpui does not paint a hidden element and its layout box must stay readable. + +`getElementBounds()`, automation locators and text selection regions now use the element's border box. They used the padding box before, which was narrower by the border width on an element with a `borderWidth`. diff --git a/.changeset/virtual-list-horizontal-padding.md b/.changeset/virtual-list-horizontal-padding.md new file mode 100644 index 000000000..2b1f8a28d --- /dev/null +++ b/.changeset/virtual-list-horizontal-padding.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +`padding` on a `` now insets its rows on the left and right as well as the top and bottom. Rows used to span the list's full width and ignore left and right padding. A `scrollbarColor` thumb stays at the list's own edge, past the padding. diff --git a/.changeset/virtual-list-scrollbar.md b/.changeset/virtual-list-scrollbar.md new file mode 100644 index 000000000..d5c8cd8cf --- /dev/null +++ b/.changeset/virtual-list-scrollbar.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +`scrollbarColor` and `scrollbarWidth` now also draw a thumb on a ``. It is painted over the list in the frame the list scrolls, from the heights the list has measured, so it never lags the scroll position and costs no frame of its own. The list's layout is unchanged. diff --git a/.changeset/virtual-list-thumb-averages-rows.md b/.changeset/virtual-list-thumb-averages-rows.md new file mode 100644 index 000000000..3cb35ba5b --- /dev/null +++ b/.changeset/virtual-list-thumb-averages-rows.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +The thumb of a `` counts the rows the list has not measured yet as the average measured row, not as `estimatedItemHeight`. A list of tall rows with a short estimate no longer draws a thumb several times too long that shrinks as rows scroll into view; the thumb is right once a few rows have been measured. diff --git a/README.md b/README.md index ac02e031e..745aff7e9 100644 --- a/README.md +++ b/README.md @@ -830,6 +830,37 @@ activation policy, so there is no menu-bar-agent mode yet. For a real background daemon, run the app from a `launchd` agent in `~/Library/LaunchAgents/`; launchd never activates the process. +### Custom fonts + +`registerFont()` makes a font that is not installed on the machine available to +`fontFamily`. It takes the bytes of a **TrueType or OpenType** file (`.ttf` or +`.otf`). + +```tsx +import { readFileSync } from 'node:fs' +import { useGpuixRequired } from '@gpuix/react' + +function LoadFonts() { + const renderer = useGpuixRequired() + useEffect(() => { + renderer.registerFont?.(readFileSync('./Inter-Regular.ttf')) + }, [renderer]) + return
Hello
+} +``` + +Text that already named the family repaints with the font once it is +registered, so there is no need to register before the first render. Register +every weight and style of a family you use; text picks the closest one. + +The font must have family, full and PostScript names and a glyph for `"m"`. +GPUI aborts the process on a font without the names, and silently drops a font +without the glyph, so `registerFont()` throws for both and for bytes that are +not a font. Fonts load into the process and last until it exits. + +WOFF and WOFF2 are not supported; convert them to TrueType first. The browser +renderer does not have this method, use `FontFace` there. + ### Let an agent drive the app Make focus opt-in through the environment, so a human run behaves normally and @@ -1713,6 +1744,51 @@ that height. An explicit `height` still overrides both. /> ``` +### Scrollbars + +A container with `overflow: 'scroll'` scrolls but draws no scrollbar. Give it a +`scrollbarColor` and gpuix paints a thumb over its content, on each axis that has +more to show: + +```tsx +
+ {rows} +
+``` + +`scrollbarWidth` is the thickness in px and defaults to 6. The thumb is as long +as the viewport is of the content (never under 20px), fully rounded, and sits +on the right edge, or the bottom for sideways scrolling, leaving the corner free +when both show. It is painted from the scroll offset in the frame the content +moves, so a wheel tick costs nothing beyond that frame. It is a drawing, not a +control: it takes no pointer input, so it cannot be dragged. It applies to +`overflow: 'scroll'` containers and to ``. A list's thumb comes +from the heights the list has measured (and `estimatedItemHeight` for rows it has +not reached), the same totals it scrolls by, so it only runs along the right edge +and its length settles as rows are measured. `padding` on a `` insets +its rows on all four sides, and the thumb stays at the list's own edge, past the +padding, where a browser puts a padded scroller's scrollbar. + +### Give memory back when idle + +A JavaScript engine collects on allocation pressure, so after a burst of work +(the first mount, opening a large screen) the heap stays at its peak until the +next burst. `collectGarbageWhenIdle()` runs one full collection once the app has +stopped committing for a while, then waits for the next burst. + +```tsx +import { collectGarbageWhenIdle } from '@gpuix/react' + +const stop = collectGarbageWhenIdle() // checks every 5s, collects after two quiet checks +``` + +It uses `Bun.gc(true)`, or `gc()` where Node runs with `--expose-gc`, and does +nothing in a runtime that has neither. Quiet means no mutation batch reached a +renderer and the process is under half a core. The batch count decides, not CPU +alone, because a focused text field blinks its caret in native code and redraws +twice a second. Pass `activity`, `collect`, `checkMs`, `quietChecks` or +`busyCpu` to change what it watches or how often. + ### Input in a search pill `` has **no default inner padding** and paints text at the top of its @@ -2209,18 +2285,69 @@ A filled child of a click target (switch thumb, radio dot, check icon) needs **`pointerEvents: "none"`**, or it eats the parent's click. For Select and Combobox rows, use the item primitive's `asChild` prop instead. +### Portals + +`createPortal(children, target)` mounts `children` as the children of an element +that is already mounted, wherever the calling component sits in the tree. Use it +for a toast layer, a drag preview, or any content that belongs to another part of +the window. Context still flows from the caller, as in React DOM. + +Take `target` from a `ref` and render the portal once it is set. A portal into an +element that has not mounted has nowhere to attach. + +```tsx +import { createPortal, type PublicInstance } from '@gpuix/react' + +function Screen() { + const [layer, setLayer] = useState(null) + return ( + <> +
+ {layer && createPortal(, layer)} + + ) +} +``` + +The portal's children are appended after whatever the target already holds, and +React keeps them in key order among themselves. `target` must belong to the same +root as the component that calls `createPortal`. + +For floating menus that must paint above a ``, use +[``](#overlay-menus) rather than a portal: a portal changes where +elements live in the tree, not the order gpui paints them in. + ### Measure an element `getElementBounds(id)` returns the last painted box, or `null` if that node did not paint. It works on the live `GpuixRenderer` and on the test renderer. Bounds are recorded during **paint**, so read them after a frame, not in the -same commit as mount. +same commit as mount. The box is the element's border box, like a DOM +`getBoundingClientRect`. ```tsx const box = renderer.getElementBounds?.(ref.current.id) // { x, y, width, height } ``` +### Find what is under a point + +`getElementsAt(x, y)` returns the ids of the elements whose last painted box +holds a window point, topmost first, like the DOM's `elementsFromPoint`. It +works on the live `GpuixRenderer` and on the test renderer. + +```tsx +const [topmost] = renderer.getElementsAt?.(event.x, event.y) ?? [] +``` + +It answers from the last frame, so an element that did not paint (hidden, or +outside a virtual list's window) is never listed. `overflow: hidden` and +`scroll` clip what lies outside them, `pointer-events: none` is skipped (and +inherited, until a child sets `auto`), and a deferred +[``](#overlay-menus) layer comes first, ordered by `priority`, +because gpui paints it after the tree and outside the clip of its ancestors. +Beyond that, tree order is paint order: gpuix has no z-index. + ### Button GPUIX has no native `