Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/native-integration-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@gpuix/native': minor
'@gpuix/react': minor
---

Add desktop `getNativeWindowHandle()` and non-flushing `getElementPaintState(id)` snapshots, with React types and test renderer forwarding. Handles are borrowed, tagged Buffers; paint geometry includes logical bounds, rectangular clipping and scale. Neither query manages child lifetimes or dispatches downstream FFI onto GPUI's UI thread.

GPU-backed test windows may return native handles; replaced test renderers cannot query another renderer's handle. These observational queries do not add style behavior.

No Zed submodule change or dynamic surface implementation. The existing pin already supplies `HasWindowHandle`, `HasDisplayHandle`, and paint-time bounds. Upstream research: [#24327](https://github.com/zed-industries/zed/pull/24327) merged the window traits, [#50768](https://github.com/zed-industries/zed/pull/50768) merged X11 support, and [#62775](https://github.com/zed-industries/zed/pull/62775) merged headless `NotSupported` handling. The pinned `InteractiveElement::on_painted` is available locally; upstream issue/PR and code searches for that exact symbol returned no matches. [remorses/zed#8](https://github.com/remorses/zed/pull/8) remains open/conflicting and is not required or imported.
134 changes: 134 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,140 @@ 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.

## Native integration snapshots

Desktop renderers expose two **observational queries** for downstream native
integrations. No native child, browser, surface, plugin host, or lifecycle hook
is created. Reach them through `useGpuixRequired()` or `createRenderer()`:

```ts
renderer.getNativeWindowHandle?.() // NativeWindowHandle | null
renderer.getElementPaintState?.(ref.current.id) // ElementPaintState | null
```

`NativeWindowHandle`, `NativeWindowHandleKind`, `ElementPaintState`, and
`PaintBounds` are exported types from `@gpuix/react` and generated by
`@gpuix/native`. The methods are optional on `NativeRenderer` because custom
and browser renderers need not implement them. The browser does not implement
these queries. `TestRenderer` implements both: GPU-backed offscreen windows can
return a native handle; headless platforms without a raw window return `null`.
A replaced test renderer cannot query its replacement's native handle.
Invalid element IDs (negative, fractional, non-finite, or above JS's safe integer
limit) throw. A live desktop renderer queried before initialization or after its
window/UI loop is gone throws; no cached native handle is returned.

### Borrowed handle bytes

`getNativeWindowHandle()` returns `{ kind, handle, display?, screen? }`, or
`null` if GPUI cannot supply a supported matching window/display pair. `handle`
and `display` are **Node Buffers in native byte order**, never JS Number
pointers. Buffer length is the native field's width, not uniformly eight bytes:

| `kind` | `handle` | `display` | `screen` |
|---|---|---|---|
| `AppKit` | `NSView*`, pointer-sized (**not** `NSWindow*`) | absent | absent |
| `Win32` | `HWND`, pointer-sized | absent | absent |
| `Xlib` | X11 `Window`, native `unsigned long` | borrowed `Display*`, pointer-sized | X11 screen index |
| `Xcb` | `xcb_window_t`, 4 bytes | borrowed `xcb_connection_t*`, pointer-sized | X11 screen index |
| `Wayland` | `wl_surface*`, pointer-sized | borrowed `wl_display*`, pointer-sized | absent |

The current GPUI Linux X11 backend reports `Xcb`, not `Xlib`. Keep the matching
Linux display connection with its window ID/surface; opening a different
connection is not a substitute. This is not a full serialization of every
`raw-window-handle` field (for example, no visual ID or Win32 instance handle).
Do not pass XCB bytes to an API expecting an Xlib `unsigned long` without an
explicit native conversion. Wayland does not offer arbitrary X11-style child
reparenting; a surface pointer does not grant that capability.

**Unsafe FFI contract:** the Buffers own only copied bytes. GPUI retains all
ownership of the native window/view/display; the query does not retain, lease,
lock, or extend their lifetime. Never free them or take exclusive ownership.
Closing, replacing, or tearing down the window invalidates saved bytes. Their
presence—even a fresh successful query—is not proof of validity at later use.
X11 IDs can also be destroyed or reused externally. Downstream native code must
establish its own lifetime and synchronization discipline before dereferencing
anything. These are in-process identifiers, not IPC capabilities.

The query reads GPUI on its **UI thread**, then copies the result back to JS.
This does **not** make downstream JS or FFI run there. macOS uses Node's main
thread for GPUI; Windows/Linux use a separate Rust UI thread. Native integrations
must satisfy platform thread affinity and arrange their own thread dispatch.
There is no dispatch/retain/destroy callback API here, and no guaranteed safe
point to attach or tear down a native child. Geometry polling cannot supply one.
The embedder owns child teardown ordering, focus, input, stacking, and clipping;
GPUI overlays do not automatically composite above a native child.

### Last-painted element geometry

`getElementPaintState(id)` reads the renderer's most recent paint record:

```ts
interface PaintBounds { x: number; y: number; width: number; height: number }
interface ElementPaintState {
bounds: PaintBounds
clipBounds: PaintBounds
scaleFactor: number
}
```

- Both rectangles use **logical GPUI pixels**, relative to the window content
origin (top-left, positive Y downward), including scroll/element offsets, not
desktop coordinates. Multiply by the **recorded** `scaleFactor` for physical
pixels; native toolkit origin, DPI and rounding conversions remain yours.
- `bounds` is the recorded element box, not a native child allocation. Containers
use the existing absolute full-size bounds tracker; leaves and anchored
overlays use GPUI's `on_painted` without changing layout.
- `clipBounds` intersects that box with GPUI's rectangular content mask **at the
recording point**. Empty intersections have zero area. It is not a pixel
visibility test: rounded clips, opacity, occlusion, window hiding/minimizing,
and other windows are not represented. An opacity-zero element can have a
record. A fully clipped element can have an empty record or no record if GPUI
skipped painting it.
- This is **paint, not layout or prepaint**. GPUI may roll back speculative list
prepaint. Only rows that actually reach paint can have records. Use a wrapping
`div` to query a `virtual-list` itself, which has no bounds tracker.
- The query does **not** flush React, request a frame, or wait for a newer paint.
A commit, scroll, resize or DPI change may still return the previous geometry
and scale. `flushSync` only commits React. In tests, explicitly call
`renderer.flush()` to paint; unlike `getElementBounds`, this new test query
never flushes implicitly.
- `null` means no record in the last painted frame for that renderer/ID: before
first paint, unknown ID, unmounted node, or virtualized-away row. Removed
records disappear on the **next paint**, not on commit. This query does not
add style support: the existing `visibility` prop is not mapped to GPUI and
does not hide elements.
A hidden/minimized OS window may stop painting and leave its last snapshot
unchanged. `null` is not an unmount or window-close notification. The registry
is scoped by renderer tree identity: if a different renderer owns the thread's
last paint map, the query returns `null`, never that renderer's geometry.

The existing one-renderer/one-window restriction remains. A query is not a
transaction with either another query or future native operations. No child
lifecycle safety or frame-synchronous embedding is promised.

Run the tiny read-only diagnostic with `cd examples && bun embedding.tsx`.
It opens without focus and logs handle *sizes* and painted geometry, not pointer
addresses. It attaches no native resources.

### Sources and GPUI availability

The unchanged GPUI pin already implements `Window: HasWindowHandle +
HasDisplayHandle` and paint recording. Upstream [Zed #24327](https://github.com/zed-industries/zed/pull/24327)
merged the window traits; [#50768](https://github.com/zed-industries/zed/pull/50768)
merged X11 handles; [#62775](https://github.com/zed-industries/zed/pull/62775)
merged test-window `HandleError::NotSupported` instead of panics. The pinned
`InteractiveElement::on_painted` is present locally; exact upstream issue/PR
and code searches for `on_painted` returned no matches. No GPUI change is needed.
[remorses/zed#8](https://github.com/remorses/zed/pull/8) (embedded dynamic surfaces)
is open/conflicting and is **not** used.

The Buffer convention follows [Electron `getNativeWindowHandle`](https://www.electronjs.org/docs/latest/api/browser-window#wingetnativewindowhandle),
with explicit backend/display tags. Rust's [`WindowHandle` borrowed lifetime](https://docs.rs/raw-window-handle/latest/raw_window_handle/struct.WindowHandle.html)
(`!Send`, `!Sync`, with XID exceptions) does not survive copying bytes into JS.
Qt's [window embedding example](https://doc.qt.io/qt-6/qtdoc-demos-windowembedding-example.html)
likewise requires the application to keep a foreign handle alive without
exclusive ownership and convert dimensions using device pixel ratio.

## Debug frame overlay

GPUI paints frame-time stats into the window after layout. The overlay is not
Expand Down
27 changes: 27 additions & 0 deletions examples/embedding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React, { useEffect, useRef } from 'react'
import { render, useGpuixRequired, type PublicInstance } from '@gpuix/react'

function Diagnostic() {
const renderer = useGpuixRequired()
const target = useRef<PublicInstance>(null)
useEffect(() => {
// These are observations, never a safe point to attach/destroy a native child.
const timer = setInterval(() => {
const native = renderer.getNativeWindowHandle?.()
console.log({
kind: native?.kind,
handleBytes: native?.handle.length,
displayBytes: native?.display?.length,
paint: target.current && renderer.getElementPaintState?.(target.current.id),
})
}, 1000)
return () => clearInterval(timer)
}, [renderer])
return (
<div ref={target} style={{ width: 240, height: 100, padding: 16, backgroundColor: '#243044' }}>
<text style={{ color: '#ffffff' }}>Native integration diagnostic</text>
</div>
)
}

render(<Diagnostic />, { title: 'Embedding snapshots', width: 400, height: 240, focus: false })
1 change: 1 addition & 0 deletions packages/native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ reqwest_client = { path = "../../zed/crates/reqwest_client" }
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
napi = { version = "3", features = ["napi8", "serde-json"] }
napi-derive = "3"
raw-window-handle = "0.6"

# The slow half of the engine split above. fancy-regex is pure Rust, so it is
# the only Syntect engine that builds here, and the browser still pays ~133ms on
Expand Down
50 changes: 50 additions & 0 deletions packages/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ export declare class GpuixRenderer {
getScrollOffset(elementId: number): Array<number> | null
getAutomationTree(): string
getElementBounds(id: number): Array<number> | null
/**
* Borrowed native identifiers in Buffers, or null if GPUI cannot supply
* a supported window/display pair. This does not retain the window.
*/
getNativeWindowHandle(): NativeWindowHandle | null
/**
* Last-painted geometry, or null when this element had no paint record.
* Does not flush, request a frame, or synchronize native child lifetimes.
*/
getElementPaintState(id: number): ElementPaintState | null
getAllText(): Array<string>
getPaintedText(): Array<string>
/**
Expand Down Expand Up @@ -328,6 +338,13 @@ export declare class TestGpuixRenderer {
getAutomationTree(): string
/** Last painted bounds for an element, or null if it was not painted. */
getElementBounds(id: number): Array<number> | null
/**
* Borrowed identifiers for GPU-backed offscreen windows, or null on
* headless platforms that cannot supply a raw handle.
*/
getNativeWindowHandle(): NativeWindowHandle | null
/** Same non-flushing last-paint query as the live renderer. */
getElementPaintState(id: number): ElementPaintState | null
clockPause(): number
clockSet(nowMs: number): number
clockFastForward(deltaMs: number): number
Expand Down Expand Up @@ -360,6 +377,13 @@ export interface EdgeInsets {
left: number
}

/** Geometry observed during paint, not a layout or visibility guarantee. */
export interface ElementPaintState {
bounds: PaintBounds
clipBounds: PaintBounds
scaleFactor: number
}

export interface EventModifiers {
shift: boolean
ctrl: boolean
Expand Down Expand Up @@ -498,6 +522,32 @@ export interface HighlightRect {
height: number
}

/**
* Borrowed native identifiers, encoded in native byte order. No ownership or
* lifetime is transferred. See README Native integration snapshots before FFI use.
*/
export interface NativeWindowHandle {
kind: NativeWindowHandleKind
handle: Buffer
display?: Buffer
screen?: number
}

export declare const enum NativeWindowHandleKind {
AppKit = 'AppKit',
Win32 = 'Win32',
Xlib = 'Xlib',
Xcb = 'Xcb',
Wayland = 'Wayland'
}

export interface PaintBounds {
x: number
y: number
width: number
height: number
}

export interface WindowInsets {
safeArea: EdgeInsets
ime: EdgeInsets
Expand Down
1 change: 1 addition & 0 deletions packages/native/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,4 @@ module.exports = nativeBinding
module.exports.GpuixRenderer = nativeBinding.GpuixRenderer
module.exports.TestGpuixRenderer = nativeBinding.TestGpuixRenderer
module.exports.hasTestGpuixRenderer = nativeBinding.hasTestGpuixRenderer
module.exports.NativeWindowHandleKind = nativeBinding.NativeWindowHandleKind
Loading
Loading