Skip to content

Releases: remorses/gpuix

@gpuix/react@0.7.0

Choose a tag to compare

@remorses remorses released this 01 Sep 13:32
  1. 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 from 0 to 1, rounded corners work as expected, and hover or active can replace the gradient.

    <div
      style={{
        background: {
          type: 'linear-gradient',
          angle: 90,
          stops: [
            { color: '#7c3aed', position: 0 },
            { color: '#06b6d4', position: 1 },
          ],
          colorSpace: 'oklab',
        },
        borderRadius: 12,
      }}
    />

    colorSpace accepts "srgb" or "oklab" and defaults to "srgb". GPUI does not support radial, conic, repeating, or gradients with more than two stops.

  2. 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.

  3. 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 onKeyDown callback, 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 accepts onKeyUp. Element callbacks run before renderer callbacks for raw keys that no GPUI action consumed. Renderer callbacks observe events but cannot cancel native propagation.

  4. Export TestGpuixRenderer consistently 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 of TypeError: TestGpuixRenderer is not a constructor.

    import { TestGpuixRenderer, hasTestGpuixRenderer } from '@gpuix/native'
    
    if (hasTestGpuixRenderer()) {
      const renderer = new TestGpuixRenderer()
    }

    @gpuix/react/testing exposes the matching hasNativeTestRenderer guard. GpuixRenderer is unchanged and continues to work on Linux.

  5. 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.

  6. 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, and render() exits on that signal.

  7. 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/ with bun run blurred-window.

Fixes #30
Fixes #31
Fixes #32
Fixes #35
Fixes #36

@gpuix/react@0.6.0

Choose a tag to compare

@remorses remorses released this 29 Aug 15:59
  1. Every interactive surface now has a stable GPUI identity. hover and active work on <text>, <input>, <textarea>, <code>, <markdown>, <diff>, <img>, <svg>, and <anchored>, not only <div>. <text> also receives its declared click, mouse, keyboard, focus, and pointer-capture events.

    <text style={{ padding: 8, hover: { color: '#f38ba8' } }} onClick={select}>
      {label}
    </text>
    
    <img src={avatar} onClick={openProfile} />
    <anchored side="bottom" onMouseLeave={close}>{items}</anchored>

    <img>, <svg>, and <anchored> now report painted bounds to automation. Animated GIFs retain frame state. An active style no longer needs an unrelated click handler. A filled <text> takes mouse hits like HTML; set pointerEvents: 'none' to keep the old pass-through. createRoot(renderer) throws if that renderer already has a mounted root. Removed text nodes are freed.

  2. Background window launch and live keyboard automation. render() adds focus and show. focus: false opens behind the current app. show: false creates the live tree with no window. activateWindow() later reveals it.

    render(<App />, {
      title: 'Notes',
      focus: process.env.GPUIX_BACKGROUND !== '1',
    })
    const app = await launch({
      command: 'bun',
      args: ['app.tsx'],
      env: { GPUIX_BACKGROUND: '1' },
    })
    await app.getByTestId('composer').fill('hello gpuix')
    await app.getByTestId('composer').press('enter')

    Linux currently ignores focus and show.

  3. Pixel-stable bidirectional history with <virtual-list>. scrollToItem accepts an offset inside the row. getListScrollTop returns [itemIndex, offsetInItemPx, viewportHeightPx]. Infinite histories can prepend or append a page without moving the message under the reader. PublicInstance is exported for typed host refs. See examples/infinite-chat.tsx.

    renderer.scrollToItem(listId, index, offsetInItem)
    const top = renderer.getListScrollTop(listId)
  4. Custom renderers implement one atomic applyBatch(json) transport. Separate mutation methods are gone from NativeRenderer. Style and custom-prop payloads are JSON values inside the batch.

    const renderer: NativeRenderer = {
      applyBatch(json) {
        return nativeTransport.applyBatch(json)
      },
    }
  5. The WebGPU chat example is live at https://gpuix.dev/chat-example/. The bidirectional history example is also served at /infinite from bun run web.

  6. One prebuilt binary per OS.

    OS Target Renderer
    macOS aarch64-apple-darwin Metal
    Linux x86_64-unknown-linux-gnu Vulkan / wgpu
    Windows x86_64-pc-windows-msvc Direct3D

    Intel macOS, arm64 Linux, and arm64 Windows have no prebuilt packages. macOS and Linux chat examples ship as .tar.gz.

    tar -xzf example-chat-aarch64-apple-darwin.tar.gz
    ./example-chat-aarch64-apple-darwin
  7. Text selection and native editing. Soft-wrapped washes include the first glyph of the next visual row. Virtual-list selection survives unmounts, scrolls at the edge, and stops when the list cannot move. Double-click selects a word in <input> and <textarea>. Triple-click selects the full value. Adjacent typing groups into one undo step for 700 ms.

  8. CRLF source no longer leaves carriage returns in <code>. Rendering, highlighting, selection, and copied text use normalized LF rows.

