From df3ddaf943cda76b109bbf8eb6a15dd99e173998 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Wed, 9 Sep 2026 11:59:00 +0700 Subject: [PATCH 1/2] feat(native): expose native integration snapshots Expose borrowed platform handles and last-painted geometry through two queries. Keep native child ownership and synchronization in applications. Add public contracts, generated types, encoding and renderer regressions. --- .changeset/native-integration-snapshots.md | 10 + README.md | 134 +++++++++++++ examples/embedding.tsx | 27 +++ packages/native/Cargo.lock | 1 + packages/native/Cargo.toml | 1 + packages/native/index.d.ts | 50 +++++ packages/native/index.js | 1 + packages/native/src/automation.rs | 84 +++++++-- packages/native/src/embedding.rs | 176 ++++++++++++++++++ packages/native/src/lib.rs | 7 +- packages/native/src/renderer.rs | 83 ++++++++- packages/native/src/test_renderer.rs | 29 +++ .../react/src/__tests__/embedding.test.tsx | 142 ++++++++++++++ packages/react/src/index.ts | 4 + packages/react/src/testing.ts | 12 +- packages/react/src/types/host.ts | 7 +- 16 files changed, 749 insertions(+), 19 deletions(-) create mode 100644 .changeset/native-integration-snapshots.md create mode 100644 examples/embedding.tsx create mode 100644 packages/native/src/embedding.rs create mode 100644 packages/react/src/__tests__/embedding.test.tsx diff --git a/.changeset/native-integration-snapshots.md b/.changeset/native-integration-snapshots.md new file mode 100644 index 00000000..8aa3d71d --- /dev/null +++ b/.changeset/native-integration-snapshots.md @@ -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. diff --git a/README.md b/README.md index edebe157..f0a63a3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/embedding.tsx b/examples/embedding.tsx new file mode 100644 index 00000000..567f0b00 --- /dev/null +++ b/examples/embedding.tsx @@ -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(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 ( +
+ Native integration diagnostic +
+ ) +} + +render(, { title: 'Embedding snapshots', width: 400, height: 240, focus: false }) diff --git a/packages/native/Cargo.lock b/packages/native/Cargo.lock index 687667e4..fbff2608 100644 --- a/packages/native/Cargo.lock +++ b/packages/native/Cargo.lock @@ -2619,6 +2619,7 @@ dependencies = [ "napi-derive", "parking_lot", "pulldown-cmark", + "raw-window-handle", "reqwest_client", "rmp-serde", "rustc-hash 2.1.1", diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index f812df47..510b0662 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -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 diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 0f2a4b95..60b7c679 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -103,6 +103,16 @@ export declare class GpuixRenderer { getScrollOffset(elementId: number): Array | null getAutomationTree(): string getElementBounds(id: number): Array | 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 getPaintedText(): Array /** @@ -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 | 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 @@ -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 @@ -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 diff --git a/packages/native/index.js b/packages/native/index.js index ee79c88b..46f82eb0 100644 --- a/packages/native/index.js +++ b/packages/native/index.js @@ -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 diff --git a/packages/native/src/automation.rs b/packages/native/src/automation.rs index 8a197650..67cdf73a 100644 --- a/packages/native/src/automation.rs +++ b/packages/native/src/automation.rs @@ -9,7 +9,7 @@ use std::cell::RefCell; use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; use gpui::{ @@ -18,15 +18,21 @@ use gpui::{ }; use web_time::Instant; -#[derive(Clone, Copy, Debug)] -pub struct ElementBounds { +#[cfg_attr( + not(all(target_arch = "wasm32", target_os = "unknown")), + napi_derive::napi(object) +)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PaintBounds { pub x: f64, pub y: f64, pub width: f64, pub height: f64, } -impl ElementBounds { +pub type ElementBounds = PaintBounds; + +impl PaintBounds { fn from_gpui(bounds: Bounds) -> Self { Self { x: f64::from(f32::from(bounds.origin.x)), @@ -37,8 +43,26 @@ impl ElementBounds { } } +/// Geometry observed during paint, not a layout or visibility guarantee. +#[cfg_attr( + not(all(target_arch = "wasm32", target_os = "unknown")), + napi_derive::napi(object) +)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ElementPaintState { + pub bounds: PaintBounds, + pub clip_bounds: PaintBounds, + pub scale_factor: f64, +} + +#[derive(Default)] +struct PaintFrame { + owner: Weak>, + elements: HashMap, +} + thread_local! { - static BOUNDS: RefCell> = RefCell::new(HashMap::new()); + static BOUNDS: RefCell = RefCell::new(PaintFrame::default()); } /// Zero-size canvas. Keep it ahead of the app subtree under the root. @@ -47,11 +71,18 @@ thread_local! { /// `List::prepaint` speculatively prepaints a row range and can roll the window /// back and prepaint a different one, so a prepaint-recorded box can belong to a /// row that never reached the screen. -pub fn bounds_frame_reset() -> impl IntoElement { +pub fn bounds_frame_reset( + tree: &Arc>, +) -> impl IntoElement { + let owner = Arc::downgrade(tree); canvas( |_, _, _| (), move |_, _, _, _| { - BOUNDS.with(|cell| cell.borrow_mut().clear()); + BOUNDS.with(|cell| { + let mut frame = cell.borrow_mut(); + frame.owner = owner.clone(); + frame.elements.clear(); + }); }, ) .absolute() @@ -66,29 +97,52 @@ pub fn bounds_frame_reset() -> impl IntoElement { /// move the layout box: the wrapper would become the flex item and the image /// would lose intrinsic sizing and corner clipping. pub fn track_own_bounds(el: E, id: u64) -> E { - el.on_painted(move |bounds, _, _| record_bounds(id, bounds)) + el.on_painted(move |bounds, window, _| record_bounds(id, bounds, window)) } -pub fn record_bounds(id: u64, bounds: Bounds) { +pub fn record_bounds(id: u64, bounds: Bounds, window: &Window) { + let state = ElementPaintState { + bounds: PaintBounds::from_gpui(bounds), + clip_bounds: PaintBounds::from_gpui(bounds.intersect(&window.content_mask().bounds)), + scale_factor: f64::from(window.scale_factor()), + }; BOUNDS.with(|cell| { - cell.borrow_mut() - .insert(id, ElementBounds::from_gpui(bounds)); + cell.borrow_mut().elements.insert(id, state); }); } pub fn get_bounds(id: u64) -> Option { - BOUNDS.with(|cell| cell.borrow().get(&id).copied()) + BOUNDS.with(|cell| cell.borrow().elements.get(&id).map(|state| state.bounds)) } pub fn all_bounds() -> HashMap { - BOUNDS.with(|cell| cell.borrow().clone()) + BOUNDS.with(|cell| { + cell.borrow() + .elements + .iter() + .map(|(&id, state)| (id, state.bounds)) + .collect() + }) +} + +pub fn get_paint_state( + id: u64, + tree: &Arc>, +) -> Option { + BOUNDS.with(|cell| { + let frame = cell.borrow(); + if !frame.owner.ptr_eq(&Arc::downgrade(tree)) { + return None; + } + frame.elements.get(&id).copied() + }) } pub fn bounds_tracker(id: u64, selection_start: Option) -> impl IntoElement { canvas( |bounds, _, _| bounds, - move |bounds, _, _, _| { - record_bounds(id, bounds); + move |bounds, _, window, _| { + record_bounds(id, bounds, window); if let Some(selectable) = selection_start { crate::text::record_start_region(bounds, selectable); } diff --git a/packages/native/src/embedding.rs b/packages/native/src/embedding.rs new file mode 100644 index 00000000..08d95eb1 --- /dev/null +++ b/packages/native/src/embedding.rs @@ -0,0 +1,176 @@ +//! Observational native handles. Copying bytes does not extend the GPUI borrow. + +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}; + +#[napi(string_enum)] +pub enum NativeWindowHandleKind { + AppKit, + Win32, + Xlib, + Xcb, + Wayland, +} + +/// Borrowed native identifiers, encoded in native byte order. No ownership or +/// lifetime is transferred. See README Native integration snapshots before FFI use. +#[napi(object)] +pub struct NativeWindowHandle { + pub kind: NativeWindowHandleKind, + pub handle: Buffer, + pub display: Option, + pub screen: Option, +} + +pub(crate) fn native_window_handle(window: &gpui::Window) -> Option { + // Window also has an inherent window_handle() returning a GPUI entity id. + let handle = HasWindowHandle::window_handle(window).ok()?; + let display = HasDisplayHandle::display_handle(window).ok()?; + encode_handles(handle.as_raw(), display.as_raw()) +} + +fn encode_handles( + handle: RawWindowHandle, + display: RawDisplayHandle, +) -> Option { + let (kind, handle, display, screen) = match (handle, display) { + (RawWindowHandle::AppKit(handle), RawDisplayHandle::AppKit(_)) => ( + NativeWindowHandleKind::AppKit, + (handle.ns_view.as_ptr() as usize).to_ne_bytes().to_vec(), + None, + None, + ), + (RawWindowHandle::Win32(handle), RawDisplayHandle::Windows(_)) => ( + NativeWindowHandleKind::Win32, + handle.hwnd.get().to_ne_bytes().to_vec(), + None, + None, + ), + (RawWindowHandle::Xlib(handle), RawDisplayHandle::Xlib(display)) => ( + NativeWindowHandleKind::Xlib, + handle.window.to_ne_bytes().to_vec(), + Some((display.display?.as_ptr() as usize).to_ne_bytes().to_vec()), + Some(display.screen), + ), + (RawWindowHandle::Xcb(handle), RawDisplayHandle::Xcb(display)) => ( + NativeWindowHandleKind::Xcb, + handle.window.get().to_ne_bytes().to_vec(), + Some( + (display.connection?.as_ptr() as usize) + .to_ne_bytes() + .to_vec(), + ), + Some(display.screen), + ), + (RawWindowHandle::Wayland(handle), RawDisplayHandle::Wayland(display)) => ( + NativeWindowHandleKind::Wayland, + (handle.surface.as_ptr() as usize).to_ne_bytes().to_vec(), + Some((display.display.as_ptr() as usize).to_ne_bytes().to_vec()), + None, + ), + _ => return None, + }; + // Buffer::from(Vec) has no Env or napi_ref. Only the return conversion on + // the JS thread creates JS buffers; these UI-thread results stay Rust-owned. + Some(NativeWindowHandle { + kind, + handle: handle.into(), + display: display.map(Into::into), + screen, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::num::{NonZeroIsize, NonZeroU32}; + use std::ptr::NonNull; + + #[test] + fn encodes_pointer_bits_without_js_number_rounding() { + let bits = isize::MAX; + let handle = raw_window_handle::Win32WindowHandle::new(NonZeroIsize::new(bits).unwrap()); + let result = encode_handles( + handle.into(), + RawDisplayHandle::Windows(raw_window_handle::WindowsDisplayHandle::new()), + ) + .unwrap(); + assert!(matches!(result.kind, NativeWindowHandleKind::Win32)); + assert_eq!(&*result.handle, &bits.to_ne_bytes()); + assert!(result.display.is_none()); + assert!(result.screen.is_none()); + } + + #[test] + fn xcb_requires_connection_and_preserves_id_width() { + let handle = raw_window_handle::XcbWindowHandle::new(NonZeroU32::new(u32::MAX).unwrap()); + let missing = raw_window_handle::XcbDisplayHandle::new(None, 3); + assert!(encode_handles(handle.into(), missing.into()).is_none()); + let connection = NonNull::dangling(); + let display = raw_window_handle::XcbDisplayHandle::new(Some(connection), 3); + let result = encode_handles(handle.into(), display.into()).unwrap(); + assert!(matches!(result.kind, NativeWindowHandleKind::Xcb)); + assert_eq!(&*result.handle, &u32::MAX.to_ne_bytes()); + assert_eq!( + &*result.display.unwrap(), + &(connection.as_ptr() as usize).to_ne_bytes() + ); + assert_eq!(result.screen, Some(3)); + } + + #[test] + fn appkit_preserves_nsview_pointer_and_tag() { + let pointer = NonNull::::dangling(); + let handle = raw_window_handle::AppKitWindowHandle::new(pointer); + let display = raw_window_handle::AppKitDisplayHandle::new(); + let result = encode_handles(handle.into(), display.into()).unwrap(); + assert!(matches!(result.kind, NativeWindowHandleKind::AppKit)); + assert_eq!(&*result.handle, &(pointer.as_ptr() as usize).to_ne_bytes()); + assert!(result.display.is_none()); + assert!(result.screen.is_none()); + } + + #[test] + fn xlib_preserves_unsigned_long_and_requires_display() { + let mut handle = raw_window_handle::XlibWindowHandle::new(std::ffi::c_ulong::MAX); + handle.visual_id = 0; + assert!(encode_handles( + handle.into(), + raw_window_handle::XlibDisplayHandle::new(None, -1).into() + ) + .is_none()); + let pointer = NonNull::::dangling(); + let display = raw_window_handle::XlibDisplayHandle::new(Some(pointer), -1); + let result = encode_handles(handle.into(), display.into()).unwrap(); + assert!(matches!(result.kind, NativeWindowHandleKind::Xlib)); + assert_eq!(&*result.handle, &std::ffi::c_ulong::MAX.to_ne_bytes()); + assert_eq!( + &*result.display.unwrap(), + &(pointer.as_ptr() as usize).to_ne_bytes() + ); + assert_eq!(result.screen, Some(-1)); + } + + #[test] + fn wayland_preserves_surface_and_display() { + let pointer = NonNull::::dangling(); + let handle = raw_window_handle::WaylandWindowHandle::new(pointer); + let display = raw_window_handle::WaylandDisplayHandle::new(pointer); + let result = encode_handles(handle.into(), display.into()).unwrap(); + assert!(matches!(result.kind, NativeWindowHandleKind::Wayland)); + assert_eq!(&*result.handle, &(pointer.as_ptr() as usize).to_ne_bytes()); + assert_eq!( + &*result.display.unwrap(), + &(pointer.as_ptr() as usize).to_ne_bytes() + ); + assert!(result.screen.is_none()); + } + + #[test] + fn mismatched_backends_are_unavailable() { + let handle = raw_window_handle::AppKitWindowHandle::new(NonNull::dangling()); + let display = raw_window_handle::WindowsDisplayHandle::new(); + assert!(encode_handles(handle.into(), display.into()).is_none()); + } +} diff --git a/packages/native/src/lib.rs b/packages/native/src/lib.rs index 36e7daeb..a73a4285 100644 --- a/packages/native/src/lib.rs +++ b/packages/native/src/lib.rs @@ -11,10 +11,15 @@ use napi::bindgen_prelude::*; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] use napi_derive::napi; +mod accessibility; #[cfg(target_os = "macos")] mod app_menu; -mod accessibility; mod automation; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +mod embedding; +pub use automation::{ElementPaintState, PaintBounds}; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub use embedding::*; mod color; mod custom_elements; mod diff; diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 2ff9f51e..8c06c981 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -410,6 +410,13 @@ enum UiCommand { id: u64, response: SyncSender>, }, + GetNativeWindowHandle { + response: SyncSender>, + }, + GetElementPaintState { + id: u64, + response: SyncSender>, + }, FocusElement(u64), FocusNext, FocusPrevious, @@ -604,6 +611,20 @@ async fn run_ui_commands( }); }) } + UiCommand::GetNativeWindowHandle { response } => { + window.update(cx, move |_view, window, _cx| { + response + .send(crate::embedding::native_window_handle(window)) + .ok(); + }) + } + UiCommand::GetElementPaintState { id, response } => { + window.update(cx, move |view, _window, _cx| { + response + .send(crate::automation::get_paint_state(id, &view.tree)) + .ok(); + }) + } UiCommand::FocusElement(id) => window.update(cx, move |view, window, cx| { view.reveal_virtual_list_ancestor(id); if let Some(handle) = view.focus_handles.get(&id) { @@ -1812,6 +1833,66 @@ impl GpuixRenderer { .map(|bounds| vec![bounds.x, bounds.y, bounds.width, bounds.height])) } + /// Borrowed native identifiers in Buffers, or null if GPUI cannot supply + /// a supported window/display pair. This does not retain the window. + #[napi] + pub fn get_native_window_handle(&self) -> Result> { + #[cfg(target_os = "macos")] + return update_window(|view, window, _cx| { + if !Arc::ptr_eq(&view.tree, &self.tree) { + return Err(Error::from_reason("Renderer does not own the GPUI window")); + } + Ok(crate::embedding::native_window_handle(window)) + })?; + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + { + let (response, receiver) = sync_channel(1); + self.send_ui_command(UiCommand::GetNativeWindowHandle { response })?; + return recv_ui_response(receiver, "the native window handle query"); + } + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Ok(None) + } + + /// Last-painted geometry, or null when this element had no paint record. + /// Does not flush, request a frame, or synchronize native child lifetimes. + #[napi] + pub fn get_element_paint_state( + &self, + id: f64, + ) -> Result> { + let id = to_element_id(id)?; + #[cfg(target_os = "macos")] + return update_window(|view, _window, _cx| { + if !Arc::ptr_eq(&view.tree, &self.tree) { + return Err(Error::from_reason("Renderer does not own the GPUI window")); + } + Ok(crate::automation::get_paint_state(id, &self.tree)) + })?; + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + { + let (response, receiver) = sync_channel(1); + self.send_ui_command(UiCommand::GetElementPaintState { id, response })?; + return recv_ui_response(receiver, "the element paint state query"); + } + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Ok(None) + } + #[napi] pub fn get_all_text(&self) -> Vec { let tree = self.tree.lock().unwrap(); @@ -3946,7 +4027,7 @@ impl gpui::Render for GpuixView { .ok(); }, )) - .child(crate::automation::bounds_frame_reset()) + .child(crate::automation::bounds_frame_reset(&self.tree)) .child(result) .into_any_element() }; diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs index d638ce81..ca9b3f4e 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -929,6 +929,35 @@ impl TestGpuixRenderer { .map(|bounds| vec![bounds.x, bounds.y, bounds.width, bounds.height])) } + /// Borrowed identifiers for GPU-backed offscreen windows, or null on + /// headless platforms that cannot supply a raw handle. + #[napi] + pub fn get_native_window_handle(&self) -> Result> { + with_test_state(|cx, window, view| { + if !cx.update(|cx| Arc::ptr_eq(&view.read(cx).tree, &self.tree)) { + return Err(Error::from_reason( + "Renderer does not own the GPUI test window", + )); + } + cx.update_window(window, |_, window, _| { + crate::embedding::native_window_handle(window) + }) + .map_err(|error| Error::from_reason(error.to_string())) + }) + } + + /// Same non-flushing last-paint query as the live renderer. + #[napi] + pub fn get_element_paint_state( + &self, + id: f64, + ) -> Result> { + Ok(crate::automation::get_paint_state( + to_element_id(id)?, + &self.tree, + )) + } + #[napi] pub fn clock_pause(&self) -> Result { with_test_state(|cx, window, view| { diff --git a/packages/react/src/__tests__/embedding.test.tsx b/packages/react/src/__tests__/embedding.test.tsx new file mode 100644 index 00000000..da294e66 --- /dev/null +++ b/packages/react/src/__tests__/embedding.test.tsx @@ -0,0 +1,142 @@ +import React, { createRef } from "react" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { GpuixRenderer } from "@gpuix/native" +import { flushSync } from "../reconciler/reconciler.js" +import { createTestRoot, hasNativeTestRenderer, type TestRoot } from "../testing.js" +import type { NativeRenderer, PublicInstance } from "../types/host.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +describeNative("native integration snapshots", () => { + let root: TestRoot + beforeEach(() => { root = createTestRoot({ width: 300, height: 200 }) }) + afterEach(() => { root.unmount(); root.renderer.flush() }) + + it("reports borrowed tagged bytes when the test platform supplies a raw handle", () => { + const renderer: NativeRenderer = root.renderer + const handle = renderer.getNativeWindowHandle!() + if (process.platform === "darwin") expect(handle?.kind).toBe("AppKit") + if (!handle) return // A headless GPUI platform may report NotSupported. + expect(["AppKit", "Win32", "Xlib", "Xcb", "Wayland"]).toContain(handle.kind) + expect(Buffer.isBuffer(handle.handle)).toBe(true) + expect(handle.handle.length).toBe(handle.kind === "Xcb" || process.arch === "ia32" ? 4 : 8) + const again = renderer.getNativeWindowHandle!()! + expect(again).toEqual(handle) + handle.handle.fill(0) + expect(renderer.getNativeWindowHandle!()).toEqual(again) + }) + + it("rejects a live query before init rather than leaking a test window", () => { + const renderer = new GpuixRenderer() + expect(() => renderer.getNativeWindowHandle()).toThrow() + expect(() => renderer.getElementPaintState(1)).toThrow() + }) + + it("reports logical bounds, rectangular clipping, and the painted scale", () => { + const ref = createRef() + root.render( +
+
+
, + ) + const state = root.renderer.getElementPaintState(ref.current!.id)! + expect(state.bounds).toEqual({ x: 75, y: 10, width: 50, height: 30 }) + expect(state.clipBounds).toEqual({ x: 75, y: 10, width: 25, height: 30 }) + expect(state.scaleFactor).toBeGreaterThan(0) + const { x, y, width, height } = state.bounds + expect(root.renderer.getElementBounds(ref.current!.id)).toEqual([x, y, width, height]) + }) + + it("does not paint on query and drops removed nodes only after the next paint", () => { + const ref = createRef() + flushSync(() => root.root.render(
)) + const id = ref.current!.id + expect(root.renderer.getElementPaintState(id)).toBeNull() + root.renderer.flush() + const first = root.renderer.getElementPaintState(id)! + expect(first.bounds.width).toBe(40) + + flushSync(() => root.root.render(
)) + expect(root.renderer.getElementPaintState(id)).toEqual(first) + root.renderer.flush() + expect(root.renderer.getElementPaintState(id)!.bounds.width).toBe(80) + + flushSync(() => root.root.render(null)) + expect(root.renderer.getElementPaintState(id)).not.toBeNull() + root.renderer.flush() + expect(root.renderer.getElementPaintState(id)).toBeNull() + }) + + it("records leaf geometry and opacity-zero containers without promising pixel visibility", () => { + const ref = createRef() + root.render('} style={{ width: 40, height: 20, color: "#fff" }} />) + const leafId = ref.current!.id + expect(root.renderer.getElementPaintState(leafId)!.bounds).toEqual({ x: 0, y: 0, width: 40, height: 20 }) + flushSync(() => root.root.render(null)) + expect(root.renderer.getElementPaintState(leafId)).not.toBeNull() + root.renderer.flush() + expect(root.renderer.getElementPaintState(leafId)).toBeNull() + root.render(
) + expect(root.renderer.getElementPaintState(ref.current!.id)).not.toBeNull() + }) + + it("clips to the viewport by default and never reports negative clip dimensions", () => { + const ref = createRef() + root.render(
) + const visible = root.renderer.getElementPaintState(ref.current!.id)! + expect(visible.bounds).toEqual({ x: 280, y: 180, width: 50, height: 40 }) + expect(visible.clipBounds).toEqual({ x: 280, y: 180, width: 20, height: 20 }) + root.render(
) + const negative = root.renderer.getElementPaintState(ref.current!.id)! + expect(negative.bounds).toEqual({ x: -20, y: -10, width: 50, height: 40 }) + expect(negative.clipBounds).toEqual({ x: 0, y: 0, width: 30, height: 30 }) + for (const position of [400, -100]) { + root.render(
) + const clipped = root.renderer.getElementPaintState(ref.current!.id) + // GPUI may skip paint altogether; a record is not proof of visibility. + if (clipped) { + expect(clipped.clipBounds.width).toBe(0) + expect(clipped.clipBounds.height).toBe(0) + } + } + }) + + it("does not return the most recently painted renderer's geometry to another renderer", () => { + const ref = createRef() + root.render(
) + const old = root + const id = ref.current!.id + expect(old.renderer.getElementPaintState(id)).not.toBeNull() + old.unmount() + root = createTestRoot() + root.render(
) + expect(old.renderer.getElementPaintState(id)).toBeNull() + expect(() => old.renderer.getNativeWindowHandle()).toThrow() + }) + + it("drops virtualized-away rows after scrolling and painting", () => { + const list = createRef() + const first = createRef() + const last = createRef() + root.render( + + {Array.from({ length: 100 }, (_, i) => ( +
+ ))} + , + ) + expect(root.renderer.getElementPaintState(first.current!.id)).not.toBeNull() + expect(root.renderer.getElementPaintState(last.current!.id)).toBeNull() + root.renderer.scrollToItem(list.current!.id, 99) + root.renderer.flush() + expect(root.renderer.getElementPaintState(first.current!.id)).toBeNull() + expect(root.renderer.getElementPaintState(last.current!.id)).not.toBeNull() + }) + + it("rejects invalid ids and returns null for an unrecorded id", () => { + for (const id of [-1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => root.renderer.getElementPaintState(id)).toThrow() + } + expect(root.renderer.getElementPaintState(9999)).toBeNull() + }) +}) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e3900886..d0052e30 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -108,6 +108,10 @@ export type { EventModifiers, WindowOptions, WindowSize as NativeWindowSize, + NativeWindowHandle, + NativeWindowHandleKind, + ElementPaintState, + PaintBounds, } from "@gpuix/native" export { GpuixRenderer } from "@gpuix/native" diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index ef690429..5ea61fc0 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -12,7 +12,7 @@ import { createRequire } from "node:module" import type { ReactNode } from "react" -import type { EventPayload } from "@gpuix/native" +import type { ElementPaintState, EventPayload, NativeWindowHandle } from "@gpuix/native" import type { DebugFrameOverlayMode, DebugFrameOverlayStats, @@ -60,6 +60,8 @@ interface NativeTestRendererApi extends NativeRenderer { getAutomationTree(): string getRetainedElementCount(): number getElementBounds(elementId: number): number[] | null + getNativeWindowHandle(): NativeWindowHandle | null + getElementPaintState(elementId: number): ElementPaintState | null clockPause(): number clockSet(nowMs: number): number clockFastForward(deltaMs: number): number @@ -455,6 +457,14 @@ export class TestRenderer implements NativeRenderer { return this.native.getElementBounds(elementId) } + getNativeWindowHandle(): NativeWindowHandle | null { + return this.native.getNativeWindowHandle() + } + + getElementPaintState(elementId: number): ElementPaintState | null { + return this.native.getElementPaintState(elementId) + } + clockPause(): number { return this.native.clockPause() } diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index b7b3098f..9069c7d9 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -1,4 +1,4 @@ -import type { EventPayload } from "@gpuix/native" +import type { ElementPaintState, EventPayload, NativeWindowHandle } from "@gpuix/native" export type DimensionValue = number | string @@ -645,6 +645,11 @@ export interface NativeRenderer { // ── Window API ───────────────────────────────────────────────── getWindowSize?(): { width: number; height: number } getWindowInsets?(): NativeWindowInsets + /** Borrowed native identifiers, not retained resources. Desktop only. + * Read README Native integration snapshots before passing these bytes to FFI. */ + getNativeWindowHandle?(): NativeWindowHandle | null + /** Non-flushing last-paint geometry. Null is not an unmount notification. */ + getElementPaintState?(elementId: number): ElementPaintState | null setWindowTitle?(title: string): void /** Bring the window forward and focus it. Reveals a `show: false` window. */ activateWindow?(): void From 199da0083e381caec3b9048aa25d2fdbb102c2d9 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Wed, 9 Sep 2026 12:14:56 +0700 Subject: [PATCH 2/2] test: make native snapshot regressions portable to Windows Decode file URLs before importing subprocess fixtures and derive clipping positions from the native viewport rather than the requested window size. --- packages/react/src/__tests__/embedding.test.tsx | 11 +++++++---- packages/react/src/__tests__/events.test.tsx | 4 ++-- packages/react/src/__tests__/render.test.tsx | 5 ++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/react/src/__tests__/embedding.test.tsx b/packages/react/src/__tests__/embedding.test.tsx index da294e66..1b874434 100644 --- a/packages/react/src/__tests__/embedding.test.tsx +++ b/packages/react/src/__tests__/embedding.test.tsx @@ -82,15 +82,18 @@ describeNative("native integration snapshots", () => { it("clips to the viewport by default and never reports negative clip dimensions", () => { const ref = createRef() - root.render(
) + const { width, height } = root.renderer.getWindowSize() + const left = width - 20 + const top = height - 20 + root.render(
) const visible = root.renderer.getElementPaintState(ref.current!.id)! - expect(visible.bounds).toEqual({ x: 280, y: 180, width: 50, height: 40 }) - expect(visible.clipBounds).toEqual({ x: 280, y: 180, width: 20, height: 20 }) + expect(visible.bounds).toEqual({ x: left, y: top, width: 50, height: 40 }) + expect(visible.clipBounds).toEqual({ x: left, y: top, width: 20, height: 20 }) root.render(
) const negative = root.renderer.getElementPaintState(ref.current!.id)! expect(negative.bounds).toEqual({ x: -20, y: -10, width: 50, height: 40 }) expect(negative.clipBounds).toEqual({ x: 0, y: 0, width: 30, height: 30 }) - for (const position of [400, -100]) { + for (const position of [Math.max(width, height) + 100, -100]) { root.render(
) const clipped = root.renderer.getElementPaintState(ref.current!.id) // GPUI may skip paint altogether; a record is not proof of visibility. diff --git a/packages/react/src/__tests__/events.test.tsx b/packages/react/src/__tests__/events.test.tsx index 4ffe32a3..7a08a9cd 100644 --- a/packages/react/src/__tests__/events.test.tsx +++ b/packages/react/src/__tests__/events.test.tsx @@ -12,6 +12,7 @@ import fs from "fs" import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" import { describe, it, expect, beforeEach } from "vitest" import React, { useState, useRef } from "react" import { createTestRoot, hasNativeTestRenderer } from "../testing" @@ -173,8 +174,7 @@ describe("frame loop", () => { }) it("keeps the process alive after an uncaught exception", () => { - const rendererPath = new URL("../reconciler/renderer.ts", import.meta.url) - .pathname + const rendererPath = fileURLToPath(new URL("../reconciler/renderer.ts", import.meta.url)) const script = [ `import { installRuntimeErrorHandlers, startFrameLoop } from ${JSON.stringify(rendererPath)}`, "installRuntimeErrorHandlers()", diff --git a/packages/react/src/__tests__/render.test.tsx b/packages/react/src/__tests__/render.test.tsx index 5cc0a617..fd9c0992 100644 --- a/packages/react/src/__tests__/render.test.tsx +++ b/packages/react/src/__tests__/render.test.tsx @@ -183,9 +183,8 @@ describeNative("render()", () => { }) it("shows a process-level unhandled rejection on the overlay", () => { - const testingPath = new URL("../testing.ts", import.meta.url).pathname - const rendererPath = new URL("../reconciler/renderer.ts", import.meta.url) - .pathname + const testingPath = fileURLToPath(new URL("../testing.ts", import.meta.url)) + const rendererPath = fileURLToPath(new URL("../reconciler/renderer.ts", import.meta.url)) const script = [ 'import React from "react"', `import { TestRenderer } from ${JSON.stringify(testingPath)}`,