Repository navigation
Releases: remorses/gpuix
Release list
disktree@0.2.0
-
Runs on plain Node. Bun is no longer required:
npx disktree ~/srcThe package ships compiled JavaScript and needs Node 20 or later.
bunx disktreestill works. -
iCloud folders are scanned like any other folder. The Bun-only check that skipped evicted iCloud folders is gone.
@gpuix/solid@0.10.0
First release of @gpuix/solid, the official Solid 1 renderer for GPUIX. Solid apps run with normal Bun commands and a preload. No Vite, no wrapper CLI.
bun add --exact @gpuix/solid @gpuix/native solid-js{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@gpuix/solid"
}
}# bunfig.toml
preload = ["@gpuix/solid/preload"]import { createSignal } from 'solid-js'
import { render } from '@gpuix/solid'
const [count, setCount] = createSignal(0)
render(() => (
<div onClick={() => setCount((value) => value + 1)}>
<text>Count: {count()}</text>
</div>
))What ships
- Native
motionandAnimatePresence, with the same API as@gpuix/react - Headless
Select,Combobox, andTooltip - Shared testing and automation from
@gpuix/native/testingand@gpuix/native/automation - Solid JSX declarations for every GPUIX element
@gpuix/solid/preloadforbun app.tsx, and@gpuix/solid/bun-pluginfor productionBun.buildcalls:
import solidPlugin from '@gpuix/solid/bun-plugin'
await Bun.build({ entrypoints: ['./app.tsx'], target: 'bun', plugins: [solidPlugin] })Pin @gpuix/solid and @gpuix/native to the same exact version.
Empty text nodes now take no layout space. Solid creates an empty placeholder for every dynamic hole (<Show>, <For>, {cond && <x/>}), and those no longer stretch flex columns.
The rest of 0.10.0 (live image uploads, scrollIntoView, window controls, promptForPaths, caret and paste fixes) is in the @gpuix/react@0.10.0 release. Those native features work from Solid too.
@gpuix/react@0.10.0
-
Framework-neutral runtime moves into
@gpuix/native. Host contracts, testing, and automation now live in@gpuix/native/host,@gpuix/native/testing, and@gpuix/native/automation. React keeps its current public imports as adapters and re-exports, so existing React code does not change. The native package now exposes an ESM loader by default; its explicit CommonJS condition remains for the documented Hermes runtime.This split is what powers the new
@gpuix/solid@0.10.0renderer, released separately. -
Add exit animations for
motion.divthrough anAnimatePresenceAPI shaped like Motion for React.import { AnimatePresence, motion } from '@gpuix/react' function Toast({ show }: { show: boolean }) { return ( <AnimatePresence> {show ? ( <motion.div key="toast" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} > <text>Saved</text> </motion.div> ) : null} </AnimatePresence> ) }
React keeps the leaving node mounted. Native motion tweens to
exit, thenmotionCompleteletsAnimatePresenceunmount it. WithoutAnimatePresence, unmount is still immediate. Give each leaving child a uniquekey.Completion stays tied to the target that started it, including no-op targets, and offscreen virtual-list rows finish without being painted. A partial exit target also keeps animated properties it does not replace.
-
Add live image uploads and
scrollIntoViewon host refs.<img>refs can push image bytes without a data URL or JSON mutation:const img = useRef<ImgInstance>(null) useLayoutEffect(() => { img.current?.setImage(pngBuffer) img.current?.setImagePixels(800, 80, rgbaBytes) }, [pngBuffer, rgbaBytes]) return <img ref={img} style={{ width: 800, height: 80 }} />
setImagetakes encoded PNG, JPEG, WebP, GIF, SVG, BMP, TIFF, ICO, or Netpbm.setImagePixelstakes packed RGBA. Prefer pixels for a live frame. There is no density argument. Upload a 2x bitmap into a 1x layout box on retina. Alpha is straight, not premultiplied. A later Reactsrcchange overwrites the pixels.Every host ref now has
scrollIntoView(), which scrolls the nearest overflow parent or<virtual-list>until that node is visible. On a windowed list it uses the logical item index, not the mounted child index. -
Add native desktop window controls to the renderer.
const renderer = useGpuixRequired() renderer.minimizeWindow?.() renderer.zoomWindow?.() renderer.toggleFullscreen?.()
The methods forward to GPUI's platform window on macOS, Windows, Linux, and FreeBSD. They are not available in the browser renderer.
-
Add
renderer.promptForPaths()for opening the operating system's file picker.const paths = await renderer.promptForPaths({ files: true, multiple: true, prompt: 'Attach', })
The promise resolves with absolute paths, resolves with
nullon cancellation, and rejects for invalid options, platform failures, or browsers where operating-system paths are unavailable. Custom renderers can omit the optional capability.The current Windows GPUI backend reports
IFileDialog::Showerrors as cancellation. Those cases also resolve withnulluntil GPUI distinguishes the system error code. -
Draw the
<input>and<textarea>caret at line height, like Chrome.A composer with
fontSize: 14andlineHeight: 20now paints a 20px bar instead of a 14px em-square bar. Extra leading stretches the caret with the row. -
Let
Cmd+VandCtrl+VreachonKeyDownwhen the clipboard has no text.Text still pastes natively, including text accompanied by an image. Applications can now handle image-only and file clipboard contents instead of losing the paste shortcut or inserting a copied file path as text.
-
Keep uniformly rounded and per-corner rounded Select, Combobox, and Tooltip content rounded through the complete deferred overlay surface. The anchored fallback background no longer appears as square corners behind a rounded popup.
The outer surface now also owns visibility and opacity, including hover and active refinements, so its fallback fill follows the content without multiplying opacity.
pointerEvents: "none"disables the anchored occluder.
Fixes #68
@gpuix/react@0.9.0
-
Add a window-level
onSelectionChangecallback so React apps can react when the text selection changes.Pass it to
render()orcreateRoot(), the same way asonKeyDown. The payload is a normalEventPayload.valueis the joined selected text, or omitted when the selection is empty.render(<App />, { onSelectionChange(event) { setCopied(event.value ?? '') }, })
The event fires once per real change, including a clear. An unchanged frame does not fire.
-
Fix a live-window panic on ordinary mouse clicks and text-selection drags. A physical left click aborted the app with
cannot update GpuixView while it is already being updated. AppKit dispatches the event with the root view already leased, and GPUIX then nested-updated that same view from the text-selection mouse-up listener.A tap now skips drag-end cleanup when no drag was active. Real selection-drag move and end work run after the current GPUI effect cycle, so the root lease is released first.
-
Pin
@gpuix/reactand@gpuix/nativeto the same exact version. GPUIX is still pre-1.0. A new release can break either package.@gpuix/reactdepends on@gpuix/nativewith a version range, so that range can install a newer native binary under an older React package. Add both as direct dependencies and upgrade them together.bun add --exact @gpuix/react @gpuix/native react
Fixes #38
@gpuix/react@0.8.0
-
Expose GPUI accessibility through React
roleandaria-*props. VoiceOver, Accessibility Inspector, and other AX clients can now see labelled controls instead of an empty window. A node is in the platform tree only with both a GPUI id and a role.<div role="button" aria-label="Delete note" aria-id="notes.delete" onClick={remove} > Delete </div>
Prop names match React DOM (
aria-label, notariaLabel). Role values are ARIA tokens ("button","heading")."none"/"presentation"produce no node.<text>defaults toLabel,<input>toTextInput,<textarea>toMultilineTextInput,<img>toImagewithalt.onClickregisters AccessKit Click, so VoiceOver Press fires the same JS handler. Browser / wasm has no AccessKit adapter; these props are no-ops there. -
Let component libraries wrap Select without rewriting children. Select follows Base UI’s split between Root label data and mounted Item interaction data.
itemson Root is optional. It is a label lookup forSelectValuewhile the menu is closed. Keyboard nav and clicks read the mountedSelectItemchildren, so a styled wrapper aroundItemworks. Withoutitems,SelectValueshows the raw value.disabledlives onSelectItem.const models = [ { value: 'sonnet', label: 'Sonnet' }, { value: 'opus', label: 'Opus', disabled: true }, ] <Select items={models} value={model} onValueChange={setModel}> <SelectTrigger> <SelectValue placeholder="Select a model" /> </SelectTrigger> <SelectContent> {models.map((item) => ( <SelectItem key={item.value} value={item.value}> {item.label} </SelectItem> ))} </SelectContent> </Select>
GPUI has no bubbling. Use
asChildonSelectItemandComboboxItemwhen a styled row paints the fill, so that row becomes the real hit target:<SelectItem value="opus" asChild> <MenuRow>Claude Opus 4.6</MenuRow> </SelectItem>
The child must forward its ref and host props.
@gpuix/reactnow exportsProps,GpuixTheme,GpuixMetrics,InputProps,AnchoredProps, and the other host prop types. ImportFloatingLayerandrenderSlotfrom@gpuix/react/floating.getElementBounds(id)is on the live renderer and returns{ x, y, width, height }, ornull.getFocusedElementId(),focusNextWithin(id), andfocusPreviousWithin(id)walk GPUI's painted tab map. -
Add
onFileDropfor Finder and OS file drops. Put the listener on the element that should receive the drop. GPUI hit-tests the pointer and delivers the paths to that node.<div onFileDrop={(event) => openFiles(event.paths ?? [])} style={{ width: 400, height: 300 }} > <text>Drop files here</text> </div>
event.pathsisstring[]: absolute Unicode filesystem paths. Works ondiv,text,img,svg,input,textarea,code,markdown,diff, andanchored.<virtual-list>does not take this event; wrap it in adiv. Desktop only. -
Add
checkUpdate()on@gpuix/nativeso a packaged app can update itself from GitHub Releases. HTTP uses the samereqwest_clientas<img>.import { checkUpdate } from '@gpuix/native' const update = await checkUpdate('0.1.0', { endpoints: ['https://github.com/OWNER/REPO/releases/latest'], pubkey: '<public key from cargo packager signer generate>', }) if (update) await update.downloadAndInstall()
Create the GitHub release first. On each OS run
cargo packager --release --config packager.jsonwithCARGO_PACKAGER_SIGN_PRIVATE_KEYset, thengh release uploadthe bundle and its.sig.downloadAndInstall()replaces the packaged files and does not relaunch. Desktop only. -
Open a window as a Wayland
wlr-layer-shellsurface viaWindowOptions.layerShell.render(node, { layerShell })opens a compositor-anchored bar, dock, notification overlay, or wallpaper instead of a normal floating window.render(<Bar />, { appId: 'my-panel', height: 34, windowBackground: 'opaque', focus: false, layerShell: { namespace: 'my-panel', layer: 'top', anchor: ['top', 'left', 'right'], exclusiveZone: 34, keyboardInteractivity: 'none', }, })
Linux/Wayland only; ignored on macOS and Windows.
appIdalso sets the Waylandapp_id/ X11WM_CLASSon a normal window.Thanks @KiteShell for #64
-
Add http(s)
srcsupport to<img>. GPUI fetches the URL in the background and paints when decode finishes. Pass bothwidthandheightso the box does not jump after load.<img src="https://example.com/avatar.png" objectFit="cover" style={{ width: 48, height: 48, borderRadius: 24 }} />
-
Add
textDecorationstyle with"underline","line-through", and"none":<text style={{ color: '#3b82f6', textDecoration: 'underline' }}> Learn more </text>
-
Make Enter insert a newline in
<textarea>unlessonSubmitis set.<textarea>: Enter and Shift+Enter insert a newline.<textarea onSubmit={send}>: Enter emitsonSubmit; Shift+Enter still inserts a newline.<input>: Enter still emitsonSubmit. -
<input>and<textarea>size their rows fromstyle.fontSizeandstyle.lineHeight. Without an explicitlineHeight, the row follows GPUI's default leading. Single-line<input>now vertically centers text, clips toborderRadius, and draws the caret at about 75% offontSize. -
Keep the macOS window alive after a JavaScript runtime error, and show a stack overlay with Reload.
render()catchestick()errors, native event callbacks,uncaughtException, andunhandledRejection. Reload remounts the last tree. -
Keep click, keyboard, and other event handlers active after
bun --hotremounts an app on its existing native window. -
Deliver
onClickfrom primary-button mouse-up for retained and custom native elements so embedded macOS windows receive clicks reliably. -
Keep Bun responsive while GPUIX pumps embedded AppKit events on macOS.
GpuixRenderer.tick()now drains only native work that is ready. -
Prevent live-app mouse automation from aborting the GPUI process when a locator clicks, hovers, wheels, or drags.
-
Keep an element's style when React hides it.
hideInstancenow keeps the existing style and only addsvisibility: "hidden". -
Stop the browser chat example from dying before the WebGPU window opens. Window-key listeners and
focusElement()calls made while the asynchronous WebGPU window opens are queued until the window exists. -
Document how to ship a GPUIX React app on hermes-node. The guide is at
website/src/guides/hermes.mdx.
Fixes #37
Fixes #38
Fixes #39
Fixes #41
Fixes #46
Fixes #47
Fixes #59
Fixes #63
@gpuix/react@0.7.0
-
Add native two-stop linear gradients to
style.background. Gradients use GPUI's GPU shaders on every renderer. Angles follow CSS direction, stop positions range from0to1, rounded corners work as expected, andhoveroractivecan replace the gradient.<div style={{ background: { type: 'linear-gradient', angle: 90, stops: [ { color: '#7c3aed', position: 0 }, { color: '#06b6d4', position: 1 }, ], colorSpace: 'oklab', }, borderRadius: 12, }} />
colorSpaceaccepts"srgb"or"oklab"and defaults to"srgb". GPUI does not support radial, conic, repeating, or gradients with more than two stops. -
Add data URL sources to
<img>. Images created or loaded in memory can now render without a temporary file:const src = `data:image/png;base64,${Buffer.from(pngBytes).toString('base64')}` <img src={src} style={{ width: 240, height: 140 }} />
Base64 and percent-encoded data URLs support PNG, JPEG, WebP, GIF, SVG, BMP, TIFF, ICO, and Netpbm images. Filesystem paths continue to work.
-
Applications now own Tab key behavior. GPUIX no longer binds Tab or Shift+Tab to focus traversal. Both keys reach normal element keyboard handlers and the renderer-level
onKeyDowncallback, so terminals and editors can process them directly.Applications that want focus traversal can call the direct GPUI wrappers from the renderer callback:
render(<App />, { onKeyDown(event, renderer) { if (event.key !== 'tab') return if (event.modifiers?.shift) renderer.focusPrevious?.() else renderer.focusNext?.() }, })
render()also acceptsonKeyUp. Element callbacks run before renderer callbacks for raw keys that no GPUI action consumed. Renderer callbacks observe events but cannot cancel native propagation. -
Export
TestGpuixRendererconsistently on every platform. macOS and Windows builds with test support construct the GPU renderer. Linux and other builds without test support now throw a clear availability error instead ofTypeError: TestGpuixRenderer is not a constructor.import { TestGpuixRenderer, hasTestGpuixRenderer } from '@gpuix/native' if (hasTestGpuixRenderer()) { const renderer = new TestGpuixRenderer() }
@gpuix/react/testingexposes the matchinghasNativeTestRendererguard.GpuixRendereris unchanged and continues to work on Linux. -
Fix blurry Windows text above 100% display scaling. The native UI thread requests Per-Monitor V2 DPI awareness before GPUI creates a window, so Windows no longer bitmap-stretches GPUIX apps hosted by Node or Bun. Processes that already have Per-Monitor V2 awareness keep their existing configuration.
-
Quit after the last window closes on Windows and Linux. Closing the final GPUIX window now ends the Node or Bun process, matching macOS.
tick()reports when the native UI thread has ended, andrender()exits on that signal. -
Add a macOS frosted-glass window example. It combines GPUI's native vibrancy backdrop with a transparent titlebar and translucent React surfaces:
render(<App />, { titlebarTransparent: true, windowBackground: 'blurred', })
Run it from
examples/withbun run blurred-window.
@gpuix/react@0.6.0
-
Every interactive surface now has a stable GPUI identity.
hoverandactivework on<text>,<input>,<textarea>,<code>,<markdown>,<diff>,<img>,<svg>, and<anchored>, not only<div>.<text>also receives its declared click, mouse, keyboard, focus, and pointer-capture events.<text style={{ padding: 8, hover: { color: '#f38ba8' } }} onClick={select}> {label} </text> <img src={avatar} onClick={openProfile} /> <anchored side="bottom" onMouseLeave={close}>{items}</anchored>
<img>,<svg>, and<anchored>now report painted bounds to automation. Animated GIFs retain frame state. Anactivestyle no longer needs an unrelated click handler. A filled<text>takes mouse hits like HTML; setpointerEvents: 'none'to keep the old pass-through.createRoot(renderer)throws if that renderer already has a mounted root. Removed text nodes are freed. -
Background window launch and live keyboard automation.
render()addsfocusandshow.focus: falseopens behind the current app.show: falsecreates the live tree with no window.activateWindow()later reveals it.render(<App />, { title: 'Notes', focus: process.env.GPUIX_BACKGROUND !== '1', })
const app = await launch({ command: 'bun', args: ['app.tsx'], env: { GPUIX_BACKGROUND: '1' }, }) await app.getByTestId('composer').fill('hello gpuix') await app.getByTestId('composer').press('enter')
Linux currently ignores
focusandshow. -
Pixel-stable bidirectional history with
<virtual-list>.scrollToItemaccepts an offset inside the row.getListScrollTopreturns[itemIndex, offsetInItemPx, viewportHeightPx]. Infinite histories can prepend or append a page without moving the message under the reader.PublicInstanceis exported for typed host refs. Seeexamples/infinite-chat.tsx.renderer.scrollToItem(listId, index, offsetInItem) const top = renderer.getListScrollTop(listId)
-
Custom renderers implement one atomic
applyBatch(json)transport. Separate mutation methods are gone fromNativeRenderer. Style and custom-prop payloads are JSON values inside the batch.const renderer: NativeRenderer = { applyBatch(json) { return nativeTransport.applyBatch(json) }, }
-
The WebGPU chat example is live at https://gpuix.dev/chat-example/. The bidirectional history example is also served at
/infinitefrombun run web. -
One prebuilt binary per OS.
OS Target Renderer macOS aarch64-apple-darwinMetal Linux x86_64-unknown-linux-gnuVulkan / wgpu Windows x86_64-pc-windows-msvcDirect3D Intel macOS, arm64 Linux, and arm64 Windows have no prebuilt packages. macOS and Linux chat examples ship as
.tar.gz.tar -xzf example-chat-aarch64-apple-darwin.tar.gz ./example-chat-aarch64-apple-darwin
-
Text selection and native editing. Soft-wrapped washes include the first glyph of the next visual row. Virtual-list selection survives unmounts, scrolls at the edge, and stops when the list cannot move. Double-click selects a word in
<input>and<textarea>. Triple-click selects the full value. Adjacent typing groups into one undo step for 700 ms. -
CRLF source no longer leaves carriage returns in
<code>. Rendering, highlighting, selection, and copied text use normalized LF rows.
Fixes #25
@gpuix/react@0.5.1
-
Fixed
@gpuix/react/testingreporting no native renderer when installed from npm.hasNativeTestRendererwas alwaysfalse, so every suite that guards on it skipped silently:Test Files 1 skipped (1) Tests 6 skipped (6)testing.jsships as ESM and loaded the addon with a barerequire("@gpuix/native"). Node has norequirein 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 thecatchreported native as missing. It now usescreateRequire(import.meta.url).
See @gpuix/react@0.5.0 for the feature release this patches, including the browser renderer, the highlight prop and the macOS menu bar.
@gpuix/react@0.5.0
-
GPUIX apps now run in the browser. The same React tree renders through GPUI's browser platform on WebGPU, with a WebGL2 fallback.
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 startsgpui_webin the page.bun run web # build the Wasm if it is missing, then serve with HMR bun run web:wasm # only cargo + wasm-bindgen
bun run webserves through Bun's frontend dev server, so an edit to a component module is a React Fast Refresh update instead of a page reload.useStatesurvives, the GPUI canvas is never re-created, and the ~19 MB Wasm module is never re-fetched.Browser apps always expose the automation API on
globalThis, so Playwright or Playwriter can drive them by evaluating in the page:await globalThis.gpuix.getByTestId('send').click() await globalThis.gpuix.getByTestId('composer').fill('hello') await globalThis.gpuix.clock.fastForward(200)
Two rules for a browser entry, both learned the hard way: never call
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/nativeimport out of any Refresh boundary, because the Wasm half is a singleton andWebGpuixRenderer::initfails withGPUIX web is already running.Browser-specific fixes that landed with it: the debug frame overlay works on Wasm (
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 hostinputCSS can no longer unhide a stray text field at the top of the page.On iOS, touch pans emit scroll wheel events instead of mouse drags, so a swipe scrolls instead of selecting text. A tap does not start a selection, a long press followed by a drag still does, and a text input requests the software keyboard inside its tap handler so the keyboard opens on the composer and closes elsewhere.
-
New
highlightprop — 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.<div highlight={{ query: 'fox' }}> <text>the quick brown fox</text> </div>
It reaches
<text>,<code>,<markdown>and<diff>with no extra props, because every string GPUIX paints goes through the same funnel.useTextSearchowns the cursor and the count, so a find bar needs no effects:import { 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 querysubstring to match, case-insensitive by default caseSensitiveexact case only wholeWordneither neighbour may be alphanumeric or _rangesexplicit [start, end)UTF-16 pairscolor/activeColorany CSS colour; defaults come from the theme activeIndexwhich match gets activeColor, for a find cursormatchIndexOffsetmatches before this subtree; only for virtualized content radiuscorner radius of the wash, default 2 Matches are non-overlapping and leftmost-first, and never cross a line, exactly like browser find. They do cross the several host nodes React creates for one interpolated line:
<text>Hello {name}!</text>is three host text nodes andHello Tommystill matches.activeIndexcounts matches in paint order, so it means the same thing whether a match sits in a<text>or inside a<code>block.A
<virtual-list>never builds off-screen rows, so the app supplies both numbers with the newfindRangesexport, which runs the same algorithm as the native matcher on a string you give it:import { 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), }, })
A highlight is a quad, so
getPaintedText()cannot see it.renderer.getPaintedHighlights()reports the matched range in UTF-16 units plus the boxes it drew, one per visual row.Nothing resolves and nothing paints unless an element declares a
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. PassitemCountwithestimatedItemHeightandwindowStart, then render only that slice; native keeps the full logical length for the scrollbar.const 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> ) }
onVisibleRangereportsstartIndexandendIndexafter a scroll. TypeScript now requiresestimatedItemHeightnext toitemCount, and native ignoresitemCountwithout it, because a row React has not mounted would otherwise measure as height 0 and collapse the scrollbar on a jump.There is deliberately no
VirtualListwrapper component. The window is application state. A generic wrapper cannot know when to widen its own window, so it silently dropped rows wheneveritemCountgrew without a scroll, which is exactly what a filter does. -
A prepended row is visible again. A list is anchored on a row, not a pixel offset, so inserting rows above the viewport used to slide the viewport down by the height of the new rows. A todo list or a feed that prepends the newest item never showed it.
scrolled down pinned to the top ┌──────────────────┐ ┌──────────────────┐ │ new row (above) │ ◄── inserted │ new row │ ◄── inserted, visible ├──────────────────┤ ├──────────────────┤ │ ░░ viewport ░░░░ │ stays put │ ░░ viewport ░░░░ │ follows the insert │ ░░░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░░░ │ └──────────────────┘ └──────────────────┘A browser anchors the same way and suppresses it at
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 usingalignment="bottom". -
Automation can drag, hover, wheel, and hold modifiers. The protocol already carried the events, but nothing exposed them, so a drag or a pan could not be driven from a test.
await 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 / dragRaw pointer input in window coordinates A drag sends interpolated moves, not one jump, because snapping, live previews, and per-move commits only appear when the pointer travels. Every mouse call takes
modifiersin the same hyphenated syntax aspress('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 DOMtextContent, andclick({ 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.await app.getByTestId('composer').click() await app.getByTestId('composer').fill('hello gpuix')
bounds()andclick()threwElement 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 pixe...
@gpuix/react@0.4.0
Also ships @gpuix/native@0.4.0. CI publishes both packages.
-
Native
motion.divanimations — 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.import { 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>
Numeric targets:
width,height,top,right,bottom,left,opacity,borderRadius. Timing uses seconds.easeis"linear","ease","easeIn","easeOut","easeInOut", or a cubic-bezier[x1, y1, x2, y2].Set
initial={false}to mount at the firstanimatetarget. A running animation can reverse or change target without a jump. -
Playwright-like automation API — mark elements with
testId, then drive them from tests or from another process.import { 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])
Locators:
getByTestId,getByText,getByType.app.clock.pause(),set(ms), andfastForward(ms)freeze native motion time.A live app listens on stdin when stdin is a pipe.
launch({ command, args })pipes stdin and speaks SSEdata:lines:import { 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()