Fixes #25

@gpuix/react@0.5.1

Choose a tag to compare

@remorses remorses released this 26 Aug 19:38
  1. Fixed @gpuix/react/testing reporting no native renderer when installed from npm. hasNativeTestRenderer was always false, so every suite that guards on it skipped silently:

    Test Files  1 skipped (1)
         Tests  6 skipped (6)
    

    testing.js ships as ESM and loaded the addon with a bare require("@gpuix/native"). Node has no require in ESM. Inside this repository vitest inlines the workspace package and provides one, so the suite here always passed; installed from npm the package is externalized and run by Node, the call threw, and the catch reported native as missing. It now uses createRequire(import.meta.url).

See @gpuix/react@0.5.0 for the feature release this patches, including the browser renderer, the highlight prop and the macOS menu bar.

@gpuix/react@0.5.0

Choose a tag to compare

@remorses remorses released this 26 Aug 19:23
  1. 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 starts gpui_web in the page.

    bun run web       # build the Wasm if it is missing, then serve with HMR
    bun run web:wasm  # only cargo + wasm-bindgen

    bun run web serves through Bun's frontend dev server, so an edit to a component module is a React Fast Refresh update instead of a page reload. useState survives, the GPUI canvas is never re-created, and the ~19 MB Wasm module is never re-fetched.

    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/native import out of any Refresh boundary, because the Wasm half is a singleton and WebGpuixRenderer::init fails with GPUIX 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 host input CSS 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.

  2. New highlight prop — paint a background wash behind matched or explicitly given text ranges. This is what you need for Ctrl+F, agent citations, or LSP diagnostic tints. Put it on any element and it applies to that subtree, so the root searches the window and a container searches only that container.

    <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.

    useTextSearch owns 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
    query substring to match, case-insensitive by default
    caseSensitive exact case only
    wholeWord neither neighbour may be alphanumeric or _
    ranges explicit [start, end) UTF-16 pairs
    color / activeColor any CSS colour; defaults come from the theme
    activeIndex which match gets activeColor, for a find cursor
    matchIndexOffset matches before this subtree; only for virtualized content
    radius corner radius of the wash, default 2

    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 and Hello Tommy still matches. activeIndex counts matches in paint order, so it means the same thing whether a match sits in a <text> or inside a <code> block.

    A <virtual-list> never builds off-screen rows, so the app supplies both numbers with the new findRanges export, which runs the same algorithm as the native matcher on a string you give it:

    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.

  3. <virtual-list> can mount a window of rows instead of all of them. The children form retains every child, so the first mount of a long transcript used to pay for every row. Pass itemCount with estimatedItemHeight and windowStart, then render only that slice; native keeps the full logical length for the scrollbar.

    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>
      )
    }

    onVisibleRange reports startIndex and endIndex after a scroll. TypeScript now requires estimatedItemHeight next to itemCount, and native ignores itemCount without it, because a row React has not mounted would otherwise measure as height 0 and collapse the scrollbar on a jump.

    There is deliberately no VirtualList wrapper component. The window is application state. A generic wrapper cannot know when to widen its own window, so it silently dropped rows whenever itemCount grew without a scroll, which is exactly what a filter does.

  4. 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 using alignment="bottom".

  5. 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 / drag Raw 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 modifiers in the same hyphenated syntax as press('cmd-a'), so cmd-wheel zoom, shift-click range selection, and alt-drag duplication are testable. launch() can now scroll a live app, textContent() concatenates descendants like DOM textContent, and click({ button }) really sends that button. Mouse input, locator bounds, and clock controls also work against a live app on Windows, Linux, and FreeBSD.

  6. <input> and <textarea> are reachable from the locator API.

    await app.getByTestId('composer').click()
    await app.getByTestId('composer').fill('hello gpuix')

    bounds() and click() threw Element has no painted bounds, because a custom element paints itself and the editor never attached the automation bounds tracker; the only workaround was a hard-coded pixe...

