Consume the pending changesets into a minor release. Users get the official Solid 1 renderer, AnimatePresence exit animations, live image uploads and scrollIntoView on host refs, native window controls, and an OS file picker. Bump the native Cargo crate, native npm package, React package, Solid package, generated napi loader version checks, and Bun workspace lock to 0.10.0. Keep published adapter dependencies on @gpuix/native as workspace:^. CI is the only publish path for native platform binaries. Fixes #68 Session: ses_f3b7fae0dffeZjEBH48TZSQVBq
95 KiB
Changelog
0.10.0
-
Add the official Solid 1 renderer as
@gpuix/solid. Solid applications run through normal Bun commands with a preload. The framework-neutral JavaScript runtime now lives in@gpuix/native.bun add --exact @gpuix/solid @gpuix/native solid-js{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "@gpuix/solid" } }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> ))The package includes native motion, Select, Combobox, Tooltip, shared testing and automation, Solid JSX declarations,
@gpuix/solid/preload, and@gpuix/solid/bun-pluginfor productionBun.buildcalls. Pin@gpuix/solidand@gpuix/nativeto the same exact version.Framework-neutral APIs are now available from
@gpuix/native/host,@gpuix/native/testing, and@gpuix/native/automation. React keeps its current public imports as adapters and re-exports. The native package now exposes an ESM loader by default; its explicit CommonJS condition remains for the documented Hermes runtime.Empty text nodes take zero layout space instead of a full line. Solid's universal renderer creates an empty placeholder for every dynamic hole (
<Show>,<For>,{cond && <x/>}). Those placeholders no longer stretch flex columns. React never emits empty text nodes, so this is a no-op for React apps. -
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.
@gpuix/solidexports the sameAnimatePresenceandmotionAPIs. -
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.The waveform example paints a generated buffer through
setImagePixels. -
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.
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
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. Native hover, event ids, disabled state, selection, and popup close stay on one hit target.
@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.visibility: "hidden"maps to GPUIinvisible(), so a hidden tab stop is skipped. -
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, the same wayonClickworks. Nested targets work: the inner listener gets the drop.<div onFileDrop={(event) => openFiles(event.paths ?? [])} style={{ width: 400, height: 300 }} > <text>Drop files here</text> </div>event.pathsisstring[]: absolute Unicode filesystem paths.event.xandevent.yare the drop point in window pixels. An empty drop, or a drop that contains a non-Unicode path, does not fire. 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>. There is no second native addon and no feed JSON.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. The updater readsapi.github.comlatest, matches the host asset, and GETs the sibling.sig.downloadAndInstall()replaces the packaged files and does not relaunch. Desktop only. The browser wasm build does not export this. -
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: no native titlebar, positioned by itsanchorrather than bywindow_bounds.origin, with an optionalexclusiveZoneso tiled windows keep clear of it.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', }, })Options map to GPUI's
WindowKind::LayerShell:layer(background|bottom|top|overlay),anchor(any oftop/bottom/left/right; two opposite edges stretch that axis),exclusiveZone,exclusiveEdge,margin([top, right, bottom, left]),keyboardInteractivity(none|on-demand|exclusive). Linux/Wayland only; ignored on macOS and Windows.appIdalso sets the Waylandapp_id/ X11WM_CLASSon a normal window. -
Add http(s)
srcsupport to<img>. GPUI fetches the URL in the background and paints when decode finishes. The React tree does not wait. 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 }} />Filesystem paths and data URLs still work. Failed loads still show the existing fallback placeholder.
-
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. Plain Enter used to always fireonSubmitin both<input>and<textarea>. A normal multiline editor could not insert a newline, and Enter did nothing when noonSubmitlistener was set.<textarea value={draft} onChange={(event) => setDraft(event.value ?? '')} /> <textarea value={draft} onChange={(event) => setDraft(event.value ?? '')} onSubmit={send} /><textarea>: Enter and Shift+Enter insert a newline and emitonChange.<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, so a largerfontSizegrows the box. PasslineHeightto set the row in pixels.minRowsandmaxRowsstill multiply that row height.<textarea value={draft} minRows={1} maxRows={8} style={{ fontSize: 14, lineHeight: 20 }} />Single-line
<input>now vertically centers text when given extra height, clips content to its ownborderRadius, and draws the caret at about 75% offontSizeinstead of the full line box. -
Keep the macOS window alive after a JavaScript runtime error, and show a stack overlay with Reload. A throw used to kill the AppKit pump or leave the window empty.
render()now catchestick()errors, native event callbacks,uncaughtException, andunhandledRejection, paints the message and stack, and Reload remounts the last tree. Save underbun --hotstill remounts too.render(<App />) -
Keep click, keyboard, and other event handlers active after
bun --hotremounts an app on its existing native window. Renderer event ownership and element IDs now survive JavaScript module reloads for the full life of the native renderer. -
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. It no longer waits for a display-link wake, so continuously producing PTYs, timers, promises, and sockets can make progress between frames. -
Prevent live-app mouse automation from aborting the GPUI process when a locator clicks, hovers, wheels, or drags. Mouse input now enters through the window without already holding the root view.
-
Keep an element's style when React hides it.
hideInstanceused to send{ visibility: "hidden" }and drop every other declaration. Suspense retries and hidden Activity trees then lost their layout box. The hide now keeps the existing style and only addsvisibility: "hidden". Hover and active still drop.unhideInstancerestores the style React passed in. -
Stop the browser chat example from dying before the WebGPU window opens. The error overlay no longer reads
process.platformat module load. Window-key listeners andfocusElement()calls made while the asynchronous WebGPU window opens are queued until the window exists. Applications can focus an input from a mount effect without aGPUIX web window is not readystartup error. -
Document how to ship a GPUIX React app on hermes-node. The guide is at
website/src/guides/hermes.mdxand is linked from the README. It covers the macOS rebuild,bun build --format=cjs, wrapping the two files in a.appwith cargo-packager, shrinking the.node, and the measured ship set.
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.
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, sogetByTestId(...).click()works on them. Animated GIFs retain their frame state and animate. Anactivestyle no longer needs an unrelated click handler.A
<text>with an opaquebackgroundColornow takes mouse hits, like an HTML element with a background. The wheel still reaches a scroller behind it. SetpointerEvents: 'none'when the label must stay transparent to pointer input.Each native renderer now accepts one mounted React root. A second
createRoot(renderer)throws instead of taking over the same window and event map. Removed text nodes are also freed instead of accumulating for the process lifetime. -
Background window launch and live keyboard automation.
render()addsfocusandshowoptions.focus: falseopens behind the current app.show: falsecreates the live React tree without showing a window.activateWindow()later reveals and focuses it.render(<App />, { title: 'Notes', focus: process.env.GPUIX_BACKGROUND !== '1', })Agent-driven checks can keep the user's keyboard while still using real GPU paint:
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')fill()andpress()now use GPUI's live desktop input pipeline. Automation does not need window activation. Linux currently ignoresfocusandshowbecause GPUI does not support those flags there. -
Pixel-stable bidirectional history with
<virtual-list>.scrollToItemaccepts an offset inside the row, andgetListScrollTopreturns the logical item anchor plus viewport height. Infinite histories can prepend or append a page without moving the message the reader is looking at.renderer.scrollToItem(listId, index, offsetInItem) const top = renderer.getListScrollTop(listId) // [itemIndex, offsetInItemPx, viewportHeightPx] or nullScroll requests are applied after the next render's child splice. A negative offset can anchor the viewport above the named row, and GPUI resolves it against the newly measured row heights.
before page after page arrives ┌──────────────────┐ ┌──────────────────┐ │ loading row │ │ new message 7 │ ├──────────────────┤ ├──────────────────┤ │ message A │ <--> same pixel │ message A │ │ message B │ │ message B │ └──────────────────┘ └──────────────────┘PublicInstanceis now exported for typed host refs. The newexamples/infinite-chat.tsxshows delayed cursor pagination in both directions, variable-height Safe MDX rows, bounded page retention, loading voids, stable anchors, and links to messages outside the loaded page. Run it on desktop or in the browser:cd examples && bun run infinite-chat bun run web # open /infinite -
Custom renderers now implement one atomic
applyBatch(json)transport. The separate native mutation methods are removed fromNativeRenderer. React validates and sends one batch per commit on desktop, web, and in tests.const renderer: NativeRenderer = { applyBatch(json) { return nativeTransport.applyBatch(json) }, }Style and custom-prop payloads are JSON values inside that batch, not nested JSON strings.
-
The WebGPU examples are available online. https://gpuix.dev/chat-example/ runs the GPUIX chat example in the browser and shows a lightweight loading state while the Wasm renderer starts. The example now uses GPUIX branding and describes the React-to-GPUI architecture accurately. The bidirectional history example is also available from the browser development server at
/infinite. -
Native releases now ship one prebuilt target per OS. The package supports the architecture most commonly used on each platform:
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 no longer have prebuilt packages. The standalone macOS and Linux chat examples now ship as
.tar.gzarchives, which preserve the executable name and mode and reduce the download size. Windows continues to ship an.exe.tar -xzf example-chat-aarch64-apple-darwin.tar.gz ./example-chat-aarch64-apple-darwin -
Text selection and native editing behave consistently across long content. Selection washes now include the first glyph of each soft-wrapped row. A drag in a virtual list survives anchor-row unmounts, scrolls near the list edge, and stops its timer when the list cannot move further.
Double-click selects the word under the pointer in
<input>and<textarea>. Triple-click selects the full value. A selected word no longer collapses when the pointer moves, textarea selection autoscrolls past the visible box, and adjacent typing or deletion groups into one undo step for 700 ms with a 200-step history cap. -
CRLF source no longer leaves carriage returns in
<code>. Rendering, syntax highlighting, selection, and copied text now use normalized LF rows. A trailing newline still produces its final empty row.
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).
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-bindgenbun 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 pixel coordinate. In the browser,fill()andpress()threwGPUI browser input is unavailable: the client looked forinput[data-gpui-input], and zed-industries/zed#63201 replaced that element with a<textarea>. It now matches the attribute alone, exported asIME_MIRROR_SELECTOR.<img>,<svg>,<anchored>,<diff>and<markdown>do register painted bounds now too, so atestIdon<markdown>no longer returns null, andTestRenderer.findByTestId()resolves it from the retained tree. -
macOS apps have a menu bar, so
⌘Q,⌘H,⌥⌘H,⌘Mand⌘Wwork. GPUI never callsNSApplication.setMainMenu:, soNSApp.mainMenustayed nil, macOS painted nothing next to the Apple menu, and there was no way to quit a GPUIX app from the keyboard.Apple <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)New
appNamewindow option for the name insideHide XandQuit X. It defaults totitle.render(<App />, { title: 'Todo', appName: 'Todo' })appNamedoes not set the title of the application menu: macOS takes that from the executable, sobun app.tsxshowsbun. Only a real.appbundle changes it. There is no Edit menu on purpose, because AppKit consumes a menu key equivalent before the window sees it and⌘Cwould be taken away from text selection and from<input>. -
Pointer capture, like HTML.
onMouseMoveandonMouseUpcontinue after the pointer leaves the element that receivedonMouseDown, matchingsetPointerCapture. A clip, resizer, or slider keeps receiving events without a full-window overlay.<div onMouseDown={(e) => startDrag(e)} onMouseMove={(e) => moveDrag(e)} onMouseUp={() => endDrag()} />Capture is armed only when the same node listens for down and move. A node with only
onMouseDown/onMouseUpdoes not capture, and a release outside still cancels the click, as in the DOM. -
The wheel reaches an ancestor scroller from under an absolutely positioned child, like a browser. Absolute and fixed boxes used BlockMouse, which ended the hit test, so a timeline clip or a graph node swallowed a pan gesture. Every filled or positioned
divnow uses BlockMouseExceptScroll: clicks and hovers stop, the wheel passes through.<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>Set
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 positioneddivin a browser, so a wrapper that only carries a scroll offset should setpointerEvents: "none". -
<code>is a bare surface. It paints glyphs only: no fill, no border, no radius, no padding, no language header.styleis the surface, exactly like a<div>, so the card look belongs to your app instead of to the element.<code code={source} language="typescript" showLineNumbers style={{ padding: 12, borderRadius: 10, borderWidth: 1, borderColor: '#ffffff1f', backgroundColor: '#ffffff09', }} />fontFamily,fontSize,fontWeight,lineHeightandcolorinstylenow beat the theme, and one resolver feeds the div text style, everyTextRun, and the fixed row height.style.lineHeightused to be dropped and clip tall glyphs; it re-sizes the rows instead.Migration:
showHeaderis gone. Render your own header in a wrapper. Fivetheme.metricsfields only ever styled that card and moved to themdCode*group, where they still tune the<markdown>fenced block:Before After codePaddingX/codePaddingYmdCodePaddingX/mdCodePaddingYcodeRadiusmdCodeRadiuscodeHeaderPaddingYmdCodeHeaderPaddingYcodeHeaderTextSizemdCodeHeaderTextSize<markdown>keeps its card: a document renderer owns its layout, a primitive does not. -
Syntax highlighting moved from Tree-sitter to Syntect, with Oniguruma on native. There is no Tree-sitter runtime and no per-language C grammar in the binary. Language detection is unchanged (fence tag, then path, then shebang), and token classes stay
HighlightKindvalues rather than baked-in colours, so a theme change recolours existing spans without a reparse.Syntect compiles every TextMate regex of a grammar the first time that grammar is used, on the frame thread, inside a paint. The engine decides how expensive that is:
grammar fancy-regex, first use Oniguruma, first use TypeScript ~133ms ~12ms Markdown ~39ms ~1.7ms Rust ~17ms ~1.6ms The chat example mount for 1000 turns goes from about 240ms to about 130ms, and the worst scroll frame from about 17ms to about 6ms. The browser Wasm build keeps the pure-Rust fancy-regex engine, because Oniguruma is a C library. Token colours can shift a little versus Tree-sitter, because Syntect scopes are not the old capture names.
-
A large mount is 4x faster and the retained tree is 5x smaller.
applyBatchused to build aserde_json::Valuetree, 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,320setStyleops carrying 90 distinct styles, and every element gets the sameArc.Measured on a 10,000-turn chat, 221,764 ops:
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 A 5,000-row chat also stops rebuilding virtual-list focus maps on every GPUI frame, so sidebar motion and caret blink no longer pay that cost per tick, and custom-element props are only re-parsed when a retained value actually changes.
getAutomationTree()stops serializing style, events, and custom props, which took a 5k-row tree from about 110ms to about 22ms, sogetByTestId().click()is no longer dominated by encoding unused style maps. -
The install is about 8x smaller.
@gpuix/nativepacked every platform binary into the main tarball through a*.nodeglob, on top of the six per-platform packages thatoptionalDependenciesalready resolves. A hello-world install paid for all of it:node_modules 254M ├── @gpuix/native 185M ◄── all six binaries, unused ├── @gpuix/native-darwin-arm64 23M ◄── the one that loads └── @gpuix/react 544KOnly the loader, the types, the browser entry, and the Wasm build ship in the main package now. Nothing changes at runtime.
-
Fixed Windows x64 native binding failing to load with
ERR_DLOPEN_FAILED. The published.nodestatically importedTaskDialogIndirectfrom comctl32 v6 andu_strlenfromicuuc.dll. Node and Bun do not activate comctl32 v6, so Windows resolved the old comctl32 andLoadLibraryfailed before any JS ran.bun -e "require('@gpuix/native'); console.log('OK')" -
Every CSS
cursorkeyword GPUI can paint is supported, not justpointeranddefault. Resize and drag cursors are what tell a user that an edge can be trimmed or a clip can be grabbed; until nowcol-resizewas silently dropped.<div style={{ cursor: 'grab', active: { cursor: 'grabbing' } }} /> <div style={{ cursor: 'col-resize' }} />Group Keywords Pointing default,auto,pointer,context-menu,not-allowed,no-dropText text,vertical-text,crosshairDragging grab,grabbing,move,all-scroll,alias,copyResizing 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-resizecursoris a typed union, so an editor completes the list. An unlisted keyword is ignored, like any other invalid style value. -
Colour strings accept the full csscolorparser grammar across styles, themes, pseudo-states, selection colours, SVG tint, borders, and shadows: modern RGB/HSL/HWB, HSV, LAB/LCH, OKLab/OKLCH, named colours,
transparent, alpha,none, and limited relative-colour forms. TypeScript types are unchanged. -
More style properties reach the GPU. Per-side border widths and one structured
boxShadowwith offset, blur, spread, and colour are new. Per-corner radii,flexBasis, andalignContentwere already declared in the public style type but never applied; they work now. -
New
onAuxClickfor the non-primary mouse buttons.onClicknever fired for a right or middle click, so theisRightClickfield it documents could never betrueand a context menu had no event to hang on.onClickstays primary-only, like the DOM.<div onClick={() => select(item)} onAuxClick={(event) => { if (event.isRightClick) openContextMenu(event.x, event.y) }} />onMouseDownandonMouseUpstill see every button throughevent.button:0left,1middle,2right. -
Window geometry hooks are pull-based.
useWindowSize()seeded state with a hardcoded800x600and read the renderer once from an effect, so a first read before the platform window had a size kept800x600forever, and a resize was never observed at all. It samples every 100 ms now and only rerenders when the numbers change.New
getWindowInsets()anduseWindowInsets()report system and software-keyboard geometry, so a composer can stay above the iOS keyboard instead of hiding behind it:const { keyboardTop, keyboardVisible, ime } = useWindowInsets() return ( <div style={{ paddingBottom: ime.bottom }}> {keyboardVisible ? `Keyboard starts at ${keyboardTop}px` : 'Keyboard closed'} </div> )Field Meaning imeEdges covered by the software keyboard safeAreaEdges covered by notches, status bars, home indicators effectivePer-edge max of the two, the region content should avoid keyboardTopY coordinate where the keyboard starts keyboardVisibleime.bottom > 0visibleHeightWindow height minus the effective top and bottom Both hooks take the same option, because Safari fires
visualViewportevents in bursts while the keyboard animates and iOS reports stale values on some of them:useWindowInsets() // 100ms, the default useWindowInsets({ intervalMs: 250 }) // slower useWindowInsets({ intervalMs: false }) // read once, never poll -
One diagonal gesture scrolls both axes, and
position: "fixed"lays out.overflow: "scroll"moved only one axis per wheel event, because GPUI zeroes the smaller of the two deltas by default.<div style={{ width: 260, height: 220, overflow: 'scroll' }}> {/* one diagonal swipe now pans on X and Y together */} </div>A flex column stretches its children to the cross axis, so rows in a two-axis container still need to state a width, or there is nothing to pan on X.
position: "fixed"blocked hits likeabsolutebut stayed in normal flow, so a box drifted when its siblings changed; it now lays out likeabsolute. -
Text selection starts from the empty space before the glyphs. A press in parent padding, a code gutter, or the empty start of a line clamps to the nearest text on that row, instead of requiring the mouse-down to land inside the tight text box.
[padding] hello world ^ press here, drag right → "hello world"A press above or below every line still does not start a selection, so a composer or titlebar cannot claim the nearest paragraph, and
userSelect: "none"now also blocks the start.Copying across interpolated text is fixed too.
<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 yieldsHello Tommy!, while<code>,<diff>and<markdown>keep one line per line. -
Fixed
keyon every GPUIX element. A list built with.map()failed to typecheck, so any real app broke on the firsttscrun:error TS2322: Type '{ key: string; ... }' is not assignable to type 'Props'. Property 'key' does not exist on type 'Props'.keylives onPropsnow, next toref. It cannot live onJSX.IntrinsicAttributes, because TypeScript 5 ignores that member for intrinsic elements. Every element prop type extendsProps, so<div>,<text>,<img>,<svg>,<canvas>,<input>,<textarea>,<anchored>,<code>,<diff>,<markdown>and<virtual-list>acceptkeyagain, and so domotion.div, Select, Combobox and Tooltip.@gpuix/react/jsx-dev-runtimetypes also match the runtime file now: they re-exportedjsxandjsxsfromreact/jsx-dev-runtime, which exports onlyjsxDEV. -
Abandoned concurrent renders stay out of the native mutation queue. React may throw away a Suspense render. GPUIX waits until commit before it creates native elements, so fallback text paints and abandoned text does not. Unchanged click handlers also stay registered across rerenders, because the whole handler map is no longer cleared before every update.
-
Each React root owns its event handler map. Two
createTestRoot()trees can both start at id1without 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 id1.Migration:
resetIdCounter()is gone, andhandleGpuixEventneeds the renderer that produced the event:handleGpuixEvent(event, renderer) -
Native
<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 withmin-width: 0now, and a fenced block inside<markdown>matches<code>: long lines scroll on X and leave the vertical wheel on the parent.<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> -
The test renderer runs on Windows through GPUI's DirectX renderer:
TestGpuixRenderer,createTestRoot(), native input simulation, and PNG screenshot capture. A live window can callcaptureScreenshot()there too. Linux stays unavailable until GPUI ships its pending wgpu headless renderer.The test app also releases its custom elements before the GPUI app goes away.
<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 centeredmaxWidthcolumn at its cap, so any layout that only changes below a breakpoint was invisible to the suite.const narrow = createTestRoot({ width: 640, height: 480 }) createTestRoot({ width: 640 }) // 640 x 800 createTestRoot({ width: 0 }) // throws: must be a positive, finite number -
New
getDebugFrameOverlayStats()so tests and apps can read the same draw times the on-screen overlay shows.renderer.resetDebugFrameOverlayStats() // ... scroll or click ... const stats = renderer.getDebugFrameOverlayStats() // stats.currentMs, stats.p90Ms, stats.p99Ms, stats.maxMs, stats.frames, stats.samplesp90Msis the overlay 10% line andp99Msis the 1% line: the slow tail, not the fast frames.On macOS,
THROTTLE=utilityrestarts a run undertaskpolicy -c utility, which pins work to E-cores as an M1/M2 Air CPU proxy.backgroundandmaintenanceare slower. GPU and RAM stay on the host machine, so this is not Chrome 6x, and it should not be set in CI.THROTTLE=utility bun run test chat.perf.test.tsx THROTTLE=utility bun --hot chat.tsx -
A Quickstart and a todo starter app. The README described the architecture and the mutation protocol before it ever said how to install the packages, and never mentioned
jsxImportSource, which is required: without it TypeScript falls back to DOM types and<virtual-list>,<markdown>,<code>andstyle.hoverall fail.bun add @gpuix/react react bun add -d @types/react typescript{ "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 devDesktop app with hot remount bun run buildStandalone binary in dist/todobun run web:devBrowser build served with isolation headers bun run screenshotDrives the app through the automation client bun run testVitest against the GPU test renderer bun run typechecktsc --noEmitIt shows
<virtual-list>, a native<input>,motion.div, tinted<svg>icons, nativehoverandactive, andtestIdautomation hooks. Copy the folder, change@gpuix/reactfromworkspace:^to a version range, and runbun install. -
A video-editor timeline example, to answer whether GPUIX can carry a real editing surface. It drags clips between tracks, trims both edges with snapping, scrubs a playhead, marquee-selects, zooms under the pointer, and pans on both axes with a frozen ruler and a frozen track column.
cd examples && bun --hot timeline.tsxTwo patterns in it are worth copying. React owns the scroll offset: a native
overflow: "scroll"grid cannot drive a frozen header, because GPUI moves the grid on the wheel frame and theonScrollcallback arrives a frame later, so the two tear apart during a fast pan. A drag needs no overlay: each clip and trim handle listens foronMouseDown,onMouseMoveandonMouseUp, 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.A pannable surface also has to cull. On 3,259 clips across 26 tracks, one wheel frame costs 7.7ms culled and 92ms with
memoalone. -
GitHub releases include a standalone chat example executable for each platform. No Node, Bun, or Rust install is required.
chmod +x example-chat-aarch64-apple-darwin ./example-chat-aarch64-apple-darwinmacOS may block the unsigned binary the first time: right-click the file, choose Open, and confirm. On Windows, download
example-chat-x86_64-pc-windows-msvc.exeand double-click it. -
@gpuix/nativeand@gpuix/reactare published as Apache-2.0. Both packages declarelicense: Apache-2.0and ship the license text in the npm tarball. GPUI itself is Apache-2.0, so this matches the native dependency. -
Smaller fixes:
destroyElementno 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 afterclose()are rejected and shutdown is idempotent across the in-process and SSE backends.
0.4.0
-
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, because the next transition starts from the current visible value.Springs, keyframes, variants, exit transitions, and shared layout animations are not available yet.
-
Playwright-like automation API — mark elements with
testId, then drive them from tests or from another process. Ordinary log lines are ignored.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.click()hits the center of the last painted bounds.fill(text)replaces the focused editor.press('enter')sends one key.waitFor()polls until exactly one match exists.app.clock.pause(),set(ms), andfastForward(ms)freeze native motion time so CI can capture the same frames every run.A live app listens on stdin when stdin is a pipe, not a TTY. A terminal run is unchanged.
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()
0.3.0
-
CSS grid on
div—display: "grid"plusgridTemplateColumnsmaps to GPUI's Taffy grid. UsegridColumnMin: "max-content"for tables so each column is as wide as its widest cell.<div style={{ display: 'grid', gridTemplateColumns: 3, gridColumnMin: 'max-content', rowGap: 1, columnGap: 1, }} > {cells} </div>gridTemplateRowsandgridRowMinwork the same on the other axis. -
Window chrome at open time —
render()now honors a transparent titlebar, traffic-light position, and a blurred or transparent window background. Traffic lights can sit in a sidebar. The native titlebar does not take a strip above the app.import { render } from '@gpuix/react' render(<App />, { title: 'Waku', width: 1180, height: 820, titlebarTransparent: true, windowBackground: 'blurred', trafficLightX: 16, trafficLightY: 17, })windowBackgroundis"opaque"(default),"transparent", or"blurred". The oldertransparent: trueflag still maps to a transparent background whenwindowBackgroundis unset. -
<diff>flows with its parent — it no longer owns a scroller unless you passscroll. Nested scrolling is not supported in GPUI. A parent that already scrolls used to fight the innerlist(). The default is now a column of rows, same as<code>.Use
maxLinesto keep a long patch short. Show more firesonShowMorewith the hidden line count. ClearmaxLinesin that handler to reveal the rest.const [open, setOpen] = useState(false) <diff patch={unifiedPatch} wordDiff maxLines={open ? undefined : 24} onShowMore={() => setOpen(true)} />Pass
scrolland a bounded height only for a dedicated full-window viewer. That path still virtualizes with GPUI'slist(). -
Debug frame overlay — see draw time on a live window. The overlay paints after layout. It is not a React element.
import { render } from '@gpuix/react' render(<App />, { title: 'My App', debugFrameOverlay: 'full' })Or call the renderer:
renderer.setDebugFrameOverlay('full') renderer.cycleDebugFrameOverlay() renderer.resetDebugFrameOverlayStats() renderer.getDebugFrameOverlay()Modes are
hidden(default),minimal(last draw time), andfull(CUR,1%,10%,MAX,FRAMES). The readout is draw time, not FPS.8.3 MSis about 120 Hz. -
Quit when the last window closes — on macOS the red traffic-light button used to destroy the window and leave the bun/Node process running. Closing the last window now quits AppKit. The next
tick()returnsfalse.render()exits the process, so the Dock icon goes away. -
Overlays block hits, and
pointerEventsworks — a filled or absolutely positioneddivnow inserts a blocking hitbox. Clicks, hovers, and scroll no longer reach controls under a Select, Combobox, or any other card.Set
pointerEvents: "none"to opt out. SetpointerEvents: "auto"to block even when the element has no fill. -
Opaque Select, Combobox, and Tooltip surfaces —
FloatingLayernow defaults tobackgroundColor: "#1A1A1A"so window blur and page content do not show through the card. Pass your ownstyle.backgroundColorto override. -
<svg>icons paint on the first frame — file paths are read from disk.data:image/svg+xmlURLs from Bun/Vitestimport … with { type: 'file' }are percent-decoded. The icon paints withsvg().data(...). -
bun --hotremounts no longer paint a black window —render()now unmounts the previous React root withflushSync, so the old tree is gone before the new one is created. -
Cmd+Delete and Cmd+Backspace in
<input>and<textarea>— on macOS these match the system text field. Cmd+Backspace deletes to the start of the line. Cmd+Delete deletes to the end of the line. -
Vertical wheel over
overflowX: "scroll"stays on the parent — GPUI remaps mouse-wheel Y onto overflow-x unlessrestrict_scroll_to_axisis set. A parent that contains<code>or a markdown table then used to jump on both axes. Trackpad X still pans the wide child.<div style={{ overflowY: 'scroll' }}> <code code={wideSource} language="ts" /> </div> -
Parent scroller takes the wheel over a filled in-flow
div— abackgroundColorused to insertocclude()(BlockMouse), so<virtual-list>never saw the wheel over text or a card. In-flow fills now useblock_mouse_except_scroll(). Absolute, fixed, andpointerEvents: "auto"still steal the wheel. -
macOS scroll stays at the display rate on expensive frames —
tick()used to sleep a fixed 8ms after every pump. A 10ms scroll frame plus that sleep ran at about 55fps on a 120Hz display. The next pump now waits only the leftover budget. -
Faster first React mount —
applyBatchsends styles and custom props as JSON values instead of double-encoded strings. A 10,000-row list spent most of its mount time parsing escaped strings twice. Legacy string payloads still decode. -
Raw custom-prop values stay intact —
setCustomPropstill treats the payload as a JSON string. After the batch started carrying objects, a raw"top"or"true"was parsed again and threw.<anchored side="top">never committed. The queue now usessetCustomPropValuefor a raw JSON value.
0.2.0
-
Selectable text everywhere, plus
<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; the selection spans both.<div style={{ display: 'flex', flexDirection: 'column' }}> <text>drag from here</text> <code code={'and into this code block'} language="ts" /> </div>Chrome opts out the same way CSS does, and it inherits:
<div style={{ userSelect: 'none' }}> <text>toolbar label, never selected</text> </div>Read it from the renderer with
renderer.getSelectedText()and clear it withrenderer.clearSelection().<code>is a syntax-highlighted block. One row per line at an exact line height, so its height is known before highlighting runs and a late highlight never reflows it.<code code={source} language="typescript" showLineNumbers /> <code code={source} path="src/app.ts" /> {/* detect from the extension */}<diff>is a unified diff viewer virtualized with GPUI'slist(), so a 2000-line patch paints only the rows on screen. Collapsing a file removes its rows rather than hiding them.<diff patch={unifiedPatch} wordDiff collapsedPaths={['pnpm-lock.yaml']} onToggleFile={(e) => toggle(e.value)} onLineClick={(e) => console.log(e.oldLine, e.newLine, e.value)} />wordDiffhighlights only the tokens that changed inside paired+/-lines.<markdown>is GitHub-flavoured markdown: headings, lists, tables, block quotes, fenced code, strikethrough, task lists, and autolinked bare URLs.<markdown source={readme} onLinkClick={(e) => open(e.value)} />All three take the same
themeprop. Fields layer on top of the built-in dark theme:<code code={source} language="rust" theme={{ appearance: 'light', accent: '#7c86ff', syntax: { keyword: '#f38ba8', string: '#a6e3a1' }, }} />Bundled languages: Rust, TypeScript, TSX, JavaScript, JSX, Python, Go, JSON, Bash, TOML, YAML, Markdown, HTML, CSS, C.
Row heights, gutter widths, paddings and the heading scale live in
theme.metrics, so tuning the design is a React re-render and never a native rebuild.<diff patch={patch} theme={{ metrics: { diffLineHeight: 26, diffGutterWidth: 48, mdHeadingSizes: [24, 19, 16, 14], }, }} />New style props:
userSelect("text"|"none", inherited),selectionColor, andlineHeightis now applied.New test helpers:
renderer.getPaintedText(),renderer.dragSelect(x1, y1, x2, y2), andrenderer.getSyntaxCacheStats().Ported from Comet (MIT). See
THIRD_PARTY_NOTICES.md. -
Native
<input>and<textarea>— single-line and multiline editors backed by GPUI's platform input handler.<textarea value={draft} minRows={1} maxRows={8} onChange={(event) => setDraft(event.value ?? '')} onSubmit={send} />Both support a native caret, mouse selection, IME composition, clipboard actions, undo/redo, caret movement and grapheme-safe deletion.
Entersubmits andShift+Enterinserts a newline in a textarea. -
render()remounts React on the same native window — abun --hotsave remounts the tree without creating a second window.import { render } from '@gpuix/react' function App() { return <div style={{ padding: 16 }}>hello</div> } render(<App />, { title: 'My App', width: 800, height: 600 })bun --hot app.tsxThe first call creates the GPUI renderer, window, React root, and frame loop. Later calls reuse that host and remount the tree.
useStateresets. The native.nodeaddon stays loaded.createRoot,createRenderer, andstartFrameLoopstill exist for tests and custom hosts. Pass{ renderer }intorender()to drive the test renderer.React Refresh (keep hook state across saves) is not included.
-
Headless Select, Combobox, and Tooltip — unstyled primitives with the same compound composition used by shadcn. Import a namespace, wrap it in a local
components/ui/*.tsx, and use those styled components in the app.import * 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>Dedicated entry points:
Import Main parts @gpuix/react/selectRoot,Trigger,Value,Content,Item@gpuix/react/comboboxRoot,Input,Content,List,Item,Empty@gpuix/react/tooltipProvider,Root,Trigger,ContentThe barrel
@gpuix/reactstill exports the prefixed names (Select,SelectTrigger, and the rest).Each part accepts GPUIX styles, including state-based item style functions. Menus support native focus, keyboard navigation, outside-click dismissal, window-edge snapping, and click occlusion. Comboboxes use the native text input and rank prefix matches before substring matches.
-
<virtual-list>— long, variable-height React collections. GPUI builds and lays out only rows near the viewport while React and the native retained tree keep the complete collection.<virtual-list alignment="bottom" followTail estimatedItemHeight={180} style={{ flexGrow: 1, minHeight: 0 }} > {messages.map((message) => ( <Message key={message.id} message={message} /> ))} </virtual-list>Rows can contain any GPUIX host or custom element. Appended rows preserve list measurements, changed rows are remeasured, and existing
scrollTo,scrollToItem, andgetScrollOffsetmethods work with virtual lists. -
Tintable local SVG icons —
<svg>uses GPUI's monochrome SVG renderer.<svg src="/absolute/path/to/search.svg" style={{ width: 16, height: 16, color: '#b4b4b4' }} />widthandheightcontrol layout.colorcontrols the icon tint. -
startFrameLoop()— stop burning CPU on idle apps. The oldsetImmediateloop spun at roughly 27,000 ticks per second and measured 73.5% CPU on an idle counter.startFrameLooppaces at ~125fps (~1% CPU).import { startFrameLoop } from '@gpuix/react' startFrameLoop(renderer)const loop = startFrameLoop(renderer, { frameMs: 16 }) loop.stop()Each frame is scheduled only after the previous one finishes. Rendering is unchanged: one draw per React commit, and no draws at all while idle.
-
Native GPUI platform — Node applications use GPUI's native platform, window, renderer, and event pipeline on macOS, Windows, and Linux.
On macOS, Node drives an embedded AppKit event pump from the pinned GPUIX fork on the process main thread. On Windows and Linux, GPUI runs its normal blocking event loop on a dedicated Rust UI thread while Node sends in-process render and window commands. Windows runtime validation is still pending.
-
GPUI upgrade to zed
d5dc01f2— picks up several months of GPUI work, includingApplication::run_embedded(). GPUIX now holds the returnedApplicationHandlefor the lifetime of the process.Scroll events can now report a cancelled phase. Previously a cancelled scroll gesture was reported to JS as
"ended".<div style={{ overflow: 'scroll' }} onScroll={(e) => { if (e.touchPhase === 'cancelled') return }} />Building from source now requires Rust 1.97.1, pinned in
rust-toolchain.toml. On macOS you also need the Metal compiler:xcodebuild -downloadComponent MetalToolchainPrebuilt binaries from npm are unaffected.
-
Style props that were declared and dropped now work —
<text>takes the full style set (padding, width, backgroundColor, borderRadius, flex).fontSizeworks on<div>and custom elements.textAlign,rowGap,columnGap, andlineHeightare applied.borderWidth: 0can clear a border.<text style={{ paddingLeft: 40, width: 300, backgroundColor: '#7c86ff', borderRadius: 12 }}> now works </text> -
autoFocusworks and<input>is unstyled —autoFocuswas declared and dropped by the reconciler, so an<input>never held keyboard focus unless the user clicked it. It now works on every element type.<input value={text} autoFocus onKeyDown={(e) => e.keyChar && setText(t => t + e.keyChar)} /><input>no longer hardcodes a background, border, or radius. Only the placeholder dims. Style the element or its wrapper:<input value={text} style={{ backgroundColor: '#00000000', borderWidth: 0, color: '#ececec', fontSize: 15 }} /><input>is controlled: it paintsvalueand reports keystrokes. -
Blinking caret — the native input and textarea caret blinks every 500ms while focused and idle. Editing or moving the caret makes it immediately solid. Blurring the field stops its repaint timer.
<input theme={{ caret: '#22c55e' }} /> -
Clipboard and natural scroll —
Cmd+Cwrites to the system clipboard viaarboard. Wheel deltas keep the sign the OS already applied, so natural scrolling matches System Settings. -
React 19 JSX components — the GPUIX JSX runtime accepts any valid
ReactNodereturn type, so libraries such assafe-mdxcan render parsed content into GPUIX host elements.
2026-03-02 23:30 UTC
- Add hover/active pseudo-selector style support — styles applied natively by GPUI with zero JS round-trips.
- New
hoverandactivekeys inStyleDescaccept nested style objects:style={{ backgroundColor: '#313244', hover: { backgroundColor: '#45475a' }, active: { backgroundColor: '#585b70' } }}. - Rust
StyleDesc(style.rs): addedhover: Option<Box<StyleDesc>>andactive: Option<Box<StyleDesc>>fields with serde support. - Renderer (renderer.rs):
build_div()calls GPUI's native.hover()and.active()methods, passing the sub-styles throughapply_styles()which works onStyleRefinementvia theStyledtrait. - TypeScript types (host.ts):
hover?andactive?typed asOmit<StyleDesc, 'hover' | 'active'>to prevent infinite nesting. - Added 7 tests validating hover-only, active-only, combined hover+active, empty hover, color-only hover, and hover alongside event handlers.
- New
2026-03-02 16:50 UTC
- Add GitHub Actions CI/CD pipeline (
.github/workflows/ci.yml) — builds native binaries for 4 targets (macOS arm64/x64, Linux x64/arm64), runs tests on macOS, and publishes to npm. - Publish is version-gated: skips if the package.json version is already on npm. Bump version + push to main to release.
- Two packages published:
@gpuix/native(per-platform binaries via napi pre-publish) and@gpuix/react(pure TypeScript). - Generate
packages/native/npm/per-platform package scaffolding (darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu). - Add
build:releasescript for Linux CI builds without test-support (gpui_macos is macOS-only). - macOS builds include test-support by default so published binaries ship
TestGpuixRendererfor user testing. - Update
@gpuix/reactdependency on@gpuix/nativefromworkspace:*toworkspace:^for publishing. - Add
publishConfigto@gpuix/reactpackage.json. - Document Cargo feature gate in Cargo.toml comments.
2026-03-02 16:32 UTC
- Migrate
packages/nativefrom napi-rs v2 to v3 — prerequisite for CI/CD and per-platform npm publishing.- Bump
napicrate from2to3andnapi-derivefrom2to3inCargo.toml(napi-buildstays at2). - Bump
@napi-rs/clifrom^2.18.0to^3.1.3inpackage.json. - Switch napi config from v2
triplesformat (name+triples.additional) to v3targetsformat (binaryName+targetsarray). Addx86_64-unknown-linux-gnuandaarch64-unknown-linux-gnutargets. - Change
prepublishOnlyscript fromnapi prepublishtonapi pre-publish(v3 hyphenated command). - Add
publishConfigwithregistryandaccess: "public". - Wrap
ThreadsafeFunctioninArcinGpuixRenderer— napi v3ThreadsafeFunctionis!Clone, soArcallows sharing it into theGpuixViewclosure from&selfmethods. - Generated
index.jsnow uses v3'srequireNative()function pattern (replaces v2's switch/case loader). - Generated
index.d.tsnow includes JSDoc comments from Rust///doc comments.
- Bump
- All 105 tests pass.
2026-03-02 17:05 UTC
- Fix
fontWeightto accept both string and number values — previouslyfontWeight: 700(number) would reject the entire mutation batch because the Rust deserializer only accepted strings. Now uses aFontWeightValueenum with#[serde(untagged)]that deserializes both"bold"(string) and700(number). Numeric values are clamped to 1–1000. - All 105 tests pass.
2026-03-02 16:55 UTC
- Add
whiteSpacesupport —"nowrap"prevents text wrapping (single line),"normal"enables wrapping (default). Applied in bothapply_styles()andbuild_text()via GPUI's.whitespace_nowrap()/.whitespace_normal(). - Add
textOverflowsupport —"ellipsis"truncates long text with "..." at end,"ellipsis-start"truncates from the start. Applied in bothapply_styles()andbuild_text()via GPUI's.text_ellipsis()/.text_ellipsis_start(). - Add
lineClampsupport — limits text to N visible lines. Applied in bothapply_styles()andbuild_text()via GPUI's.line_clamp(n). Values < 1 are ignored. - Update README: document all text style properties, add note about
white-space: prenot being supported in GPUI with a workaround pattern (split\n+ flex column + nowrap per line). - Add 13 new tests in styles.test.tsx: whiteSpace nowrap/normal with visual comparison, textOverflow ellipsis/ellipsis-start with comparison, lineClamp at 1/2/3 lines with comparison, edge case lineClamp: 0, div-level inheritance for nowrap and lineClamp, pre-like behavior composite test, and short text no-truncation edge case.
- All 104 tests pass (82 existing + 22 in styles.test.tsx).
2026-03-02 16:30 UTC
- Wire up
alignSelfinapply_styles()— field existed in StyleDesc but was never applied. Uses directel.style().align_selffield access since GPUI has no convenience methods. Supports center, start, end, stretch, baseline. - Fix
flexGrowandflexShrinkto respect actual numeric values — previouslyflexGrow: 0andflexGrow: 1both produced the same result (hardcoded 1.0). Now setsel.style().flex_grow = Some(value)directly. - Add
fontFamilysupport — new field inStyleDesc(Rust + TS), applied in bothapply_styles()andbuild_text()via GPUI's.font_family()method. Enables monospace fonts for code rendering. - Wire up
fontWeightinapply_styles()andbuild_text()— field existed in StyleDesc but was never applied. Parses CSS weight strings (named keywords like "bold"/"semibold" and numeric like "700") togpui::FontWeight. Case-insensitive with hyphenated variants (extra-bold, semi-bold). - Add
backgroundColorsupport tobuild_text()— text elements can now have background colors via.bg()on the wrapping div. Enables word-level diff highlighting. - Wire up
flexWrapinapply_styles()— field existed in StyleDesc but was never applied. Maps "wrap" →flex_wrap(), "wrap-reverse" →flex_wrap_reverse(), "nowrap" →flex_nowrap(). - Extract
parse_font_weight()helper function to deduplicate font-weight parsing betweenapply_styles()andbuild_text(). - Add
styles.test.tsxwith 9 end-to-end tests covering all new features with Metal GPU screenshots: alignSelf stretch, flexShrink 0, flexGrow values, fontFamily (Menlo/Courier vs default), fontWeight (bold/light/normal), text backgroundColor, flexWrap, and a composite diff-viewer row test. - All 91 tests pass.
2026-03-02 14:54 UTC
- Add test proving React refs expose the element's numeric ID (
ref.current.id) for use with programmatic scroll API - Remove dead
id?: stringprop fromPropstype — it was never wired to anything - Add scroll usage docs to README:
overflow: "scroll"example, per-axis scrolling, and programmatic scroll via refs - Comment Props type to document that element IDs come from refs, not a user prop
2026-03-02 14:42 UTC
- Add scrollable container support —
overflow: "scroll",overflowX: "scroll",overflowY: "scroll"now create native GPUI scrollable divs - GPUI handles scroll physics automatically: scroll wheel events update a persistent
ScrollHandleoffset, content is clipped and translated, offset is clamped to valid bounds ScrollHandlepersists across frames inGpuixView::scroll_handles(keyed by element ID), same lifecycle pattern asfocus_handles- Add per-axis overflow hidden support:
overflowX: "hidden"andoverflowY: "hidden"now map tooverflow_x_hidden()/overflow_y_hidden() - Add programmatic scroll API via napi:
scrollTo(elementId, x, y),scrollToItem(elementId, index),getScrollOffset(elementId)on bothGpuixRendererandTestGpuixRenderer - Production renderer syncs scroll handles to a thread_local (
SCROLL_HANDLES) after each render so napi methods can access them without an App context - TestRenderer exposes
scrollTo(),scrollToItem(),getScrollOffset()wrapper methods - NativeRenderer interface updated with optional scroll methods
- Add 6 new end-to-end scroll tests: basic scroll, overflow-y only, programmatic scrollTo, scrollToItem, screenshot regression (before/after scroll), and onScroll event + overflow scroll combo
- All 80 tests pass
2026-03-01 20:45 UTC
- Remove JS shadow tree from TestRenderer — all element state now lives exclusively in Rust's RetainedTree, queried via napi
- TestRenderer inspection methods (findByType, getAllText, toJSON, getRoot, getElement, findByText) now query the native TestGpuixRenderer instead of maintaining a parallel JS element map
- Add
getRootId()napi method to TestGpuixRenderer for root element queries - Add
customPropstogetTreeJson()output so test inspection can see custom element props (used by img/input tests) - TestRenderer constructor now requires native renderer (throws if not available); tests already skip via
hasNativeTestRenderer - Net ~220 lines of redundant JS state management code removed
- All 68 tests pass — zero test file changes needed
2026-03-01 20:30 UTC
- Add FFI mutation batching — all React reconciler mutations per commit are now buffered JS-side and sent to Rust in a single
applyBatch()napi call instead of N individual FFI calls - Add
apply_batch(json)to bothGpuixRendererandTestGpuixRenderer(Rust) — parses a JSON array of string-named mutation tuples["methodName", ...args]and applies them under a single mutex lock - Atomic two-phase Rust processing:
parse_batch_ops()validates all ops into typedBatchOpenum before any tree mutation; malformed batch → error with tree unchanged - Add Proxy-based
wrapWithBatching()(batch-renderer.ts) — auto-captures any NativeRenderer method call as[name, ...args]; adding new methods requires zero changes to the batching layer - TestRenderer uses
_skipNativeflag + dynamic dispatch forapplyBatch()replay — also zero changes needed when adding new methods - Wire
wrapWithBatching()into bothcreateRoot()andcreateTestRoot()— batching is automatic when the renderer supportsapplyBatch() - Backward compatible: individual mutation methods remain available; batching is opt-in via
applyBatchpresence - All 68 existing tests pass through the batched path
2026-03-01 19:07 UTC
- Add native
<img>custom element backed bygpui::img(PathBuf)withsrcandobjectFitcustom props and fallback rendering states for missing/failed sources - Register image factory in the custom element registry and expose
ImgPropsin React JSX runtime/dev-runtime type surfaces - Add new end-to-end
img.test.tsxsuite including screenshot regression that captures before/after PNGs when imagesrcis set
2026-03-01 18:52 UTC
- Add new
<anchored>custom element with GPUIanchored()positioning props (x/y,position,anchor,snapToWindow,snapMargin) and optional deferred overlay rendering (deferred,priority) - Extend custom element render context to pass built child elements so custom primitives can wrap and position nested React content
- Register
anchoredin the default custom element registry and expose it in React intrinsic types/component map - Add end-to-end anchored deferred dialog overlay test (open, inside click stays open, outside click closes)
2026-03-01 18:47 UTC
- Add dialog overlay screenshot regression test that captures before/after PNGs and asserts visual output changes when opening the dialog
2026-03-01 18:45 UTC
- Add absolute positioning support in native style mapping (
position,top,right,bottom,left) so React styles place elements out of flow like dialogs/tooltips - Add end-to-end dialog overlay test: click button opens tooltip-like dialog content, inside click keeps it open, outside click closes via
onMouseDownOutside
2026-03-01 18:35 UTC
- Add polymorphic custom element trait infrastructure (
CustomElement,CustomElementFactory,CustomElementRegistry) - Implement
<input>as first custom element with value/placeholder/readOnly props and keyboard event handling - Add
custom_propsfield toRetainedElementfor storing non-style/non-event props on custom elements - Add
setCustomProp/getCustomPropnapi methods on bothGpuixRendererandTestGpuixRenderer - Add custom prop forwarding in React reconciler (
host-config.ts) — automatically syncs non-reserved props for non-div/text elements - Add
InputPropstype andinputto JSX IntrinsicElements - Add 6 end-to-end tests: input rendering, keyboard typing (controlled component), backspace, screenshot before/after, tree structure
- Fix jsx-dev-runtime.js to export
jsxDEVfor React 19 compatibility with vitest (was breaking all tests) - All 27 tests pass (6 new input + 21 existing events)
2026-03-01 17:42 UTC
- Fix custom element lifecycle cleanup by pruning/destroying stale trait instances when IDs disappear from the retained tree
- Fix stale custom prop state by resetting missing known props to
nulleach frame viasupported_props()synchronization - Apply retained
styleto custom elements throughCustomRenderContextso<input style={...}>affects native layout/hit-testing - Filter custom element event wiring to declared
supported_events()only - Harden React custom prop forwarding with safe JSON serialization fallback (
nullon unsupported/circular values) - Expand input end-to-end coverage with
readOnlyremoval regression test and style-based click hit-test assertion
2026-03-01 17:15 UTC
- Rewrite README to reflect current mutation-based architecture (was describing old JSON tree approach)
- Replace "description-based renderer" language with "mutation-based protocol over napi-rs FFI"
- Add architecture diagram showing individual napi calls (createElement, appendChild, setStyle, commitMutations)
- Add Mutation API section documenting the full NativeRenderer interface
- Add Event Flow section with pipeline diagram (GPUI → Rust closure → ThreadsafeFunction → JS event registry → React handler)
- Add detailed events table with payload fields for each event type
- Add Testing section covering TestGpuixRenderer (GPU-backed Metal tests, screenshot capture, native event simulation)
- Update status checklist: mark keyboard events, focus/blur, scroll, click-outside, and test renderer as completed
- Update usage example to use createRenderer() instead of raw GpuixRenderer constructor
2026-03-01 16:48 UTC
- Center screenshot probe cards in the visual renderer tests so captured frames represent realistic composition instead of top-left anchored blocks
- Improve screenshot test visuals with richer card styling (rounded surfaces, palette contrast, readable text hierarchy)
- Keep visual assertions unchanged (before/after PNG difference) while moving click/hover simulation coordinates to centered card hit zones
2026-03-01 16:35 UTC
- Expand visual screenshot coverage with additional end-to-end tests for
click,keyDown, andmouseEnter-driven hover state changes - Add shared screenshot assertion helper in
events.test.tsxto enforce non-empty PNG output and before/after image differences
2026-03-01 16:20 UTC
- Fix
build_textto render child text elements recursively instead of dropping nested text nodes - Improve screenshot reliability by forcing
window.refresh()beforecapture_screenshot()in the native test renderer - Strengthen screenshot integration test to assert visual output changes (compare PNG bytes before vs after interaction)
- Update screenshot test fixture to use a high-contrast background toggle so black-frame regressions are obvious
2026-03-01 15:40 UTC
- Switch TestGpuixRenderer from
TestAppContext(no GPU) toVisualTestAppContext(real Metal rendering on macOS) - Add
gpui_macosdependency forMacPlatform— provides real Metal GPU rendering in test windows - Replace raw
VisualTestContextpointer withVisualTestAppContext+AnyWindowHandlein thread_local storage - Add
capture_screenshot(path)napi method — renders via Metal, reads back pixels, saves as PNG - Add
captureScreenshot(path)JS wrapper toTestRenderer - Add screenshot integration test (renders counter, clicks, captures before/after PNGs)
- Gate
test_renderermodule on#[cfg(all(feature = "test-support", target_os = "macos"))] - All 19 tests pass (18 existing event/tree tests + 1 new screenshot test)
2026-03-01 15:24 UTC
- Fix missing text in macOS visual screenshots by enabling
gpui_macos/font-kitundertest-support - Keep
VisualTestAppContexton realMacTextSysteminstead of fallbackNoopTextSystem, restoring glyph rasterization incapture_screenshot() - Validate with an example-like counter render: text labels (
0/1,+,-,Reset) now appear correctly in captured PNGs
2026-03-01 12:50 UTC
- Add plan for GPU-backed test renderer with screenshot support (
docs/visual-screenshot-plan.md) - Plan uses GPUI's
VisualTestAppContext+ Metal rendering on macOS (Oracle-reviewed, original headless wgpu approach rejected due toWgpuRendererbeing surface-bound)
2026-03-01 12:25 UTC
- Add changelog requirement to AGENTS.md
- Document auto-generated napi-rs files in AGENTS.md (
index.d.ts,index.js,*.node)
2026-03-01 12:00 UTC
- Add
simulate_key_down(keystroke, is_held?)andsimulate_key_up(keystroke)to Rust TestGpuixRenderer for fine-grained key event testing - Extend
simulate_mouse_move(x, y, pressed_button?)to accept optional pressed button for drag simulation - Add
nativeSimulateKeyDown,nativeSimulateKeyUpJS wrappers to TestRenderer - Update
nativeSimulateMouseMoveto pass pressed button through to native - Restore dropped tests: keyUp state update, keyDown+keyUp sequence, mouse button mapping (left/right/middle), drag pressedButton
- Tighten weak assertions: scroll checks exact deltaX/deltaY/touchPhase, mouseMove checks exact x/y
- Fix stale "mock-only mode" comment in testing.ts
2026-03-01 11:45 UTC
- Migrate all event tests from JS-only simulation to native GPUI end-to-end simulation
- Add
simulate_mouse_down(x, y, button)andsimulate_mouse_up(x, y, button)to Rust TestGpuixRenderer - Add
nativeSimulateMouseDownandnativeSimulateMouseUpJS wrappers to TestRenderer - Remove all 10 JS-only simulation methods from TestRenderer (
simulateEvent,simulateClick,simulateKeyDown,simulateKeyUp,simulateMouseEnter,simulateMouseLeave,simulateMouseDown,simulateMouseUp,simulateMouseMove,simulateScroll) - Rewrite all tests to use coordinate-based native GPUI simulation with explicit element sizes
- Change key names from
"arrowDown"/"arrowUp"to GPUI names"down"/"up"