# GPUIX **React and Solid** for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), Zed's GPU UI framework. Write a React or Solid tree in TypeScript. GPUIX paints it with Metal, DirectX, or Vulkan. No Electron. No web view. `useState` and JSX still apply. Layout, text, and input go through GPUI, not the DOM. ![A glass dashboard over a painted sky](./docs/images/mail-app.jpg) Everything above is GPUIX: the glass window, the cards, the schedule, and native text. ## Quickstart Create an app from the official example. The command downloads only `example-app/` and installs its dependencies. There is no repository clone, native build, or Rust toolchain. ```bash bunx @gpuix/cli new my-app cd my-app bun run dev ``` `@gpuix/react` pulls the native renderer for your platform. Edit `app.tsx` and the running window remounts on save. Click and keyboard handlers switch to the new tree without recreating the window. Using Solid 1 instead? Start with the [Solid quick start](#solid-quick-start). ### Build from scratch Install the packages directly when you do not want the example app: ```bash bun add --exact @gpuix/react @gpuix/native react bun add -d @types/react typescript ``` **Pin your adapter and `@gpuix/native` to the same exact version.** GPUIX is still pre-1.0, so a new release can break either package. The adapter pulls `@gpuix/native` with a version range, and that range can install a newer native binary under an older adapter. Add both as direct dependencies. Upgrade them together. ### 1. Point TypeScript at the GPUIX JSX types **`jsxImportSource` is required.** Without it TypeScript uses DOM types, so ``, ``, `` and `style.hover` all fail to typecheck. ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "jsxImportSource": "@gpuix/react", "strict": true, "skipLibCheck": true, "noEmit": true } } ``` ### 2. Write the entry file End the file with `render()`. That call creates the window, mounts React, and starts the frame loop. ```tsx import { useState } from 'react' import { render } from '@gpuix/react' function App() { const [count, setCount] = useState(0) return (
setCount((c) => c + 1)} style={{ padding: 12, borderRadius: 8, cursor: 'pointer', backgroundColor: '#232323', hover: { backgroundColor: '#2c2c2c' }, }} > Count: {count}
) } render(, { title: 'My App', width: 800, height: 600 }) ``` > [!IMPORTANT] > **Give every `` a `color`.** GPUI does not inherit `color` from a > parent, so text with no color paints **black** and disappears on a dark > surface. ### 3. Run it ```bash bun --hot app.tsx ``` For hot reload during development, use `bun --hot` rather than plain `bun`. A save then remounts React on the same window instead of opening a second one. Bun is not required to run the renderer. GPUIX uses Node-API native bindings, and React apps also run on Node.js, as [`disktree`](./disktree/) does. For Node.js, compile TSX to JavaScript first or use a Node-compatible TSX loader. The `--hot` option and same-window hot-remount workflow described below are Bun-specific; running an app on Node.js does not provide Bun's hot reload. ### 4. Ship a binary ```bash bun build --compile app.tsx --outfile dist/app ./dist/app ``` The binary carries the renderer, so it runs with no Bun and no Node install. For a smaller ship set, run the same React app on [hermes-node](./website/src/guides/hermes.mdx) instead of Bun. That path is **12 MB** plus a **22 MB** native sidecar. The steps are in that guide. ### 5. Wrap it in an app with an icon A raw Mach-O has no Dock icon. Use [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) to wrap the binary. Config: [Config](https://docs.rs/cargo-packager/latest/cargo_packager/config/struct.Config.html). CLI: [docs.rs/cargo-packager](https://docs.rs/cargo-packager/latest/cargo_packager/). ```bash cargo install cargo-packager --locked ``` Build an `.icns` from a 1024 PNG, then pack. Pass the `.icns`, not a 1024 PNG. cargo-packager rejected a 1024 PNG with `No matching IconType`. ```bash mkdir AppIcon.iconset sips -z 16 16 icon-1024.png --out AppIcon.iconset/icon_16x16.png sips -z 32 32 icon-1024.png --out AppIcon.iconset/icon_16x16@2x.png sips -z 32 32 icon-1024.png --out AppIcon.iconset/icon_32x32.png sips -z 64 64 icon-1024.png --out AppIcon.iconset/icon_32x32@2x.png sips -z 128 128 icon-1024.png --out AppIcon.iconset/icon_128x128.png sips -z 256 256 icon-1024.png --out AppIcon.iconset/icon_128x128@2x.png sips -z 256 256 icon-1024.png --out AppIcon.iconset/icon_256x256.png sips -z 512 512 icon-1024.png --out AppIcon.iconset/icon_256x256@2x.png sips -z 512 512 icon-1024.png --out AppIcon.iconset/icon_512x512.png sips -z 1024 1024 icon-1024.png --out AppIcon.iconset/icon_512x512@2x.png iconutil -c icns AppIcon.iconset -o AppIcon.icns ``` **Bun** (one binary): ```json { "productName": "My App", "version": "0.1.0", "identifier": "dev.example.app", "binariesDir": "dist", "outDir": "bundle", "binaries": [{ "path": "app", "main": true }], "icons": ["AppIcon.icns"], "formats": ["app"] } ``` ```bash cargo packager --release --config packager.json open "bundle/My App.app" ``` **Hermes** needs the `.node` next to the exe. List it as a **second binary**, not a resource. Resources go in `Contents/Resources`. `dlopen` looks in `Contents/MacOS`. ```json { "binaries": [ { "path": "gpuix-hermes", "main": true }, { "path": "gpuix-native.darwin-arm64.node", "main": false } ] } ``` `formats` is the host OS only: | OS | `formats` | Output | |---|---|---| | macOS | `"app"`, then `"dmg"` | `.app`, optional `.dmg` | | Windows | `"nsis"` | setup `.exe` | | Linux | `"appimage"` | `.AppImage` | On this machine the Bun chat `.app` is **82 MB**. The Hermes counter `.app` is **34 MB**. ### 6. Auto-update Packaging does **not** turn on updates. The running app calls `checkUpdate` on `@gpuix/native`. HTTP uses the same `reqwest_client` as ``. There is no second native addon. Host on **GitHub Releases**. Create the release first. CI packs on each OS, signs, and uploads the bundle plus its `.sig`. The app hits `https://github.com/OWNER/REPO/releases/latest`. That rewrites to `https://api.github.com/repos/OWNER/REPO/releases/latest`. The updater reads `tag_name` and `assets`, then GETs the sibling `{name}.sig`. Sign once: ```bash cargo packager signer generate ``` Store the private key and its password as repo secrets `CARGO_PACKAGER_SIGN_PRIVATE_KEY` and `CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD`. Put the **public** key in the app. ```tsx import { checkUpdate } from '@gpuix/native' import { render } from '@gpuix/react' import { App } from './app' async function maybeUpdate() { const update = await checkUpdate('0.1.0', { endpoints: ['https://github.com/OWNER/REPO/releases/latest'], pubkey: '', }) if (update) await update.downloadAndInstall() } maybeUpdate() render() ``` `https://github.com/OWNER/REPO` is the same endpoint. Packager only builds the **host** OS. Run it on macOS, Linux, and Windows. `--release` is the packager profile (look in `binariesDir` for a release binary). It is not `cargo build --release`. Signing is automatic when those two env vars are set. Source: [cargo-packager CLI](https://docs.rs/cargo-packager/latest/cargo_packager/). With `productName: "My App"`, `version: "0.1.0"`, and `binaries: [{ "path": "app", "main": true }]`, packager writes: | OS | `formats` | Files in `outDir` (`bundle/`) | |---|---|---| | macOS | `"app"` | `My App.app`, then on sign `My App.app.tar.gz` + `My App.app.tar.gz.sig` | | Linux | `"appimage"` | `app_0.1.0_x86_64.AppImage` + `.sig` | | Windows | `"nsis"` | `app_0.1.0_x64-setup.exe` + `.sig` | The macOS updater wants the **`.app.tar.gz`**, not the `.app` and not a `.dmg`. Packager tars the `.app` only when it signs. Linux and Windows names use the **binary stem** (`app`), not `productName`. NSIS arch is `x64`, not `x86_64`. A missing sibling `.sig` is an error. Create the GitHub release yourself, then pack and upload. `--clobber` replaces an asset if CI retries. Do **not** upload a feed JSON. ```bash # macOS bun build --compile app.tsx --outfile dist/app cargo packager --release --config packager.json gh release upload v0.1.0 \ "bundle/My App.app.tar.gz" \ "bundle/My App.app.tar.gz.sig" \ --clobber # Linux bun build --compile app.tsx --outfile dist/app cargo packager --release --config packager.json gh release upload v0.1.0 \ bundle/app_0.1.0_x86_64.AppImage \ bundle/app_0.1.0_x86_64.AppImage.sig \ --clobber # Windows bun build --compile app.tsx --outfile dist/app.exe cargo packager --release --config packager.json gh release upload v0.1.0 \ bundle/app_0.1.0_x64-setup.exe \ bundle/app_0.1.0_x64-setup.exe.sig \ --clobber ``` `downloadAndInstall()` replaces the packaged files. It does **not** relaunch. Quit after it returns, or the next start uses the new app. HTTPS is in the native crate. This works on **Bun** and **hermes-node**. It does not exist in the browser wasm build. The repo must be **public**, or GitHub will 404 the API. Optional: put a Cloudflare cache in front of `api.github.com`. Same GitHub JSON. Not a custom schema. ### Start from the example app [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) is a complete todo app in one file, with `dev`, `build`, `web:dev` and `typecheck` scripts already wired. Create a copy with `bunx @gpuix/cli new my-app`. ![The GPUIX todo example app](./docs/images/todo-app.png) ### Shell completions Install completions for the `gpuix` command: ```bash bun add -g @gpuix/cli gpuix completions install ``` ## Examples | Example | Run | What it shows | |---|---|---| | **todo** | `bun run dev` in [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) | The starting point: one file, a ``, a native ``, and an animated sidebar | | **blurred window** | `bun run blurred-window` | A macOS frosted-glass surface using GPUI's native vibrancy backdrop and transparent titlebar | | **chat** | `bun --hot chat.tsx` | A GPUIX app: transparent titlebar, animated sidebar, per-thread transcripts, demo replies, composer, `` | | **timeline** | `bun --hot timeline.tsx` | A video-editor timeline: clip dragging, edge trimming with snapping, playhead scrubbing, marquee selection, zoom under the pointer, and a two-axis pan with a frozen ruler and track column | | **mail** | `bun --hot mail.tsx` | A Superhuman-style mail client: three panes, thread list, and a Framer newsletter | | **disktree** | `npx disktree [dir]`, source in [`disktree/`](https://github.com/remorses/gpuix/tree/main/disktree) | A port of [tobi/disktree](https://github.com/tobi/disktree) published to npm: scans a folder or the whole disk on worker threads, draws a translucent treemap on a frosted window, and ranks what could go | | **native-text** | `bun --hot native-text.tsx` | The three native text components with a tab switcher | | **counter** | `bun --hot counter.tsx` | The smallest possible app: state, events, hover | | **diff** | `bun --hot diff.tsx` | A diff viewer composed from `
` and `` in JS, for comparison | | **web** | `bun run web` from the repository root | The ChatGPT example rendered in a browser canvas with WebGPU | The todo app lives in [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) and is meant to be copied with `bunx @gpuix/cli new`. The rest live in [`examples/`](https://github.com/remorses/gpuix/tree/main/examples). Those `bun --hot` commands need a clone of this repo and a local native build. They will not run against the published packages alone. Or download a standalone **chat** build from the [GitHub release](https://github.com/remorses/gpuix/releases). No Bun or Rust install is required. ```bash tar -xzf example-chat-aarch64-apple-darwin.tar.gz ./example-chat-aarch64-apple-darwin ``` The archive keeps the executable bit, so there is no `chmod` step. macOS may still 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.exe` for x64 or `example-chat-aarch64-pc-windows-msvc.exe` for ARM64 and double-click it. On Linux, the file is `example-chat-x86_64-unknown-linux-gnu.tar.gz`. The chat and disktree desktop examples keep the native Windows title bar for dragging, minimizing, maximizing, and closing the window. Other platforms keep their existing custom chrome. disktree reveals the selection in Finder on macOS or Explorer on Windows. On Linux it opens the selected folder, or a file's parent folder, with `xdg-open`. A file-manager launch failure appears in the selection panel without stopping the app. The web example bundles the same React app and reconciler as the desktop chat example. wasm-bindgen exposes mutations and event callbacks to the existing retained tree and `GpuixView`, which run through GPUI's browser platform. The web build needs nightly Rust and the matching wasm-bindgen CLI: ```bash rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown cargo install wasm-bindgen-cli --version 0.2.127 --locked bun run web ``` The generated Wasm uses shared memory, so the page must be cross-origin isolated. Production servers must send these headers on the **top-level document**: ```http Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` `require-corp` then constrains **cross-origin** subresources, which must supply their own CORS or `Cross-Origin-Resource-Policy`. Serve the JavaScript and the Wasm from the same origin as the document and nothing else is needed. `bun run web` rebuilds the Wasm only when `packages/native/wasm` is missing. After a Rust change, force it: ```bash bun scripts/web.ts --rebuild ``` #### Hot reload in the browser `bun run web` serves the example through Bun's frontend dev server, so an edit to `examples/chat.tsx` arrives as a **React Fast Refresh** update. Components swap in place and `useState` survives, which means the composer text, the sidebar selection, and the scroll position all stay where they were. The GPUI canvas is never re-created and the ~19 MB Wasm module is never re-fetched. Fast Refresh only applies to a module whose exports are all components. Edit anything else, such as the entry file, and Bun reloads the page instead. Both paths are correct; the reload is only slower. The Wasm half is a **singleton and must never re-evaluate**. `WebGpuixRenderer::init` fails with `GPUIX web is already running` once its thread-local app exists, and GPUI's browser platform appends its own canvas to ``. What protects it is not that it lives in `node_modules`; Bun bundles it into the same client registry as your app. It is that Bun re-runs only the **changed** module and then walks upward through its importers, so an unchanged dependency stays evaluated and cached. Two rules follow: - do not call `import.meta.hot.accept("./your-app", ...)` in the entry file. Bun runs an importer's dependency-accept callback **even when the imported module already self-accepted**, so that callback would remount the tree on top of a successful refresh and throw away every `useState` - keep the `@gpuix/native` import in a module that can never become a Refresh boundary and is never explicitly accepted The chat example puts a virtualized `` and a GFM table inside an assistant turn, inside a scrolling transcript: ![A diff and a markdown table inside a chat turn](./docs/images/chat-diff.png) Markdown, code and a virtualized diff in one frame: ![Markdown, code and diff rendered together](./docs/images/showcase.png) ## Architecture GPUIX bridges React and Solid to GPUI using a **shared mutation-based runtime**. Desktop apps use napi-rs; browser apps load the same Rust renderer through wasm-bindgen. Each framework adapter collects changed elements into one atomic mutation batch. Rust applies that batch to a retained element tree that GPUI reads each frame. ``` ┌─────────────────────────────────────────────────────────────────┐ │ React or Solid (JavaScript) │ │ │ │ function App() { │ │ const [count, setCount] = useState(0) │ │ return ( │ │
│ │
setCount(c => c + 1)}> │ │ Count: {count} │ │
│ │
│ │ ) │ │ } │ └─────────────────────────────────────────────────────────────────┘ │ napi desktop / wasm-bindgen browser │ applyBatch([ │ ["createElement", 1, "div"], │ ["setStyle", 1, {...}], │ ["setRoot", 1] │ ]) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Rust host bridge │ │ │ │ RetainedTree ── stores elements, styles, event flags │ │ │ │ │ ▼ each GPUI frame │ │ GpuixView::render() → build_element() → GPUI elements │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ GPUI │ │ │ │ Metal, DirectX, Vulkan, or browser WebGPU / WebGL2 │ │ Flexbox layout via Taffy │ └─────────────────────────────────────────────────────────────────┘ ``` ## Why This Works GPUI is an **immediate-mode** UI framework — it rebuilds the entire element tree every frame. Instead of fighting this, GPUIX embraces it: 1. The React or Solid adapter detects a state change and queues host mutations (`createElement`, `setStyle`, `appendChild`, etc.) 2. `applyBatch()` validates and applies the complete commit to the Rust **RetainedTree** 3. On each GPUI frame, `GpuixView::render()` walks the RetainedTree and calls `build_element()` to produce ephemeral GPUI elements 4. GPUI lays them out (Taffy flexbox) and renders to the GPU 5. Only **changed elements** cross the FFI boundary. The framework adapter sends minimal mutations React and Solid use the same IDs, mutation queue, event routing, testing API, automation client, observers, and text-search matcher. Their framework-specific schedulers and component contexts stay in the adapter packages. ## Mutation API The mutation surface between JS and Rust is one atomic method. Desktop uses napi and the browser uses wasm-bindgen: ```ts type MutationHost = Pick ``` `createMutationQueue` only needs that. Window size, focus, and selection use smaller host types (`WindowSizeHost`, `SelectionHost`). `NativeRenderer` is `MutationHost` plus those methods, so a fake can implement `applyBatch` alone. Element IDs are plain numbers generated by an incrementing counter in JS. React may abandon work in concurrent render mode, so GPUIX keeps new host nodes in JS until React places the accepted subtree during commit. Only then are its mutations added to the batch. `applyBatch()` applies that accepted commit atomically and marks the Rust view dirty for the next frame. ## Event Flow Events travel from GPUI back to React through a `ThreadsafeFunction` on desktop and a wasm-bindgen callback in the browser. ``` User clicks element id=3 │ ▼ GPUI fires on_click on the element │ ▼ Rust closure calls emit_event_full(callback, 3, "click", {x, y, ...}) │ ▼ Desktop ThreadsafeFunction / browser callback sends EventPayload │ ▼ JS event registry: eventHandlers.get(3)?.get("click")?.(payload) │ ▼ React handler runs: onClick={() => setCount(c => c + 1)} │ ▼ State update triggers re-render → reconciler sends mutations back to Rust ``` Event handlers are stored in a JS-side registry keyed by `(elementId, eventType)`. Rust only knows **whether** an element has a listener (via `setEventListener`), not the closure itself — the actual handler lives in JS. ## Packages - **`@gpuix/native`**: Rust bindings plus the framework-neutral TypeScript host runtime. It owns host types, mutation batching, renderer state, event routing, observers, native testing, and automation. - **`@gpuix/native/host`**: host contracts, mutation helpers, renderer ownership, window observers, selection observation, and text search. Importing it does not load the `.node` addon. - **`@gpuix/native/testing`**: the shared `TestRenderer` over the real `TestGpuixRenderer`. - **`@gpuix/native/automation`**: the shared automation protocol, client, locators, and process launcher. - **`@gpuix/react`**: the React reconciler and React components. It preserves its existing exports and re-exports shared testing, automation, search, and observer APIs. - **`@gpuix/solid`**: the Solid 1 universal renderer, Solid primitives, motion, Select, Combobox, Tooltip, Bun preload, and build plugin. - **`@gpuix/cli`** — `gpuix new` downloads `example-app/`, sets its published React dependency, and installs it as a standalone project. Pin the selected adapter and `@gpuix/native` to the **same exact version**. GPUIX is still pre-1.0. Breaking changes can land before v1. Upgrade them together. ### Solid quick start Install Solid and the official Solid adapter: ```bash bun add --exact @gpuix/solid @gpuix/native solid-js ``` Use Solid's preserved JSX. The preload compiles application `.tsx` and `.jsx` files for Solid's universal renderer and selects the reactive client runtime. ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "@gpuix/solid", "strict": true, "skipLibCheck": true, "noEmit": true } } ``` ```toml preload = ["@gpuix/solid/preload"] ``` ```tsx import { createSignal } from 'solid-js' import { render } from '@gpuix/solid' function App() { const [count, setCount] = createSignal(0) return (
setCount((value) => value + 1)}> Count: {count()}
) } render(() => , { title: 'Solid GPUIX', width: 800, height: 600 }) ``` Run it directly. No wrapper command or Vite configuration is required. ```bash bun app.tsx bun --hot app.tsx ``` For production `Bun.build`, pass the exported plugin: ```ts import solidPlugin from '@gpuix/solid/bun-plugin' await Bun.build({ entrypoints: ['./app.tsx'], target: 'bun', outdir: './dist', plugins: [solidPlugin], }) ``` `@gpuix/solid` targets stable Solid 1.9. Its peer range is `>=1.9 <2`. The full Solid adapter API is in the [Solid guide](./website/src/guides/solid.mdx). ## Building This section is for **working on GPUIX itself**. To build an app with it, see [Quickstart](#quickstart) instead. Installing the packages needs no Rust toolchain and no submodule. ### Prerequisites 1. Rust toolchain 2. Node.js 18+ 3. Xcode with Metal Toolchain (macOS) The `cli/` and `disktree/` builds use Node.js to set executable permissions on Unix. Windows needs no `chmod` command. ```bash # Install Metal Toolchain if needed xcodebuild -downloadComponent MetalToolchain # Install dependencies bun install # Check out the pinned GPUI fork git submodule update --init --recursive # Build native package cd packages/native bun run build # Build React package cd ../react bun run build # Build Solid package cd ../solid bun run build # Run example (use tmux for long-running sessions) cd ../../examples bun --hot counter.tsx ``` ### Windows ARM64 source builds **Windows builds cover x64 and ARM64.** CI builds ARM64 on GitHub's `windows-11-arm` runner, tests the native addon with ARM64 Node.js and Bun, and includes it in the native npm packages and standalone chat release assets. Use an ARM64 runtime to load the ARM64 addon; the loader selects the package from the runtime's architecture, not the machine's architecture. In Visual Studio Installer, install **Desktop development with C++**, the ARM64 MSVC tools, a Windows SDK, C++ Clang tools, and CMake. Open a developer PowerShell with those tools available. [ring requires Clang for Windows ARM64](https://github.com/briansmith/ring/blob/main/BUILDING.md). If `clang` is not on `PATH`, add Visual Studio's ARM64 LLVM directory: ```powershell $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" $vs = & $vswhere -latest -products '*' -property installationPath $llvm = Join-Path $vs 'VC\Tools\Llvm\ARM64\bin' if (!(Test-Path "$llvm\clang.exe")) { throw 'Install the ARM64 Clang tools in Visual Studio Installer' } $env:PATH = "$llvm;$env:PATH" node -p 'process.arch' # must print arm64 bun -p 'process.arch' # must print arm64 clang --version cmake --version rustup target add aarch64-pc-windows-msvc $env:CARGO_BUILD_TARGET = 'aarch64-pc-windows-msvc' bun install Push-Location packages/native bun run build Pop-Location Push-Location packages/react bun run build Pop-Location Push-Location packages/solid bun run build Pop-Location ``` Run these commands from a repository checkout with the pinned `zed/` submodule already initialized, as in the build steps above. Matching runtime and addon architectures is required; renaming an x64 binary does not make it ARM64. ## Usage ```tsx import React, { useState } from 'react' import { render } from '@gpuix/react' function App() { const [count, setCount] = useState(0) return (
setCount(c => c + 1)} >
Count: {count}
) } render(, { title: 'My App', width: 800, height: 600, titlebarTransparent: true, windowBackground: 'blurred', trafficLightX: 16, trafficLightY: 17, }) ``` `render()` creates the native window, mounts React, and starts the frame loop. The red traffic-light button quits the process. Start the app again from the terminal. | Option | Values | Purpose | |---|---|---| | `titlebarTransparent` | boolean | Hide the native titlebar so the app draws chrome under the traffic lights | | `windowBackground` | `"opaque"` (default), `"transparent"`, `"blurred"` | Window fill. `"blurred"` is the macOS vibrancy backdrop | | `trafficLightX` / `trafficLightY` | pixels | Traffic-light origin. The chat example uses `(16, 17)` | | `transparent` | boolean | Same as `windowBackground: "transparent"` when that option is unset | | `appName` | string | Name inside the macOS `Hide X` and `Quit X` items. Defaults to `title` | | `focus` | boolean, default `true` | `false` opens the window behind the active app, like `open -g` | | `show` | boolean, default `true` | `false` opens the window hidden. Call `activateWindow()` to reveal it | Call it again after a save and it remounts the tree on the same window. ### The macOS menu bar GPUIX installs the application menu bar for you, so a fresh app already answers `⌘Q`, `⌘H`, `⌥⌘H`, `⌘M`, and `⌘W`. Without it `NSApp.mainMenu` is nil, macOS paints an empty menu bar, and those shortcuts do not exist at all: AppKit only provides them through menu items. ``` Apple Window ├ Services ├ (AppKit window tiling) ├ Hide ⌘H ├ Minimize ⌘M ├ Hide Others ⌥⌘H ├ Zoom ├ Show All ├ Close Window ⌘W └ Quit ⌘Q └ (open windows) ``` **`appName` does not set the title of the application menu.** macOS takes that from the executable, so `bun app.tsx` shows `bun` during development and a `bun build --compile` binary shows its own file name. Only a real `.app` bundle changes it. `appName` reaches the items inside the menu, and nothing else. There is **no Edit menu**, on purpose. A menu key equivalent is consumed by AppKit before the window sees the key event, so an Edit menu carrying `⌘C` would take the keystroke away from text selection and from ``. Use **`render()`**, not `createRenderer()`, in the app entry. `bun --hot` re-runs the whole file on save. `createRenderer()` plus `init()` would then build a second host. `render()` is idempotent: the first call owns the window, later calls only remount React. `createRenderer()`, `createRoot()`, and `startFrameLoop()` stay public for tests and custom hosts. Pass `{ renderer }` into `render()` when you already have one. **One renderer drives one root.** A renderer owns one window, one native root id, and one event map, so `createRoot()` throws if that renderer already has a mounted root. Call `unmount()` on the first root before you create another; `render()` already does that for you. ### Background launch `focus: false` opens the window **without taking focus**. The app you were typing in keeps the caret and the active titlebar. `show: false` goes further and opens no window at all, so the process runs with a live React tree and nothing on screen. ```tsx render(, { title: 'Notes', focus: false }) ``` **Turn this on whenever a coding agent runs your app.** An agent that starts the app to check its work will otherwise yank the window in front of whatever you are doing, mid-sentence, once per iteration. With `focus: false` the agent still gets a real GPU-rendered window it can screenshot and click, and you keep your editor. See [Let an agent drive the app](#let-an-agent-drive-the-app). `activateWindow()` brings the window forward and focuses it. It is the only way to reveal a `show: false` window. Reach it from any component with `useGpuixRequired()`: ```tsx import { useGpuixRequired } from '@gpuix/react' function Reveal() { const renderer = useGpuixRequired() return
renderer.activateWindow?.()}>Show
} ``` Outside React, call it on the renderer that `createRenderer()` returned. ### Window controls The desktop renderer exposes GPUI's native minimize, zoom, and fullscreen operations. Reach them through `useGpuixRequired()` or the renderer returned by `createRenderer()`. ```tsx function WindowControls() { const renderer = useGpuixRequired() return (
renderer.minimizeWindow?.()}>Minimize
renderer.zoomWindow?.()}>Zoom
renderer.toggleFullscreen?.()}>Fullscreen
) } ``` `minimizeWindow()`, `zoomWindow()`, and `toggleFullscreen()` work on macOS, Windows, Linux, and FreeBSD. `zoomWindow()` uses the platform's native zoom or maximize operation. These methods are not available in the browser renderer. ### File picker `promptForPaths()` opens the operating system's file picker. It resolves with the selected absolute paths, or `null` when the user cancels. ```tsx import { useGpuixRequired } from '@gpuix/react' function AttachFiles() { const renderer = useGpuixRequired() const attach = async () => { const paths = await renderer.promptForPaths?.({ files: true, multiple: true, prompt: 'Attach', }) if (paths) console.log(paths) } return
Attach files
} ``` With no options, the picker selects one file. Set `directories: true` to select directories instead. macOS can select files and directories in one prompt; Windows and Linux require one kind per prompt. Invalid options and platform picker failures reject. The browser method rejects because browsers return `File` objects, not operating-system paths. An injected custom renderer can omit this optional capability. On the current Windows GPUI backend, the system dialog does not distinguish cancellation from other `IFileDialog::Show` errors, so both resolve with `null`. This needs a fix in GPUI before GPUIX can report those errors. | Platform | `focus: false` | `show: false` | |---|---|---| | macOS | window orders in front without becoming key, like `open -g` | honored | | Windows | `SW_SHOWNOACTIVATE` | honored | | Linux | **ignored**, the window opens focused | **ignored** | The process still gets a **Dock icon** on macOS. GPUI sets the regular activation policy, so there is no menu-bar-agent mode yet. For a real background daemon, run the app from a `launchd` agent in `~/Library/LaunchAgents/`; launchd never activates the process. ### Custom fonts `registerFont()` makes a font that is not installed on the machine available to `fontFamily`. It takes the bytes of a **TrueType or OpenType** file (`.ttf` or `.otf`). ```tsx import { readFileSync } from 'node:fs' import { useGpuixRequired } from '@gpuix/react' function LoadFonts() { const renderer = useGpuixRequired() useEffect(() => { renderer.registerFont?.(readFileSync('./Inter-Regular.ttf')) }, [renderer]) return
Hello
} ``` Text that already named the family repaints with the font once it is registered, so there is no need to register before the first render. Register every weight and style of a family you use; text picks the closest one. The font must have family, full and PostScript names and a glyph for `"m"`. GPUI aborts the process on a font without the names, and silently drops a font without the glyph, so `registerFont()` throws for both and for bytes that are not a font. Fonts load into the process and last until it exits. WOFF and WOFF2 are not supported; convert them to TrueType first. The browser renderer does not have this method, use `FontFace` there. ### Let an agent drive the app Make focus opt-in through the environment, so a human run behaves normally and an agent run stays out of the way: ```tsx render(, { title: 'Notes', focus: process.env.GPUIX_BACKGROUND !== '1', }) ``` ```bash bun app.tsx # you: window comes to the front GPUIX_BACKGROUND=1 bun app.tsx # agent: window opens behind your editor ``` `launch()` passes `env` straight through, so an agent script sets it once and every screenshot, click, and assertion runs on a window that never interrupts you: ```ts import { launch } from '@gpuix/react/automation' const app = await launch({ command: 'bun', args: ['app.tsx'], env: { GPUIX_BACKGROUND: '1' }, }) await app.getByTestId('bump').waitFor() await app.getByTestId('bump').click() await app.screenshot({ path: 'tmp/after-click.png' }) await app.close() ``` Focus is the only thing that changes. **Automation does not need focus.** `click()` hits the last painted bounds and `screenshot()` reads the GPU surface, so both work while the window sits behind your editor, and even on a `show: false` window that is not on screen at all. ``` agent ──► launch({ env: { GPUIX_BACKGROUND: '1' } }) │ ▼ GPU window renders and paints without activation │ ├──► getByTestId(..).click() ✓ hits the last painted bounds ├──► screenshot({ path }) ✓ reads the GPU surface ├──► fill() / press() ✓ uses the live input pipeline └──► close() you ──► keep typing, your editor stays frontmost the whole time ``` `fill()` and `press()` use the live GPUI window input pipeline. They work without activating the desktop window. **Linux ignores `focus`**, so an agent there still gets a focused window. Prefer `createTestRoot()` when you can. It opens **no window at all**, so nothing can steal focus and keyboard input works. Reach for `launch()` plus `focus: false` when the check needs a real window, real GPU paint, or a real process. ### flushSync The root is a **concurrent root**, so React commits in a later microtask. `flushSync` forces the render and the commit to finish before it returns, the same as in `react-dom`. ```tsx import { flushSync } from '@gpuix/react' flushSync(() => setSidebarOpen(true)) ``` It flushes **React only**, down to one `applyBatch` call. After it returns the native retained tree is up to date, including styles and text. It does **not** wait for GPUI. Layout and paint still happen on the next frame, exactly like the browser paints after a DOM mutation. To see pixels, wait a frame in the app, or call `renderer.flush()` in a test. Use it when an ordering bug depends on the commit landing first: an unmount before a remount, or a state change before you feed the next event. ## Debug frame overlay GPUI paints frame-time stats into the window after layout. The overlay is not a React element. A React FPS label would update every frame and cause more work. ```tsx render(, { title: 'My App', debugFrameOverlay: 'full' }) ``` | Mode | What you see | |---|---| | `hidden` | nothing (default) | | `minimal` | last draw time, e.g. `8.3 MS` | | `full` | `CUR`, `1%`, `10%`, `MAX`, `FRAMES` | Or call the renderer: ```ts renderer.setDebugFrameOverlay('full') renderer.cycleDebugFrameOverlay() renderer.resetDebugFrameOverlayStats() renderer.getDebugFrameOverlay() // 'hidden' | 'minimal' | 'full' renderer.getDebugFrameOverlayStats() // { currentMs, p90Ms, p99Ms, maxMs, frames, samples } ``` `p90Ms` is the overlay **10%** line. `p99Ms` is the **1%** line. Those are the slow tail. The overlay shows **draw time**, not FPS. `8.3 MS` is about 120 Hz. The chat example has a regression test for this: `examples/chat.perf.test.tsx`. It times mount, wheel draw, and sidebar clicks. It asserts p95, not every frame. The default example suite excludes this hardware-timing test so shared CI runner variance does not fail functional checks. Run it explicitly on the target Mac: On macOS, `THROTTLE=utility` restarts the process under `taskpolicy -c utility`. That pins work to E-cores. It is an **M1/M2 Air CPU** proxy, not Chrome 6x. GPU and RAM stay fast. `THROTTLE=background` is slower. ```bash cd examples THROTTLE=utility bun run test:perf THROTTLE=utility bun --hot chat.tsx ``` ## Hot reload ### 1. End the file with `render()` ```tsx import { render } from '@gpuix/react' function App() { return
hello
} render(, { title: 'My App', width: 800, height: 600 }) ``` Do **not** call `createRenderer()` or `init()` in this file. `bun --hot` re-runs the whole entry on save. A second `init()` would open a second window. ### 2. Start the app with `bun --hot` Prefer **`bun --hot`** over a plain `bun` or `tsx` run. Without `--hot`, a save starts a second process. With it, `render()` remounts React on the same window. ```bash bun --hot app.tsx ``` ### 3. Save the file ``` save .tsx ► bun re-evaluates the entry ► render() remounts React │ ▼ GpuixRenderer, window, GPU stay ``` The first `render()` creates the native host and stores it on `globalThis`. Each save unmounts the React tree and mounts a new one on that same host. **Stays:** window, GPU device, native `.node` addon, GPUI scroll physics. **Resets:** `useState`, focus, React event handlers. This is a remount, not React Refresh. Keeping hook state needs Bun to inject `$RefreshReg$` during `--hot`. That transform exists on `bun build --react-fast-refresh` only. Tracked in [oven-sh/bun#40179](https://github.com/oven-sh/bun/issues/40179). Native `.node` edits still need a rebuild. See [Developing the Rust side](#developing-the-rust-side). On **macOS**, `startFrameLoop` calls `renderer.tick()` at a fixed rate (~125fps by default). Each tick drains only ready AppKit events and Core Foundation sources, then returns without waiting for the next native wake. Bun timers, sockets, promises, and PTY callbacks can run between ticks. Pass `{ frameMs }` to change the rate, and call `.stop()` on the returned handle to end it. A **runtime throw does not freeze the window.** The frame loop catches errors from `tick()`, native event callbacks catch throws from React handlers, and `render()` installs `uncaughtException` / `unhandledRejection` listeners so bun stays alive. The window shows the stack and a **Reload** button that remounts the last `render()` tree. Save under `bun --hot` also remounts. The process does not exit. On **Windows and Linux**, GPUI runs its normal blocking native event loop on one dedicated Rust UI thread. `tick()` does not pump that loop. It only reports whether the UI thread is still inside `Platform::run`. `startFrameLoop` still creates a JavaScript timer so last-window-close can return false and `render()` can `process.exit`, matching macOS. All platforms use GPUI's native platform, window, renderer, input, scroll, clipboard, keyboard, and IME implementations. The embedded macOS run-loop extension comes from the pinned GPUIX fork. CI runs the full React and example test suites through DirectX on Windows. > [!IMPORTANT] > On macOS, never drive `tick()` from a `setImmediate` loop. That spins at tens of thousands of > ticks per second and burns **73% CPU on a completely idle app**, versus **1%** when > paced. ## Native animations Use **`motion.div`** to animate from an initial style to a target style. React sends the target once. Rust calculates intermediate values and requests GPUI frames until the transition finishes, without a React render or N-API call for each frame. ### Animate a target ```tsx import { motion } from '@gpuix/react' function WelcomeCard() { return ( Welcome ) } ``` Set **`initial={false}`** when the element must mount at its first `animate` target. Later `animate` changes still transition normally. If a target changes while motion is active, the next transition starts from the current visible value, so reversing an animation does not jump. ### Targets and timing Motion currently accepts these **numeric targets**: | Target | Range or unit | |---|---| | `width`, `height` | pixels, zero or greater | | `top`, `right`, `bottom`, `left` | pixels | | `opacity` | `0` through `1` | | `borderRadius` | pixels, zero or greater | The **transition** uses seconds, like Motion for React: | Option | Default | Values | |---|---:|---| | `duration` | `0.3` | Non-negative seconds | | `delay` | `0` | Non-negative seconds | | `ease` | `"easeOut"` | `"linear"`, `"ease"`, `"easeIn"`, `"easeOut"`, `"easeInOut"`, or `[x1, y1, x2, y2]` | Springs, keyframes, variants, and shared layout animations are not available yet. **Exit** uses `AnimatePresence`, like Motion for React. ### Animate a sidebar Animate an **outer clipping container** and keep the inner sidebar at a fixed width. This reveals or hides the content without reflowing its text on every frame. ```tsx import { motion } from '@gpuix/react' import type { ReactNode } from 'react' function SidebarFrame({ collapsed, children, }: { collapsed: boolean children: ReactNode }) { const sidebarWidth = 252 const dividerWidth = 1 return (
{children}
) } ``` The **chat example** uses this pattern. The sidebar remains mounted while its outer width moves between `253` and `0` pixels. ### Animate unmount A `motion.div` with **`exit`** only leaves after that target finishes, and only when it is a child of **`AnimatePresence`**. Without `AnimatePresence`, React destroys the node on the same commit. ```tsx import { AnimatePresence, motion } from '@gpuix/react' function Toast({ show }: { show: boolean }) { return ( {show ? ( Saved ) : null} ) } ``` Give every child a **unique `key`** when more than one child can leave. Set **`initial={false}`** on `AnimatePresence` to skip enter on the first paint. A child with no `exit` is removed without a tween. Exit still completes when the target already matches, when the node is outside a virtual list's painted window, or when invalid runtime data makes the native target unusable. `AnimatePresence` also accepts **`onExitComplete`**, which runs after every leaving child is gone. `useIsPresent()` reports whether a custom descendant is leaving. `usePresence()` returns the same status plus `safeToRemove`, for a custom exit that decides when the retained child can unmount. ```tsx import { usePresence } from '@gpuix/react' function DeferredRemoval() { const [isPresent, safeToRemove] = usePresence() return (
!isPresent && safeToRemove?.()}> {isPresent ? 'Ready' : 'Click to remove'}
) } ``` `motion.div` accepts **`onMotionComplete`**. It runs when the current native target settles. If React changes the target before an older completion reaches JavaScript, the stale completion is ignored. ### Capture exact frames The [automation API](#automation) can freeze the native motion clock and render specific timestamps. This avoids timer sleeps and gives CI the same frames on every run. ```tsx import { connectTest } from '@gpuix/react/automation' import { createTestRoot } from '@gpuix/react/testing' import { ChatApp } from './chat' const { render, renderer } = createTestRoot() render() const app = await connectTest(renderer) const startedAt = await app.clock.pause() await app.getByTestId('sidebar-collapse').click() await app.captureFrames('review/sidebar', [ startedAt, startedAt + 50, startedAt + 100, startedAt + 150, startedAt + 200, ]) await app.clock.resume() ``` ## Scrolling Containers with `overflow: "scroll"` become natively scrollable. GPUI handles scroll physics, clipping, and offset persistence automatically. Plain scroll containers still build every child. Use `` below when the collection can grow large. > [!IMPORTANT] > **Nested scrolling is not supported.** One parent may scroll. An inner > `overflow: "scroll"`, ``, or `` must not. GPUI gives both > hitboxes the same wheel event, so the inner list steals the gesture. > > Keep long inner content in that parent. Collapse it behind an **expandable** > (preview plus Show more) instead of giving the child its own viewport. > > Horizontal overflow is the exception. `overflowX: "scroll"` on a wide child > (a code row, a table) does not steal the vertical wheel. GPUIX lays that > scroller out as a flex viewport with `minWidth: 0`. The wide child must not > shrink: set `flexShrink: 0` or a definite width. Swipe on **X** to pan. > A vertical wheel stays on the parent. ```tsx function Expandable({ preview, children, }: { preview: React.ReactNode children: React.ReactNode }) { const [open, setOpen] = useState(false) return (
{open ? children : preview} {!open &&
setOpen(true)}>Show more
}
) } ``` ```tsx function ScrollableList() { return (
{items.map((item, i) => (
{item.name}
))}
) } ``` Per-axis scrolling: use `overflowX: "scroll"` or `overflowY: "scroll"`. `overflow: "scroll"` scrolls both axes at once from a single diagonal gesture, like a browser. A flex column stretches its children to the cross axis, so a two-axis container needs its rows to state a width. Without one there is nothing to pan on **X**: ```tsx
{rows.map((row) => (
{row.cells}
))}
``` ### Panes that must move together A native scroll container cannot drive a **frozen header**. GPUI moves the container on the wheel frame, and the JavaScript callback that would move the header arrives a frame later, so the header tears away during a fast pan. When two panes must stay locked to the pixel, own the offset in React: put one `onScroll` listener on a non-scrolling parent, keep `scrollX` and `scrollY` in state, and translate each pane's content with an absolutely positioned wrapper. Zed does the same; the editor owns its scroll position and paints the gutter and the text from it. ```tsx function Pane({ offsetX, children }: { offsetX: number; children: React.ReactNode }) { return (
{/* An empty positioned box still takes hits, so opt it out. */}
{children}
) } ``` Keep the moving subtree in a `memo` component whose props do not change during a pan. The wheel then costs a handful of style mutations, not one per row. The [timeline example](./examples/timeline.tsx) does this for a ruler, a track column, and a clip grid. For programmatic scroll, use a host ref. `scrollIntoView()` walks to the nearest scroll parent. `scrollTo` and `scrollToItem` still exist on the renderer when you already have an id. ```tsx function ProgrammaticScroll() { const lastItem = useRef(null) return ( <>
{items.map((item, i) => (
{item}
))}
lastItem.current?.scrollIntoView?.()}>Jump to last
) } renderer.scrollTo(elementId, x, y) // set offset directly renderer.scrollToItem(elementId, index) // scroll child into view renderer.scrollIntoView(elementId) // nearest scroll parent renderer.getScrollOffset(elementId) // returns [x, y] or null ``` ## Virtual lists Use `` for **long, variable-height collections** such as message lists. React and Rust retain every row, but GPUI only builds, lays out, and paints rows near the viewport. ```tsx function MessageList({ messages }: { messages: Message[] }) { return ( {messages.map((message) => ( ))} ) } ``` The list needs a **bounded height** or bounded flex space. Its direct children are rows and can contain any GPUIX host or custom element. | Prop | Default | Purpose | |---|---:|---| | `alignment` | `"top"` | Use `"bottom"` for chat-style initial positioning | | `followTail` | `false` | Follow appended rows until the user scrolls away | | `overdraw` | `512` | Extra pixels built outside the viewport | | `estimatedItemHeight` | none | Height hint for unmeasured rows. **Required** with `itemCount` | ### How virtualization works **React reconciliation stays normal.** The complete keyed child list crosses the mutation protocol and remains in Rust's retained tree. GPUIX defers only the expensive GPUI element construction, layout, and paint work. ```text React Fiber + Rust RetainedTree all row IDs, props, text, and events │ ▼ GPUI ListState row count and measured height cache │ ▼ visible indexes plus overdraw cx.processor re-enters GpuixView after root render │ ▼ fresh BuildCtx builds only the requested React subtree │ ▼ GPUI layout and paint visible rows only ``` ### Row heights **Rows do not need equal heights, and you do not need to know them.** GPUI measures a row when it enters the viewport. `estimatedItemHeight` is a **hint for rows nothing has measured yet**, not a size contract. ```text index: 0 1 2 3 4 5 6 7 ┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐ │ hint │ hint │measured│measured│measured│ hint │ hint │ hint │ │ 220px │ 220px │ 184px │ 512px │ 96px │ 220px │ 220px │ 220px │ └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘ ▲ ▲ ▲ │ │ │ estimate only real, variable heights estimate only (viewport plus overdraw) ``` The sum of that height cache is the scroll length, so a rough estimate only affects **scrollbar accuracy** before a row is visited. The measured height replaces the estimate automatically, and the scrollbar converges as you scroll. When a retained descendant changes, GPUIX marks its direct row for remeasurement, so a streaming row grows correctly. Appending, removing, or reordering keyed rows keeps measurements for rows whose IDs did not change. `estimatedItemHeight` is optional in children mode, where every row exists and can be measured. It is **required** with `itemCount`, because React never mounts the rows outside the window and native has no element to measure. Those indexes render as an empty box of the estimated height until React mounts the real row. ### Row boundaries Each **direct host child** is one virtual row. Give every row a stable React key and one host root: ```tsx {messages.map((message) => (
))}
``` A row can contain nested `
`, ``, ``, ``, ``, ``, and `