Read more

@gpuix/react@0.4.0

Choose a tag to compare

@remorses remorses released this 23 Aug 15:10

Also ships @gpuix/native@0.4.0. CI publishes both packages.

  1. Native motion.div animations — animate from an initial style to a target style. React sends the targets once. Rust interpolates the presentation style and requests GPUI frames. The React tree is not reconciled on each frame.

    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. ease is "linear", "ease", "easeIn", "easeOut", "easeInOut", or a cubic-bezier [x1, y1, x2, y2].

    Set initial={false} to mount at the first animate target. A running animation can reverse or change target without a jump.

  2. Playwright-like automation API — mark elements with testId, then drive them from tests or from another process.

    import { connectTest } from '@gpuix/react/automation'
    
    const app = await connectTest(renderer)
    await app.getByTestId('inc').click()
    await app.getByText('Count: 1').waitFor()
    await app.getByTestId('composer').fill('hello gpuix')
    await app.getByTestId('composer').press('enter')
    await app.captureFrames('review/sidebar', [0, 150, 300])

    Locators: getByTestId, getByText, getByType. app.clock.pause(), set(ms), and fastForward(ms) freeze native motion time.

    A live app listens on stdin when stdin is a pipe. launch({ command, args }) pipes stdin and speaks SSE data: 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()

@gpuix/react@0.3.0

Choose a tag to compare

@remorses remorses released this 23 Aug 13:33

Also ships @gpuix/native@0.3.0. CI publishes both packages.

  1. CSS grid on divdisplay: "grid" plus gridTemplateColumns maps to GPUI's Taffy grid. Use gridColumnMin: "max-content" for tables so each column is as wide as its widest cell.

    <div
      style={{
        display: 'grid',
        gridTemplateColumns: 3,
        gridColumnMin: 'max-content',
        rowGap: 1,
        columnGap: 1,
      }}
    >
      {cells}
    </div>

    gridTemplateRows and gridRowMin work the same on the other axis.

  2. Window chrome at open timerender() now honors a transparent titlebar, traffic-light position, and a blurred or transparent window background.

    import { render } from '@gpuix/react'
    
    render(<App />, {
      title: 'Waku',
      width: 1180,
      height: 820,
      titlebarTransparent: true,
      windowBackground: 'blurred',
      trafficLightX: 16,
      trafficLightY: 17,
    })

    windowBackground is "opaque" (default), "transparent", or "blurred".

  3. <diff> flows with its parent — it no longer owns a scroller unless you pass scroll. Use maxLines to keep a long patch short. Show more fires onShowMore.

    const [open, setOpen] = useState(false)
    
    <diff
      patch={unifiedPatch}
      wordDiff
      maxLines={open ? undefined : 24}
      onShowMore={() => setOpen(true)}
    />
  4. Debug frame overlay — see draw time on a live window.

    render(<App />, { title: 'My App', debugFrameOverlay: 'full' })

    Modes are hidden, minimal, and full. The readout is draw time, not FPS. 8.3 MS is about 120 Hz.

  5. Quit when the last window closes — the red traffic-light button now exits the process.

  6. Overlays block hits, and pointerEvents works — set pointerEvents: "none" to opt out. Set pointerEvents: "auto" to block even with no fill.

  7. Opaque Select, Combobox, and Tooltip surfacesFloatingLayer defaults to backgroundColor: "#1A1A1A".

  8. <svg> icons paint on the first frame — file paths and data:image/svg+xml URLs both work.

  9. bun --hot remounts no longer paint a black window.

  10. Cmd+Delete and Cmd+Backspace in <input> and <textarea> match the macOS system text field.

  11. Vertical wheel over overflowX: "scroll" stays on the parent. Trackpad X still pans the wide child.

  12. Parent scroller takes the wheel over a filled in-flow div.

  13. macOS scroll stays at the display rate on expensive frames.

  14. Faster first React mountapplyBatch sends styles and custom props as JSON values instead of double-encoded strings.

  15. Raw custom-prop values stay intact<anchored side="top"> commits again.

