@gpuix/react and @gpuix/native.
Both packages are published together by CI, so a version number always names the
same commit on both sides of the bridge.example-chat-<target>)
for macOS, Linux, and Windows. Download it from the release page to see the chat
example run on the GPU without installing Rust.@gpuix/react/testing reporting no native renderer when installed from npm. hasNativeTestRenderer was always false, so every suite that guards on it skipped silently:12Test Files 1 skipped (1) Tests 6 skipped (6)
testing.js ships as ESM and loaded the addon with a bare require("@gpuix/native"). Node has no require in ESM. Inside this repository vitest inlines the workspace package and provides one, so the suite here always passed; installed from npm the package is externalized and run by Node, the call threw, and the catch reported native as missing. It now uses createRequire(import.meta.url).highlight prop and the macOS menu bar.RetainedTree, GpuixView, styles, and text painting are shared with desktop, so events, selects, comboboxes, inputs, motion, and GPUI scroll gestures all work through a Wasm-to-JavaScript callback bridge. napi-rs stays the desktop bridge; wasm-bindgen starts gpui_web in the page.12bun run web # build the Wasm if it is missing, then serve with HMR bun run web:wasm # only cargo + wasm-bindgen
bun run web serves through Bun's frontend dev server, so an edit to a component module is a React Fast Refresh update instead of a page reload. useState survives, the GPUI canvas is never re-created, and the ~19 MB Wasm module is never re-fetched.globalThis, so Playwright or Playwriter can drive them by evaluating in the page:123await globalThis.gpuix.getByTestId('send').click() await globalThis.gpuix.getByTestId('composer').fill('hello') await globalThis.gpuix.clock.fastForward(200)
import.meta.hot.accept("./your-app", ...) in the entry file, because Bun runs the dependency-accept callback even when the module already self-accepted for Fast Refresh and the remount wipes every hook; and keep the @gpuix/native import out of any Refresh boundary, because the Wasm half is a singleton and WebGpuixRenderer::init fails with GPUIX web is already running.render(<App />, { debugFrameOverlay: 'full' })), macOS browsers get Option+Left / Option+Right word navigation in inputs, diagonal resize cursors point the right way, and GPUI Web's IME bridge is fully hidden so host input CSS can no longer unhide a stray text field at the top of the page.highlight prop — paint a background wash behind matched or explicitly given text ranges. This is what you need for Ctrl+F, agent citations, or LSP diagnostic tints. Put it on any element and it applies to that subtree, so the root searches the window and a container searches only that container.123<div highlight={{ query: 'fox' }}> <text>the quick brown fox</text> </div>
<text>, <code>, <markdown> and <diff> with no extra props, because every string GPUIX paints goes through the same funnel.useTextSearch owns the cursor and the count, so a find bar needs no effects:1234567891011import { useTextSearch } from '@gpuix/react' const search = useTextSearch({ query }) <text>{search.total === 0 ? 'No results' : `${search.active + 1}/${search.total}`}</text> <div onClick={search.previous}><text>↑</text></div> <div onClick={search.next}><text>↓</text></div> <div {...search.props} style={{ flex: 1 }}> <Transcript /> </div>
| field | meaning |
query | substring to match, case-insensitive by default |
caseSensitive | exact case only |
wholeWord | neither neighbour may be alphanumeric or _ |
ranges | explicit [start, end) UTF-16 pairs |
color / activeColor | any CSS colour; defaults come from the theme |
activeIndex | which match gets activeColor, for a find cursor |
matchIndexOffset | matches before this subtree; only for virtualized content |
radius | corner radius of the wash, default 2 |
<text>Hello {name}!</text> is three host text nodes and Hello Tommy still matches. activeIndex counts matches in paint order, so it means the same thing whether a match sits in a <text> or inside a <code> block.<virtual-list> never builds off-screen rows, so the app supplies both numbers with the new findRanges export, which runs the same algorithm as the native matcher on a string you give it:1234567891011121314import { findRanges, useTextSearch } from '@gpuix/react' const perRow = useMemo( () => rows.map((row) => findRanges({ text: row.text, query }).length), [rows, query], ) const search = useTextSearch({ query, matches: { total: perRow.reduce((n, count) => n + count, 0), indexOffset: perRow.slice(0, windowStart).reduce((n, count) => n + count, 0), }, })
getPaintedText() cannot see it. renderer.getPaintedHighlights() reports the matched range in UTF-16 units plus the boxes it drew, one per visual row.highlight. A root-scoped query over a 1000-turn chat costs about 2ms per keystroke, and moving the find cursor only re-colours matches it already found.<virtual-list> can mount a window of rows instead of all of them. The children form retains every child, so the first mount of a long transcript used to pay for every row. Pass itemCount with estimatedItemHeight and windowStart, then render only that slice; native keeps the full logical length for the scrollbar.1234567891011121314151617181920const WINDOW = 40 function Transcript({ turns }: { turns: Turn[] }) { const [start, setStart] = useState(0) const end = Math.min(turns.length, start + WINDOW) return ( <virtual-list itemCount={turns.length} windowStart={start} estimatedItemHeight={220} onVisibleRange={(event) => setStart(Math.max(0, Math.floor(event.startIndex ?? 0) - WINDOW / 4)) } > {turns.slice(start, end).map((turn) => ( <ChatTurn key={turn.id} turn={turn} /> ))} </virtual-list> ) }
onVisibleRange reports startIndex and endIndex after a scroll. TypeScript now requires estimatedItemHeight next to itemCount, and native ignores itemCount without it, because a row React has not mounted would otherwise measure as height 0 and collapse the scrollbar on a jump.VirtualList wrapper component. The window is application state. A generic wrapper cannot know when to widen its own window, so it silently dropped rows whenever itemCount grew without a scroll, which is exactly what a filter does.1234567scrolled down pinned to the top ┌──────────────────┐ ┌──────────────────┐ │ new row (above) │ ◄── inserted │ new row │ ◄── inserted, visible ├──────────────────┤ ├──────────────────┤ │ ░░ viewport ░░░░ │ stays put │ ░░ viewport ░░░░ │ follows the insert │ ░░░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░░░ │ └──────────────────┘ └──────────────────┘
scrollTop: 0. A top-aligned list that is scrolled to the very top now stays at the top across a mutation. Scrolled anywhere else, the rows under the pointer still do not move. A history pane that loads older pages while the user reads should keep using alignment="bottom".12345678await app.getByTestId('clip-7').dragBy(120, 0, { steps: 6 }) await app.getByTestId('clip-7-trim-end').dragTo(app.getByTestId('clip-8')) await app.getByTestId('canvas').wheel(0, 120, { modifiers: 'cmd' }) await app.getByTestId('row-3').hover() await app.mouse.drag({ x: 240, y: 500 }, { x: 700, y: 620 }) await app.mouse.wheel({ x: 700, y: 600 }, -140, 0) await app.mouse.down({ x: 100, y: 100 }, { button: 2 })
| Call | What it does |
locator.hover() | Moves the pointer to the center, so hover styles and tooltips fire |
locator.wheel(dx, dy) | One wheel event over the center |
locator.dragBy(dx, dy) / locator.dragTo(target) | Press, travel, release |
locator.center() | The center of the last painted bounds |
app.mouse.move / down / up / click / wheel / drag | Raw pointer input in window coordinates |
modifiers in the same hyphenated syntax as press('cmd-a'), so cmd-wheel zoom, shift-click range selection, and alt-drag duplication are testable. launch() can now scroll a live app, textContent() concatenates descendants like DOM textContent, and click({ button }) really sends that button. Mouse input, locator bounds, and clock controls also work against a live app on Windows, Linux, and FreeBSD.<input> and <textarea> are reachable from the locator API.12await app.getByTestId('composer').click() await app.getByTestId('composer').fill('hello gpuix')
bounds() and click() threw Element has no painted bounds, because a custom element paints itself and the editor never attached the automation bounds tracker; the only workaround was a hard-coded pixel coordinate. In the browser, fill() and press() threw GPUI browser input is unavailable: the client looked for input[data-gpui-input], and zed-industries/zed#63201 replaced that element with a <textarea>. It now matches the attribute alone, exported as IME_MIRROR_SELECTOR.<img>, <svg>, <anchored>, <diff> and <markdown> do register painted bounds now too, so a testId on <markdown> no longer returns null, and TestRenderer.findByTestId() resolves it from the retained tree.⌘Q, ⌘H, ⌥⌘H, ⌘M and ⌘W work. GPUI never calls NSApplication.setMainMenu:, so NSApp.mainMenu stayed nil, macOS painted nothing next to the Apple menu, and there was no way to quit a GPUIX app from the keyboard.123456Apple <executable> Window ├ Services ├ (AppKit window tiling) ├ Hide <appName> ⌘H ├ Minimize ⌘M ├ Hide Others ⌥⌘H ├ Zoom ├ Show All ├ Close Window ⌘W └ Quit <appName> ⌘Q └ (open windows)
appName window option for the name inside Hide X and Quit X. It defaults to title.1render(<App />, { title: 'Todo', appName: 'Todo' })
appName does not set the title of the application menu: macOS takes that from the executable, so bun app.tsx shows bun. Only a real .app bundle changes it. There is no Edit menu on purpose, because AppKit consumes a menu key equivalent before the window sees it and ⌘C would be taken away from text selection and from <input>.onMouseMove and onMouseUp continue after the pointer leaves the element that received onMouseDown, matching setPointerCapture. A clip, resizer, or slider keeps receiving events without a full-window overlay.12345<div onMouseDown={(e) => startDrag(e)} onMouseMove={(e) => moveDrag(e)} onMouseUp={() => endDrag()} />
onMouseDown / onMouseUp does not capture, and a release outside still cancels the click, as in the DOM.div now uses BlockMouseExceptScroll: clicks and hovers stop, the wheel passes through.1234<div style={{ position: 'relative' }} onScroll={pan}> {/* the wheel over this clip now pans the surface behind it */} <div style={{ position: 'absolute', left: 240, width: 120, backgroundColor: '#38455C' }} /> </div>
pointerEvents: "auto" on the rare element that must swallow the wheel too, such as a modal backdrop. <anchored> still occludes by default. An absolutely positioned box still takes clicks with no background, exactly like an empty positioned div in a browser, so a wrapper that only carries a scroll offset should set pointerEvents: "none".<code> is a bare surface. It paints glyphs only: no fill, no border, no radius, no padding, no language header. style is the surface, exactly like a <div>, so the card look belongs to your app instead of to the element.123456789101112<code code={source} language="typescript" showLineNumbers style={{ padding: 12, borderRadius: 10, borderWidth: 1, borderColor: '#ffffff1f', backgroundColor: '#ffffff09', }} />
fontFamily, fontSize, fontWeight, lineHeight and color in style now beat the theme, and one resolver feeds the div text style, every TextRun, and the fixed row height. style.lineHeight used to be dropped and clip tall glyphs; it re-sizes the rows instead.showHeader is gone. Render your own header in a wrapper. Five theme.metrics fields only ever styled that card and moved to the mdCode* group, where they still tune the <markdown> fenced block:| Before | After |
codePaddingX / codePaddingY | mdCodePaddingX / mdCodePaddingY |
codeRadius | mdCodeRadius |
codeHeaderPaddingY | mdCodeHeaderPaddingY |
codeHeaderTextSize | mdCodeHeaderTextSize |
<markdown> keeps its card: a document renderer owns its layout, a primitive does not.HighlightKind values rather than baked-in colours, so a theme change recolours existing spans without a reparse.| grammar | fancy-regex, first use | Oniguruma, first use |
| TypeScript | ~133ms | ~12ms |
| Markdown | ~39ms | ~1.7ms |
| Rust | ~17ms | ~1.6ms |
applyBatch used to build a serde_json::Value tree, deep-clone every style payload out of it, and parse the clone a second time, so each style was allocated three times. The batch now deserializes straight from its JSON bytes into typed ops, and styles are shared by content: a 10,000-turn chat sends 59,320 setStyle ops carrying 90 distinct styles, and every element gets the same Arc.| before | after | |
| parse and apply | 127.1 ms | 30.1 ms |
| heap churn | 900.5 MB | 104.0 MB |
| allocations | 1,476,196 | 186,090 |
| retained tree | 224.5 MB | 42.6 MB |
| bytes per element | 3116 B | 592 B |
getAutomationTree() stops serializing style, events, and custom props, which took a 5k-row tree from about 110ms to about 22ms, so getByTestId().click() is no longer dominated by encoding unused style maps.@gpuix/native packed every platform binary into the main tarball through a *.node glob, on top of the six per-platform packages that optionalDependencies already resolves. A hello-world install paid for all of it:1234node_modules 254M ├── @gpuix/native 185M ◄── all six binaries, unused ├── @gpuix/native-darwin-arm64 23M ◄── the one that loads └── @gpuix/react 544K
ERR_DLOPEN_FAILED. The published .node statically imported TaskDialogIndirect from comctl32 v6 and u_strlen from icuuc.dll. Node and Bun do not activate comctl32 v6, so Windows resolved the old comctl32 and LoadLibrary failed before any JS ran.1bun -e "require('@gpuix/native'); console.log('OK')"
cursor keyword GPUI can paint is supported, not just pointer and default. Resize and drag cursors are what tell a user that an edge can be trimmed or a clip can be grabbed; until now col-resize was silently dropped.12<div style={{ cursor: 'grab', active: { cursor: 'grabbing' } }} /> <div style={{ cursor: 'col-resize' }} />
| Group | Keywords |
| Pointing | default, auto, pointer, context-menu, not-allowed, no-drop |
| Text | text, vertical-text, crosshair |
| Dragging | grab, grabbing, move, all-scroll, alias, copy |
| Resizing | col-resize, row-resize, ew-resize, ns-resize, nwse-resize, nesw-resize, n-resize, e-resize, s-resize, w-resize, ne-resize, nw-resize, se-resize, sw-resize |
cursor is a typed union, so an editor completes the list. An unlisted keyword is ignored, like any other invalid style value.transparent, alpha, none, and limited relative-colour forms. TypeScript types are unchanged.boxShadow with offset, blur, spread, and colour are new. Per-corner radii, flexBasis, and alignContent were already declared in the public style type but never applied; they work now.onAuxClick for the non-primary mouse buttons. onClick never fired for a right or middle click, so the isRightClick field it documents could never be true and a context menu had no event to hang on. onClick stays primary-only, like the DOM.123456<div onClick={() => select(item)} onAuxClick={(event) => { if (event.isRightClick) openContextMenu(event.x, event.y) }} />
onMouseDown and onMouseUp still see every button through event.button: 0 left, 1 middle, 2 right.useWindowSize() seeded state with a hardcoded 800x600 and read the renderer once from an effect, so a first read before the platform window had a size kept 800x600 forever, and a resize was never observed at all. It samples every 100 ms now and only rerenders when the numbers change.getWindowInsets() and useWindowInsets() report system and software-keyboard geometry, so a composer can stay above the iOS keyboard instead of hiding behind it:1234567const { keyboardTop, keyboardVisible, ime } = useWindowInsets() return ( <div style={{ paddingBottom: ime.bottom }}> {keyboardVisible ? `Keyboard starts at ${keyboardTop}px` : 'Keyboard closed'} </div> )
| Field | Meaning |
ime | Edges covered by the software keyboard |
safeArea | Edges covered by notches, status bars, home indicators |
effective | Per-edge max of the two, the region content should avoid |
keyboardTop | Y coordinate where the keyboard starts |
keyboardVisible | ime.bottom > 0 |
visibleHeight | Window height minus the effective top and bottom |
visualViewport events in bursts while the keyboard animates and iOS reports stale values on some of them:123useWindowInsets() // 100ms, the default useWindowInsets({ intervalMs: 250 }) // slower useWindowInsets({ intervalMs: false }) // read once, never poll
position: "fixed" lays out. overflow: "scroll" moved only one axis per wheel event, because GPUI zeroes the smaller of the two deltas by default.123<div style={{ width: 260, height: 220, overflow: 'scroll' }}> {/* one diagonal swipe now pans on X and Y together */} </div>
position: "fixed" blocked hits like absolute but stayed in normal flow, so a box drifted when its siblings changed; it now lays out like absolute.123[padding] hello world ^ press here, drag right → "hello world"
userSelect: "none" now also blocks the start.<text>Hello {name}!</text> is three painted runs of one line, and selecting across them used to copy them joined with newlines. Runs now carry the parent host element they belong to, so the same selection yields Hello Tommy!, while <code>, <diff> and <markdown> keep one line per line.key on every GPUIX element. A list built with .map() failed to typecheck, so any real app broke on the first tsc run:12error TS2322: Type '{ key: string; ... }' is not assignable to type 'Props'. Property 'key' does not exist on type 'Props'.
key lives on Props now, next to ref. It cannot live on JSX.IntrinsicAttributes, because TypeScript 5 ignores that member for intrinsic elements. Every element prop type extends Props, so <div>, <text>, <img>, <svg>, <canvas>, <input>, <textarea>, <anchored>, <code>, <diff>, <markdown> and <virtual-list> accept key again, and so do motion.div, Select, Combobox and Tooltip. @gpuix/react/jsx-dev-runtime types also match the runtime file now: they re-exported jsx and jsxs from react/jsx-dev-runtime, which exports only jsxDEV.createTestRoot() trees can both start at id 1 without overwriting each other's handlers, and a remount on the same native renderer keeps allocating new ids, so a late event from the old tree cannot hit a new handler that reused id 1.resetIdCounter() is gone, and handleGpuixEvent needs the renderer that produced the event:1handleGpuixEvent(event, renderer)
<markdown> wraps in flex columns. A markdown node in a flex row kept its max-content width, so a long paragraph or list item blew past the parent. The root and each text block shrink with min-width: 0 now, and a fenced block inside <markdown> matches <code>: long lines scroll on X and leave the vertical wheel on the parent.1234567<div style={{ display: 'flex', flexDirection: 'row', width: 280 }}> <div style={{ width: 40, flexShrink: 0 }} /> <markdown source="- a long sentence that must wrap in the remaining column" style={{ flexGrow: 1 }} /> </div>
TestGpuixRenderer, createTestRoot(), native input simulation, and PNG screenshot capture. A live window can call captureScreenshot() there too. Linux stays unavailable until GPUI ships its pending wgpu headless renderer.<input> keeps a GPUI entity handle, and GPUI's leak detector panics if one outlives the app, which killed the whole vitest worker on Windows after every test in the file had already passed. createTestRoot({ width, height }) does not size the window on Windows yet: it opens at the display size. Tracked in #21.createTestRoot() can also size the offscreen window, which was always 1280x800. That is wide enough to keep a centered maxWidth column at its cap, so any layout that only changes below a breakpoint was invisible to the suite.123const narrow = createTestRoot({ width: 640, height: 480 }) createTestRoot({ width: 640 }) // 640 x 800 createTestRoot({ width: 0 }) // throws: must be a positive, finite number
getDebugFrameOverlayStats() so tests and apps can read the same draw times the on-screen overlay shows.1234renderer.resetDebugFrameOverlayStats() // ... scroll or click ... const stats = renderer.getDebugFrameOverlayStats() // stats.currentMs, stats.p90Ms, stats.p99Ms, stats.maxMs, stats.frames, stats.samples
p90Ms is the overlay 10% line and p99Ms is the 1% line: the slow tail, not the fast frames.THROTTLE=utility restarts a run under taskpolicy -c utility, which pins work to E-cores as an M1/M2 Air CPU proxy. background and maintenance are slower. GPU and RAM stay on the host machine, so this is not Chrome 6x, and it should not be set in CI.12THROTTLE=utility bun run test chat.perf.test.tsx THROTTLE=utility bun --hot chat.tsx
jsxImportSource, which is required: without it TypeScript falls back to DOM types and <virtual-list>, <markdown>, <code> and style.hover all fail.12bun add @gpuix/react react bun add -d @types/react typescript
1{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@gpuix/react" } }
example-app/ is a complete todo app in one file, with scripts already wired:| Script | What it does |
bun run dev | Desktop app with hot remount |
bun run build | Standalone binary in dist/todo |
bun run web:dev | Browser build served with isolation headers |
bun run screenshot | Drives the app through the automation client |
bun run test | Vitest against the GPU test renderer |
bun run typecheck | tsc --noEmit |
<virtual-list>, a native <input>, motion.div, tinted <svg> icons, native hover and active, and testId automation hooks. Copy the folder, change @gpuix/react from workspace:^ to a version range, and run bun install.1cd examples && bun --hot timeline.tsx
overflow: "scroll" grid cannot drive a frozen header, because GPUI moves the grid on the wheel frame and the onScroll callback arrives a frame later, so the two tear apart during a fast pan. A drag needs no overlay: each clip and trim handle listens for onMouseDown, onMouseMove and onMouseUp, which arms pointer capture, so a release past the window edge still ends the gesture, while an overlay mounted on the press cannot arm anything.memo alone.12chmod +x example-chat-aarch64-apple-darwin ./example-chat-aarch64-apple-darwin
example-chat-x86_64-pc-windows-msvc.exe and double-click it.@gpuix/native and @gpuix/react are published as Apache-2.0. Both packages declare license: Apache-2.0 and ship the license text in the npm tarball. GPUI itself is Apache-2.0, so this matches the native dependency.destroyElement no longer leaves a dangling child id on the parent or skips invalidating the parent chain, so a cache keyed on the subtree revision cannot serve text that left the tree; automation calls after close() are rejected and shutdown is idempotent across the in-process and SSE backends.createTestRoot({ width, height }) does not size the window on Windows yet, tracked in #21.motion.div animations — animate from an initial style to a target style. React sends the targets once. Rust interpolates the presentation style and requests GPUI frames. The React tree is not reconciled on each frame.123456789import { motion } from '@gpuix/react' <motion.div initial={{ width: 0, opacity: 0 }} animate={{ width: 260, opacity: 1 }} transition={{ duration: 0.2, ease: 'easeOut' }} > Sidebar content </motion.div>
width, height, top, right, bottom, left, opacity, borderRadius. Timing uses seconds. ease is "linear", "ease", "easeIn", "easeOut", "easeInOut", or a cubic-bezier [x1, y1, x2, y2].initial={false} to mount at the first animate target. A running animation can reverse or change target without a jump.testId, then drive them from tests or from another process.12345678import { connectTest } from '@gpuix/react/automation' const app = await connectTest(renderer) await app.getByTestId('inc').click() await app.getByText('Count: 1').waitFor() await app.getByTestId('composer').fill('hello gpuix') await app.getByTestId('composer').press('enter') await app.captureFrames('review/sidebar', [0, 150, 300])
getByTestId, getByText, getByType. app.clock.pause(), set(ms), and fastForward(ms) freeze native motion time.launch({ command, args }) pipes stdin and speaks SSE data: lines:123456import { launch } from '@gpuix/react/automation' const app = await launch({ command: 'bun', args: ['examples/chat.tsx'] }) await app.getByTestId('composer').fill('hello') await app.screenshot({ path: 'live.png' }) await app.close()
div — display: "grid" plus gridTemplateColumns maps to GPUI's Taffy grid. Use gridColumnMin: "max-content" for tables so each column is as wide as its widest cell.1234567891011<div style={{ display: 'grid', gridTemplateColumns: 3, gridColumnMin: 'max-content', rowGap: 1, columnGap: 1, }} > {cells} </div>
gridTemplateRows and gridRowMin work the same on the other axis.render() now honors a transparent titlebar, traffic-light position, and a blurred or transparent window background.1234567891011import { render } from '@gpuix/react' render(<App />, { title: 'Waku', width: 1180, height: 820, titlebarTransparent: true, windowBackground: 'blurred', trafficLightX: 16, trafficLightY: 17, })
windowBackground is "opaque" (default), "transparent", or "blurred".<diff> flows with its parent — it no longer owns a scroller unless you pass scroll. Use maxLines to keep a long patch short. Show more fires onShowMore.12345678const [open, setOpen] = useState(false) <diff patch={unifiedPatch} wordDiff maxLines={open ? undefined : 24} onShowMore={() => setOpen(true)} />
1render(<App />, { title: 'My App', debugFrameOverlay: 'full' })
hidden, minimal, and full. The readout is draw time, not FPS. 8.3 MS is about 120 Hz.pointerEvents works — set pointerEvents: "none" to opt out. Set pointerEvents: "auto" to block even with no fill.FloatingLayer defaults to backgroundColor: "#1A1A1A".<svg> icons paint on the first frame — file paths and data:image/svg+xml URLs both work.bun --hot remounts no longer paint a black window.<input> and <textarea> match the macOS system text field.overflowX: "scroll" stays on the parent. Trackpad X still pans the wide child.div.applyBatch sends styles and custom props as JSON values instead of double-encoded strings.<anchored side="top"> commits again.<code>, <diff> and <markdown> — every string GPUIX paints can be selected with a drag and copied with Cmd+C. A drag can start in a plain <text> and end inside a code block.1234<div style={{ display: 'flex', flexDirection: 'column' }}> <text>drag from here</text> <code code={'and into this code block'} language="ts" /> </div>
userSelect: 'none'. Read the selection with renderer.getSelectedText().123<code code={source} language="typescript" showLineNumbers /> <diff patch={unifiedPatch} wordDiff collapsedPaths={['pnpm-lock.yaml']} /> <markdown source={readme} onLinkClick={(e) => open(e.value)} />
<input> and <textarea> — caret, mouse selection, IME, clipboard, undo/redo, and grapheme-safe deletion. Enter submits. Shift+Enter inserts a newline in a textarea.1<textarea value={draft} minRows={1} maxRows={8} onChange={(e) => setDraft(e.value ?? '')} onSubmit={send} />
render() remounts React on the same native window — bun --hot saves remount the tree without a second window.12import { render } from '@gpuix/react' render(<App />, { title: 'My App', width: 800, height: 600 })
@gpuix/react/select, @gpuix/react/combobox, or @gpuix/react/tooltip and wrap them in local components/ui files.12345678910import * as SelectPrimitive from '@gpuix/react/select' <SelectPrimitive.Root value={model} onValueChange={setModel}> <SelectPrimitive.Trigger> <SelectPrimitive.Value placeholder="Select a model" /> </SelectPrimitive.Trigger> <SelectPrimitive.Content> <SelectPrimitive.Item value="sonnet">Sonnet</SelectPrimitive.Item> </SelectPrimitive.Content> </SelectPrimitive.Root>
<virtual-list> — GPUI builds only rows near the viewport.123<virtual-list alignment="bottom" followTail estimatedItemHeight={180}> {messages.map((message) => <Message key={message.id} message={message} />)} </virtual-list>
1<svg src="/absolute/path/to/search.svg" style={{ width: 16, height: 16, color: '#b4b4b4' }} />
startFrameLoop() — idle CPU drops from ~73% to ~1%. Default pace is ~125fps.12import { startFrameLoop } from '@gpuix/react' startFrameLoop(renderer)
d5dc01f2. Scroll events can report touchPhase: 'cancelled'. Building from source now needs Rust 1.97.1 and the Metal toolchain on macOS.<text>, plus fontSize, textAlign, rowGap, columnGap, lineHeight, and borderWidth: 0.autoFocus works and <input> no longer ships a hardcoded look.theme.caret.ReactNode.