@gpuix/react@0.2.0

Choose a tag to compare

@remorses remorses released this 23 Aug 08:59

Also ships @gpuix/native@0.2.0. CI publishes both packages.

  1. 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.

    <div style={{ display: 'flex', flexDirection: 'column' }}>
      <text>drag from here</text>
      <code code={'and into this code block'} language="ts" />
    </div>

    Opt out with userSelect: 'none'. Read the selection with renderer.getSelectedText().

    <code code={source} language="typescript" showLineNumbers />
    <diff patch={unifiedPatch} wordDiff collapsedPaths={['pnpm-lock.yaml']} />
    <markdown source={readme} onLinkClick={(e) => open(e.value)} />

    Bundled languages: Rust, TypeScript, TSX, JavaScript, JSX, Python, Go, JSON, Bash, TOML, YAML, Markdown, HTML, CSS, C.

  2. Native <input> and <textarea> — caret, mouse selection, IME, clipboard, undo/redo, and grapheme-safe deletion. Enter submits. Shift+Enter inserts a newline in a textarea.

    <textarea value={draft} minRows={1} maxRows={8} onChange={(e) => setDraft(e.value ?? '')} onSubmit={send} />
  3. render() remounts React on the same native windowbun --hot saves remount the tree without a second window.

    import { render } from '@gpuix/react'
    render(<App />, { title: 'My App', width: 800, height: 600 })
  4. Headless Select, Combobox, and Tooltip — unstyled primitives. Import @gpuix/react/select, @gpuix/react/combobox, or @gpuix/react/tooltip and wrap them in local components/ui files.

    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>
  5. <virtual-list> — GPUI builds only rows near the viewport.

    <virtual-list alignment="bottom" followTail estimatedItemHeight={180}>
      {messages.map((message) => <Message key={message.id} message={message} />)}
    </virtual-list>
  6. Tintable local SVG icons

    <svg src="/absolute/path/to/search.svg" style={{ width: 16, height: 16, color: '#b4b4b4' }} />
  7. startFrameLoop() — idle CPU drops from ~73% to ~1%. Default pace is ~125fps.

    import { startFrameLoop } from '@gpuix/react'
    startFrameLoop(renderer)
  8. Native GPUI platform on macOS, Windows, and Linux. Windows runtime validation is still pending.

  9. GPUI upgrade to zed d5dc01f2. Scroll events can report touchPhase: 'cancelled'. Building from source now needs Rust 1.97.1 and the Metal toolchain on macOS.

  10. Style props that were declared and dropped now work — full styles on <text>, plus fontSize, textAlign, rowGap, columnGap, lineHeight, and borderWidth: 0.

  11. autoFocus works and <input> no longer ships a hardcoded look.

  12. Blinking caret every 500ms while focused. Colour is theme.caret.

  13. Clipboard and natural scroll — Cmd+C writes to the system clipboard. Wheel deltas keep the OS sign.

  14. React 19 JSX components can return any ReactNode.