From 95fea428ba00562a5d354cd7ec782af74e6a06f5 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:14 +0200 Subject: [PATCH 1/9] fix(native): select text across wrapped rows --- .changeset/select-across-wrapped-rows.md | 11 +++++++++++ packages/native/src/text/paint.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/select-across-wrapped-rows.md diff --git a/.changeset/select-across-wrapped-rows.md b/.changeset/select-across-wrapped-rows.md new file mode 100644 index 00000000..e7c906ec --- /dev/null +++ b/.changeset/select-across-wrapped-rows.md @@ -0,0 +1,11 @@ +--- +"@gpuix/native": patch +--- + +Cover the first glyph of a wrapped row in the selection wash. + +The wash walks the visual rows of a paragraph with `position_for_index`. +The index at a soft-wrap boundary reports its position on the earlier row, +so each walk started one glyph into the next row and the wash missed that +glyph. A continuation row now stretches back to the leading edge of the +layout. diff --git a/packages/native/src/text/paint.rs b/packages/native/src/text/paint.rs index 8634d7c8..9c2cb3e4 100644 --- a/packages/native/src/text/paint.rs +++ b/packages/native/src/text/paint.rs @@ -562,11 +562,19 @@ pub fn range_rects( // Walk the range one visual row at a time: binary search for the furthest // index that still sits on the current row. let mut guard = 0; + let mut row_is_continuation = false; while cur < range.end && guard < 256 { guard += 1; - let Some(p1) = layout.position_for_index(cur) else { + let Some(mut p1) = layout.position_for_index(cur) else { break; }; + // A continuation row starts at the index AFTER the wrap boundary, + // because the boundary index reports its position on the earlier + // row. That index sits one glyph into the row, so the wash must + // stretch back to the row's leading edge or it misses that glyph. + if row_is_continuation { + p1.x = layout.bounds().origin.x; + } // `seg_end` closes the wash on this row; `next` is the first index on the // following row. They differ because a row-end index's position still // reports the earlier row, and we need strict progress. @@ -599,6 +607,7 @@ pub fn range_rects( break; } cur = next; + row_is_continuation = true; } rects } From adedd5e89f9b4cea4c9d6b93e0afa59bb3bd1102 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:15 +0200 Subject: [PATCH 2/9] fix(react): survive a hot remount of the root --- .changeset/survive-a-hot-remount.md | 13 +++++++++ .../react/src/reconciler/event-registry.ts | 27 +++++++++++++++---- packages/react/src/reconciler/renderer.ts | 6 ++++- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 .changeset/survive-a-hot-remount.md diff --git a/.changeset/survive-a-hot-remount.md b/.changeset/survive-a-hot-remount.md new file mode 100644 index 00000000..524cccb0 --- /dev/null +++ b/.changeset/survive-a-hot-remount.md @@ -0,0 +1,13 @@ +--- +"@gpuix/react": patch +--- + +Keep events alive across a `bun --hot` remount. + +The map from renderer to container lived in the module, and the native +event callback keeps the module instance that created it. A hot reload +evaluates the module again, so the new tree registered its handlers in a +new map while native events searched the old one, and every click died. +The map now lives on `globalThis`, so both module instances share it. The +`onEvent` option also follows the latest `render()` call instead of the +first one. diff --git a/packages/react/src/reconciler/event-registry.ts b/packages/react/src/reconciler/event-registry.ts index 90c9cd31..12c2ff1c 100644 --- a/packages/react/src/reconciler/event-registry.ts +++ b/packages/react/src/reconciler/event-registry.ts @@ -1,22 +1,39 @@ import type { EventPayload } from "@gpuix/native" import type { Container, EventHandlerMap, NativeRenderer } from "../types/host.js" -const containersByRenderer = new WeakMap() +/// The map from renderer to container lives on globalThis, not in this +/// module. Under `bun --hot` a reload evaluates this module again, but the +/// native renderer keeps the event callback from the first evaluation. +/// That old callback must find the container the new evaluation attached, +/// so both evaluations have to share one map. +const CONTAINERS_KEY = "__gpuixEventContainers" + +function containersByRenderer(): WeakMap { + const existing = Reflect.get(globalThis, CONTAINERS_KEY) as + | WeakMap + | undefined + if (existing) { + return existing + } + const created = new WeakMap() + Reflect.set(globalThis, CONTAINERS_KEY, created) + return created +} export function attachRoot(renderer: NativeRenderer, container: Container): void { - containersByRenderer.set(renderer, container) + containersByRenderer().set(renderer, container) } export function detachRoot(renderer: NativeRenderer): void { - containersByRenderer.delete(renderer) + containersByRenderer().delete(renderer) } export function containerForRenderer(renderer: NativeRenderer): Container | undefined { - return containersByRenderer.get(renderer) + return containersByRenderer().get(renderer) } export function handleGpuixEvent(payload: EventPayload, renderer: NativeRenderer): void { - const container = containersByRenderer.get(renderer) + const container = containersByRenderer().get(renderer) if (!container) return const elementHandlers = container.eventHandlers.get(payload.elementId) if (!elementHandlers) return diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index 4849696b..313aedab 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -117,6 +117,9 @@ type RenderSlot = { renderer?: NativeRenderer root?: Root loop?: FrameLoop + /// The `onEvent` of the latest `render()` call. The native callback closes + /// over the slot, not over the option, so a hot reload can swap it. + onEvent?: (event: EventPayload) => void } function renderSlot(): RenderSlot { @@ -148,11 +151,12 @@ export function render(node: ReactNode, options: RenderOptions = {}): Root { const { onEvent, renderer: injected, debugFrameOverlay, resolveClassName, ...windowOptions } = options const slot = renderSlot() const remount = slot.root != null + slot.onEvent = onEvent if (!slot.renderer) { if (injected) { slot.renderer = injected } else { - const renderer = createRenderer(onEvent) + const renderer = createRenderer((event) => slot.onEvent?.(event)) renderer.init(windowOptions) slot.renderer = renderer console.log("[gpuix] created native window") From 73c00b940da863ec7aa0d74c5cceaa148bc95c33 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:30 +0200 Subject: [PATCH 3/9] fix(native): drive the live scroll wheel from automation --- .changeset/live-scroll-wheel.md | 10 ++++++ packages/native/index.d.ts | 1 + packages/native/src/automation.rs | 25 +++++++++++++- packages/native/src/renderer.rs | 45 +++++++++++++++++++++++++ packages/react/src/automation/client.ts | 6 ++-- 5 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 .changeset/live-scroll-wheel.md diff --git a/.changeset/live-scroll-wheel.md b/.changeset/live-scroll-wheel.md new file mode 100644 index 00000000..c40e8e96 --- /dev/null +++ b/.changeset/live-scroll-wheel.md @@ -0,0 +1,10 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Make the automation `scrollWheel` method work on the live app. + +The live renderer threw "scrollWheel is not live yet". It now dispatches a +real `ScrollWheelEvent` with a pixel delta through the window, the same +path a physical wheel takes. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 55bdc712..26fb28b3 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -102,6 +102,7 @@ export declare class GpuixRenderer { simulateMouseDown(x: number, y: number, button?: number | undefined | null): void simulateMouseUp(x: number, y: number, button?: number | undefined | null): void simulateMouseMove(x: number, y: number, pressedButton?: number | undefined | null): void + simulateScrollWheel(x: number, y: number, deltaX: number, deltaY: number): void clockPause(): number clockSet(nowMs: number): number clockFastForward(deltaMs: number): number diff --git a/packages/native/src/automation.rs b/packages/native/src/automation.rs index 320084eb..2ff5475a 100644 --- a/packages/native/src/automation.rs +++ b/packages/native/src/automation.rs @@ -14,7 +14,8 @@ use std::time::{Duration, Instant}; use gpui::{ canvas, point, px, App, Bounds, InputEvent, IntoElement, Modifiers, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Styled, Window, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, ScrollDelta, ScrollWheelEvent, Styled, + TouchPhase, Window, }; #[derive(Clone, Copy, Debug)] @@ -79,6 +80,8 @@ pub fn bounds_tracker(id: u64, selection_start: Option) -> impl IntoElemen }, ) .absolute() + .top_0() + .left_0() .size_full() } @@ -250,6 +253,26 @@ pub fn dispatch_mouse_move( ); } +pub fn dispatch_scroll_wheel( + window: &mut Window, + cx: &mut App, + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, +) { + window.dispatch_event( + ScrollWheelEvent { + position: point(px(x as f32), px(y as f32)), + delta: ScrollDelta::Pixels(point(px(delta_x as f32), px(delta_y as f32))), + modifiers: Modifiers::default(), + touch_phase: TouchPhase::Moved, + } + .to_platform_input(), + cx, + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index af78ea53..3bedc1f1 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -198,6 +198,12 @@ enum MouseInput { y: f64, pressed_button: Option, }, + Scroll { + x: f64, + y: f64, + delta_x: f64, + delta_y: f64, + }, } #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] @@ -446,6 +452,16 @@ async fn run_ui_commands( } => { crate::automation::dispatch_mouse_move(window, cx, x, y, pressed_button); } + MouseInput::Scroll { + x, + y, + delta_x, + delta_y, + } => { + crate::automation::dispatch_scroll_wheel( + window, cx, x, y, delta_x, delta_y, + ); + } }); response .send( @@ -1522,6 +1538,35 @@ impl GpuixRenderer { } } + #[napi] + pub fn simulate_scroll_wheel(&self, x: f64, y: f64, delta_x: f64, delta_y: f64) -> Result<()> { + #[cfg(target_os = "macos")] + return update_window(move |_view, window, cx| { + crate::automation::dispatch_scroll_wheel(window, cx, x, y, delta_x, delta_y); + }); + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.dispatch_mouse_input(MouseInput::Scroll { + x, + y, + delta_x, + delta_y, + }); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + { + let _ = (x, y, delta_x, delta_y); + Err(Error::from_reason( + "The production GPUIX renderer does not support this operating system", + )) + } + } + #[napi] pub fn clock_pause(&self) -> Result { #[cfg(target_os = "macos")] diff --git a/packages/react/src/automation/client.ts b/packages/react/src/automation/client.ts index 3020bef4..74fc39a1 100644 --- a/packages/react/src/automation/client.ts +++ b/packages/react/src/automation/client.ts @@ -505,6 +505,7 @@ export interface LiveAutomationRenderer { simulateMouseDown(x: number, y: number, button?: number): void simulateMouseUp(x: number, y: number, button?: number): void simulateMouseMove(x: number, y: number, pressedButton?: number): void + simulateScrollWheel(x: number, y: number, deltaX: number, deltaY: number): void tick?(): void focusElement(elementId: number): void blur(): void @@ -546,8 +547,9 @@ export function liveRendererAsTest( renderer.simulateMouseMove(x, y, pressedButton) afterInput() }, - nativeSimulateScrollWheel() { - throw new AutomationError("Unsupported", "scrollWheel is not live yet") + nativeSimulateScrollWheel(x, y, deltaX, deltaY) { + renderer.simulateScrollWheel(x, y, deltaX, deltaY) + afterInput() }, simulateKeystrokes() { throw new AutomationError("Unsupported", "keystrokes are not live yet") From 1b99a30f59fd348b7abbb62cbd224fd742bdec67 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:53 +0200 Subject: [PATCH 4/9] fix(native): sync env overrides into the addon under bun --- .changeset/sync-env-overrides-under-bun.md | 12 +++++++ packages/native/index.d.ts | 10 ++++++ packages/native/index.js | 1 + packages/native/src/renderer.rs | 14 ++++++++ packages/react/src/testing.ts | 38 ++++++++++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 .changeset/sync-env-overrides-under-bun.md diff --git a/.changeset/sync-env-overrides-under-bun.md b/.changeset/sync-env-overrides-under-bun.md new file mode 100644 index 00000000..3719e8bf --- /dev/null +++ b/.changeset/sync-env-overrides-under-bun.md @@ -0,0 +1,12 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Push `process.env` overrides through to the Rust side under Bun. + +Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`. Node +writes a `process.env` assignment through to `setenv`, but Bun only updates +its JS snapshot, so a test that set the variable after start had no effect +under `bun test`. The native module now exports `syncEnvVar`, and the test +renderer copies the known overrides across before every frame flush. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 26fb28b3..89d953a4 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -443,6 +443,16 @@ export interface EventPayload { modifiers?: EventModifiers } +/** + * Copies one `process.env` entry into the real process environment. + * + * Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, + * which reads the C environment. Node writes a `process.env` assignment + * through to `setenv`, but Bun only updates its JS snapshot. A caller on + * Bun must push the value across with this function. + */ +export declare function syncEnvVar(key: string, value?: string | undefined | null): void + export interface WindowOptions { title?: string width?: number diff --git a/packages/native/index.js b/packages/native/index.js index 1f34b865..e6301a22 100644 --- a/packages/native/index.js +++ b/packages/native/index.js @@ -578,3 +578,4 @@ if (!nativeBinding) { module.exports = nativeBinding module.exports.GpuixRenderer = nativeBinding.GpuixRenderer module.exports.TestGpuixRenderer = nativeBinding.TestGpuixRenderer +module.exports.syncEnvVar = nativeBinding.syncEnvVar diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 3bedc1f1..c00193a7 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -491,6 +491,20 @@ fn panic_message(payload: Box) -> String { .unwrap_or_else(|| "unknown panic".to_string()) } +/// Copies one `process.env` entry into the real process environment. +/// +/// Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, +/// which reads the C environment. Node writes a `process.env` assignment +/// through to `setenv`, but Bun only updates its JS snapshot. A caller on +/// Bun must push the value across with this function. +#[napi] +pub fn sync_env_var(key: String, value: Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } +} + /// The main GPUI renderer exposed to Node.js. #[napi] pub struct GpuixRenderer { diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index a0a854f6..5aecf732 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -45,6 +45,7 @@ interface NativeTestRendererApi extends NativeRenderer { getAllText(): string[] scrollTo(elementId: number, x: number, y: number): void scrollToItem(elementId: number, index: number): void + scrollIntoView(elementId: number, block?: string, inline?: string): void getScrollOffset(elementId: number): number[] | null setDebugFrameOverlay(mode: DebugFrameOverlayMode): string getDebugFrameOverlay(): string @@ -84,6 +85,31 @@ try { /** Whether the native TestGpuixRenderer is available (for conditional test registration). */ export const hasNativeTestRenderer = NativeTestRenderer != null +/// The env overrides the Rust side reads with `std::env::var`. +const NATIVE_ENV_OVERRIDES = ["GPUIX_SCROLLBARS"] as const + +/** + * Copies the env overrides from `process.env` into the real environment. + * + * Node writes a `process.env` assignment through to `setenv`, but Bun only + * updates its JS snapshot, so under `bun test` the Rust side cannot see a + * `process.env.GPUIX_SCROLLBARS = "classic"` from a test. This runs before + * every frame flush to push the current values across. + */ +function syncEnvOverrides(): void { + let syncEnvVar: ((key: string, value?: string) => void) | undefined + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + syncEnvVar = (require("@gpuix/native") as { syncEnvVar?: typeof syncEnvVar }).syncEnvVar + } catch { + return + } + if (!syncEnvVar) return + for (const key of NATIVE_ENV_OVERRIDES) { + syncEnvVar(key, process.env[key]) + } +} + export const MAC_CPU_THROTTLES = ["utility", "background", "maintenance"] as const export type MacCpuThrottle = (typeof MAC_CPU_THROTTLES)[number] @@ -213,6 +239,7 @@ export class TestRenderer implements NativeRenderer { /** Trigger the real GPUI rendering pipeline (GpuixView::render() → * build_element() → apply_styles() → layout). */ flush(): void { + syncEnvOverrides() this.native.flush() } @@ -468,6 +495,17 @@ export class TestRenderer implements NativeRenderer { this.native.flush() } + /** Scroll every ancestor scroll box so the element shows, like the web + * scrollIntoView. block places it on the y axis and inline on the x + * axis: "start", "center", "end" or "nearest". The defaults match the + * web: "start" and "nearest". scrollMargin on the element and + * scrollPadding on a box apply. */ + scrollIntoView(elementId: number, block?: string, inline?: string): void { + this.native.flush() + this.native.scrollIntoView(elementId, block, inline) + this.native.flush() + } + /** Get the current scroll offset [x, y] or null if element is not scrollable. */ getScrollOffset(elementId: number): [number, number] | null { this.native.flush() From 994901996c5eb4cc4561356c8cb78d32fcdd8027 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:26:53 +0200 Subject: [PATCH 5/9] fix(react): run the automation stdio test under the bun runner --- packages/react/src/__tests__/automation-stdio.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/react/src/__tests__/automation-stdio.test.ts b/packages/react/src/__tests__/automation-stdio.test.ts index 5ea46ad7..634269ac 100644 --- a/packages/react/src/__tests__/automation-stdio.test.ts +++ b/packages/react/src/__tests__/automation-stdio.test.ts @@ -98,11 +98,16 @@ describe("automation stdio", () => { closes += 1 } ) - const pending = backend.call("blur", {}) - const rejection = expect(pending).rejects.toMatchObject({ code: "Closed" }) + // Convert the rejection into a value before the assertion. Bun's test + // runner stalls on a `rejects` matcher that it gets while the promise + // is still pending. + const pending = backend.call("blur", {}).then( + () => undefined, + (error: unknown) => error + ) await backend.close() - await rejection + await expect(pending).resolves.toMatchObject({ code: "Closed" }) await backend.close() expect(closes).toBe(1) From efd062361c796098fce030aa83806d95e6399d94 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:27:07 +0200 Subject: [PATCH 6/9] chore(native): tidy comments and docs after the css value work --- AGENTS.md | 80 +++++++++++++-------------- packages/native/src/color.rs | 22 +++++++- packages/native/src/motion.rs | 28 +++++++--- packages/native/src/renderer.rs | 7 +-- packages/native/src/renderer/frame.rs | 29 +++++++--- packages/native/src/style.rs | 22 ++++++-- packages/native/src/style/resolve.rs | 14 ++++- packages/native/src/style/vars.rs | 47 +++++++++++----- 8 files changed, 161 insertions(+), 88 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42ba5e22..07a8ca5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ -# AGENTS.md - GPUIX Codebase Guide +# AGENTS.md, the GPUIX codebase guide **Read [README.md](./README.md) first** to understand what GPUIX is, the architecture, mutation API, event flow, supported elements/events/styles, and the test renderer. -## Project Goal +## Project goal GPUIX enables building **native GPU-accelerated desktop applications** using **React and TypeScript**, powered by [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui) (Zed's rendering framework). @@ -13,7 +13,7 @@ React (TypeScript) → napi-rs → GPUI (Rust) → GPU Your code Bridge Native render Metal/Vulkan ``` -## Architecture Overview +## Architecture overview ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -66,9 +66,9 @@ React (TypeScript) → napi-rs → GPUI (Rust) → GPU └─────────────────────────────────────────────────────────────────┘ ``` -## Key Insight: Immediate Mode Alignment +## Key insight: immediate mode alignment -GPUI is **immediate-mode** - it rebuilds the entire UI tree every frame. This actually aligns perfectly with React's model: +GPUI is **immediate-mode**. It rebuilds the entire UI tree every frame. This actually aligns perfectly with React's model: | Traditional DOM Renderer | GPUIX | |--------------------------|-------| @@ -76,9 +76,9 @@ GPUI is **immediate-mode** - it rebuilds the entire UI tree every frame. This ac | `node.style.color = x` | Send full tree description | | Mutation-based | Description-based | -We don't fight GPUI's architecture - we embrace it by sending a complete element description on every React render. +We don't fight GPUI's architecture. We embrace it by sending a complete element description on every React render. -## Package Structure +## Package structure ``` gpuix/ @@ -125,9 +125,9 @@ gpuix/ Every string GPUIX paints goes through `crate::text`: -- `selectable_text(..)` for content — registers into the per-frame selection +- `selectable_text(..)` for content. It registers into the per-frame selection registry and installs the window mouse and key listeners -- `chrome_text(..)` for line numbers, language tags and file headers — painted +- `chrome_text(..)` for line numbers, language tags and file headers. It is painted and logged for tests, but never part of a selection **Never call `div().child(some_string)` in a new element.** Doing so makes the @@ -386,13 +386,13 @@ shaped that way. ## Auto-generated files (do NOT edit manually) -The following files in `packages/native/` are auto-generated by napi-rs during `bun run build`. Never edit them by hand — they are regenerated from the Rust `#[napi]` annotations every build: +The following files in `packages/native/` are auto-generated by napi-rs during `bun run build`. Never edit them by hand. They are regenerated from the Rust `#[napi]` annotations every build: -- `packages/native/index.d.ts` — TypeScript type declarations -- `packages/native/index.js` — Node.js loader/binding glue -- `packages/native/*.node` — compiled native binary +- `packages/native/index.d.ts`, the TypeScript type declarations +- `packages/native/index.js`, the Node.js loader/binding glue +- `packages/native/*.node`, the compiled native binary -To update the TypeScript API surface, edit the Rust source files in `packages/native/src/` (add/modify `#[napi]` structs, methods, functions), then run `bun run build` in `packages/native` to regenerate. +To update the exposed TypeScript calls, edit the Rust source files in `packages/native/src/` (add/modify `#[napi]` structs, methods, functions), then run `bun run build` in `packages/native` to regenerate. ## Commit messages @@ -449,9 +449,9 @@ A local `npm publish` / `bun publish` would ship only the host binary and break To release: bump versions via changesets, push to `main`. The publish job skips versions already on npm. -## Communication Flow +## Communication flow -### Render Flow (JS → Rust) +### Render flow (JS → Rust) ``` 1. React state changes @@ -477,7 +477,7 @@ To release: bump versions via changesets, push to `main`. The publish job skips 7. GPUI renders to GPU ``` -### Event Flow (Rust → JS) +### Event flow (Rust → JS) ``` 1. User clicks element with id="btn-1" @@ -496,7 +496,7 @@ To release: bump versions via changesets, push to `main`. The publish job skips 7. State update triggers re-render → back to Render Flow ``` -## Key Types +## Key types ### ElementDesc (Rust ↔ JS) @@ -557,7 +557,7 @@ pub struct EventPayload { ## Building -### Standalone Build +### Standalone build The `zed/` submodule tracks the `gpui-macos-embedded` branch of `remorses/zed`. Cargo uses path dependencies from that submodule so the native addon and native platforms always @@ -650,7 +650,7 @@ it. Do not add this block to Zed PRs. Skip system reminders, tool output, and your own replies. If a prompt is huge, keep the full text inside the details block; do not summarize it. -## Current Status +## Current status Keep this list in sync with the README **Status** section. User-facing APIs belong in README. This list is only the remaining engineering work. @@ -676,28 +676,28 @@ belong in README. This list is only the remaining engineering work. ### TODO -#### High Priority +#### High priority -- [ ] **Background highlighting** - move Tree-sitter off the frame thread once +- [ ] **Background highlighting.** Move Tree-sitter off the frame thread once there is a way to request a repaint from a background task -#### Medium Priority +#### Medium priority -- [ ] **Canvas** - custom drawing element (`` is typed, not implemented) +- [ ] **Canvas.** Custom drawing element (`` is typed, not implemented) -#### Low Priority +#### Low priority -- [ ] **Window controls** - resize, minimize (title already works) -- [ ] **Multiple windows** - Support multiple GPUI windows -- [x] **JS remount** - `render()` plus `bun --hot` remounts the React tree on the same window -- [ ] **React Refresh** - keep `useState` across saves. Needs Bun to run the Fast Refresh transform during `bun --hot` -- [ ] **Native hot reload** - cannot unload a `.node`. `bun run dev` rebuilds and restarts -- [ ] **DevTools** - React DevTools integration -- [ ] **Animations** - Interpolated style transitions +- [ ] **Window controls.** Resize, minimize (title already works) +- [ ] **Multiple windows.** Support multiple GPUI windows +- [x] **JS remount.** `render()` plus `bun --hot` remounts the React tree on the same window +- [ ] **React Refresh.** Keep `useState` across saves. Needs Bun to run the Fast Refresh transform during `bun --hot` +- [ ] **Native hot reload.** Cannot unload a `.node`. `bun run dev` rebuilds and restarts +- [ ] **DevTools.** React DevTools integration +- [ ] **Animations.** Interpolated style transitions ## Testing -### Unit Tests +### Unit tests ```bash # Rust unit tests (selection, syntax, diff parser, markdown parser, theme) @@ -744,7 +744,7 @@ step silently selects nothing. Screenshots go to `packages/react/screenshots/` (gitignored), not `/tmp`, so they can be inspected after a run. -### Integration Test +### Integration test ```bash cd examples && bun --hot chat.tsx @@ -782,12 +782,12 @@ await app.close() freeze native motion. `captureFrames` writes one PNG per timestamp. That is how you record a sidebar open/close, not a screen recorder. -## Related Projects +## Related projects -- [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui) - Zed's GPU UI framework -- [opentui](https://github.com/anomalyco/opentui) - Terminal UI with React (reconciler reference) -- [create-gpui-app](https://github.com/zed-industries/create-gpui-app) - Official GPUI starter template -- [react-reconciler](https://github.com/facebook/react/tree/main/packages/react-reconciler) - React's custom renderer API +- [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), Zed's GPU UI framework +- [opentui](https://github.com/anomalyco/opentui), a terminal UI with React (reconciler reference) +- [create-gpui-app](https://github.com/zed-industries/create-gpui-app), the official GPUI starter template +- [react-reconciler](https://github.com/facebook/react/tree/main/packages/react-reconciler), React's custom renderer API ## Contributing @@ -802,6 +802,6 @@ For example usage of projects depending on gpui in rust: opensrc https://github. For examples of NAPI rs native packages: https://github.com/napi-rs/package-template and https://github.com/Brooooooklyn/Image -For reading gpui source code: https://github.com/zed-industries/sed inside crates/gpui +For reading gpui source code: https://github.com/zed-industries/zed inside crates/gpui For examples of a custom React renderer: https://github.com/anomalyco/opentui inside packages/react diff --git a/packages/native/src/color.rs b/packages/native/src/color.rs index 0ff1d0f5..f580f7da 100644 --- a/packages/native/src/color.rs +++ b/packages/native/src/color.rs @@ -9,7 +9,12 @@ use gpuix_css::color::{ColorContext, Rgba}; /// Turn engine channels into GPUI's sRGB paint type. pub(crate) fn to_gpui(color: Rgba) -> gpui::Rgba { - gpui::Rgba { r: color.r, g: color.g, b: color.b, a: color.a } + gpui::Rgba { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + } } /// Turn a GPUI colour into engine channels. @@ -18,7 +23,12 @@ pub(crate) fn to_gpui(color: Rgba) -> gpui::Rgba { /// this way to reach the cascade. pub(crate) fn from_gpui(color: impl Into) -> Rgba { let color = color.into(); - Rgba { r: color.r, g: color.g, b: color.b, a: color.a } + Rgba { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + } } /// Turn engine channels into GPUI's HSL paint type. @@ -52,6 +62,7 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro color: to_hsla(stop.color), percentage: stop.position, hint: stop.hint, + easing: stop.easing, }) .collect(); gpui::linear_gradient_stops(line, &stops) @@ -216,7 +227,12 @@ mod tests { #[test] fn reads_current_color_from_the_context() { let context = ColorContext { - current_color: Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + current_color: Rgba { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, dark: false, }; assert_eq!( diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index c1c3fbb2..4e8fface 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -433,9 +433,9 @@ impl MotionState { (Some(from), Some(target)) if raw < 1.0 => Some((from, target)), _ => None, }; - if let Some((from, target)) = ends.filter(|(from, target)| { - from.needs_content() || target.needs_content() - }) { + if let Some((from, target)) = + ends.filter(|(from, target)| from.needs_content() || target.needs_content()) + { let visible = from.mix(target, progress).resolve(old); let end = target.resolve(new); // The pixels a start needs so that mixing it toward `end` at @@ -623,7 +623,7 @@ mod tests { "animate": { "cornerShape": "square" }, "transition": { "duration": 1.0, "ease": "linear" } }); - let mut state = MotionState::new(&spec, started).unwrap(); + let state = MotionState::new(&spec, started).unwrap(); let frame = state.frame(started + Duration::from_millis(500)); let mut style = StyleDesc::default(); frame.style.apply_to(&mut style); @@ -706,8 +706,14 @@ mod tests { let state = MotionState::new(&description, started).unwrap(); assert_eq!(at(state.frame(started)), Some(0.0)); - assert_eq!(at(state.frame(started + Duration::from_millis(500))), Some(100.0)); - assert_eq!(at(state.frame(started + Duration::from_secs(1))), Some(200.0)); + assert_eq!( + at(state.frame(started + Duration::from_millis(500))), + Some(100.0) + ); + assert_eq!( + at(state.frame(started + Duration::from_secs(1))), + Some(200.0) + ); } #[test] @@ -729,7 +735,10 @@ mod tests { state.sync(&closing, settled).unwrap(); assert_eq!(at(state.frame(settled)), Some(200.0)); - assert_eq!(at(state.frame(settled + Duration::from_millis(500))), Some(100.0)); + assert_eq!( + at(state.frame(settled + Duration::from_millis(500))), + Some(100.0) + ); assert_eq!(at(state.frame(settled + Duration::from_secs(1))), Some(0.0)); } @@ -753,7 +762,10 @@ mod tests { // Half open when it turned, so the collapse starts at half. assert_eq!(at(state.frame(turned)), Some(100.0)); - assert_eq!(at(state.frame(turned + Duration::from_millis(500))), Some(50.0)); + assert_eq!( + at(state.frame(turned + Duration::from_millis(500))), + Some(50.0) + ); } #[test] diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index c00193a7..82f63f1f 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -425,9 +425,7 @@ async fn run_ui_commands( let now_ms = match control { ClockControl::Pause => view.clock.pause(), ClockControl::Set(now_ms) => view.clock.set_ms(now_ms), - ClockControl::FastForward(delta_ms) => { - view.clock.fast_forward_ms(delta_ms) - } + ClockControl::FastForward(delta_ms) => view.clock.fast_forward_ms(delta_ms), ClockControl::Resume => view.clock.resume(), }; cx.notify(); @@ -1942,7 +1940,6 @@ impl GpuixView { } } - impl GpuixView { /// Sync focus handles with the current element tree. /// Creates handles for new focusable elements, subscribes on_focus/on_blur, @@ -2132,7 +2129,6 @@ impl gpui::Render for GpuixView { } } - // ── Event emission ─────────────────────────────────────────────────── /// Helper to convert a GPUI Point to (f64, f64). @@ -2171,7 +2167,6 @@ pub(crate) fn emit_event_full( } } - // ── Types ──────────────────────────────────────────────────────────── #[derive(Debug, Clone)] diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 966a53ba..4f2b05ac 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -94,11 +94,27 @@ pub(super) fn build_element( let built = match element.element_type.as_str() { "div" => { ctx.custom_registry.destroy(id); - build_div(element, style, resolved.clone(), motion.as_ref(), ctx, window, cx) + build_div( + element, + style, + resolved.clone(), + motion.as_ref(), + ctx, + window, + cx, + ) } "text" => { ctx.custom_registry.destroy(id); - build_text(element, style, resolved.clone(), motion.as_ref(), ctx, window, cx) + build_text( + element, + style, + resolved.clone(), + motion.as_ref(), + ctx, + window, + cx, + ) } "virtual-list" => { ctx.custom_registry.destroy(id); @@ -136,13 +152,8 @@ pub(super) fn build_element( selection_wash: crate::color::to_hsla(cascade.selection_wash()), cascade: cascade.clone(), }; - ctx.custom_registry.render( - custom_type, - &element.custom_props, - render_ctx, - window, - cx, - ) + ctx.custom_registry + .render(custom_type, &element.custom_props, render_ctx, window, cx) } }; diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index efc54553..ca42dedd 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -610,7 +610,10 @@ mod tests { let written = serde_json::to_value(StyleDesc::default()).unwrap(); let written = written.as_object().unwrap(); for name in written.keys() { - assert!(FIELDS.contains(&name.as_str()), "`{name}` is written but never read"); + assert!( + FIELDS.contains(&name.as_str()), + "`{name}` is written but never read" + ); } assert_eq!(written.len(), FIELDS.len()); } @@ -633,7 +636,10 @@ mod tests { assert_eq!(style.gap, Some(Numeric::Text("var(--gap)".to_owned()))); assert_eq!(style.width, Some(Numeric::Text("100%".to_owned()))); assert_eq!(style.height, Some(Numeric::Text("auto".to_owned()))); - assert_eq!(style.font_weight, Some(FontWeightValue::Str("bold".to_owned()))); + assert_eq!( + style.font_weight, + Some(FontWeightValue::Str("bold".to_owned())) + ); assert_eq!(style.line_clamp, None); assert_eq!(style.hover.unwrap().color.as_deref(), Some("red")); } @@ -659,12 +665,16 @@ mod tests { let style: StyleDesc = serde_json::from_str(r#"{ "gap": 4, "gap": 8, "--pad": 1, "--pad": 2 }"#).unwrap(); assert_eq!(style.gap, Some(Numeric::Number(8.0))); - assert_eq!(declared_variables(&style), vec![("--pad".to_owned(), "2".to_owned())]); + assert_eq!( + declared_variables(&style), + vec![("--pad".to_owned(), "2".to_owned())] + ); } #[test] fn the_boxed_read_and_the_ordinary_read_agree() { - let json = r#"{ "gap": 8, "color": "red", "--pad": "4px", "hover": { "gap": 2 }, "nope": 1 }"#; + let json = + r#"{ "gap": 8, "color": "red", "--pad": "4px", "hover": { "gap": 2 }, "nope": 1 }"#; assert_eq!( *StyleDesc::from_json_boxed(json).unwrap(), serde_json::from_str::(json).unwrap() @@ -683,7 +693,9 @@ mod tests { font_size: Some(Numeric::Number(14.0)), max_width: Some(Numeric::Number(320.0)), user_select: Some("none".to_owned()), - custom: [("--pad".to_owned(), serde_json::json!("8px"))].into_iter().collect(), + custom: [("--pad".to_owned(), serde_json::json!("8px"))] + .into_iter() + .collect(), hover: Some(Box::new(StyleDesc { background_color: Some("#fff".to_owned()), ..Default::default() diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index 70a7cb75..7021b82b 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -854,7 +854,9 @@ mod tests { let scope = variables(&[("--brand", "#ff0000")]); let resolved = Resolved::build(&style, &scope); assert_eq!( - resolved.state(State::Hover).and_then(|h| h.background.clone()), + resolved + .state(State::Hover) + .and_then(|h| h.background.clone()), fill("#ff0000") ); } @@ -940,7 +942,10 @@ mod tests { line_height: Some(crate::style::Numeric::Text(text.to_string())), ..Default::default() }; - Resolved::build(&style, &no_variables()).base.text.line_height + Resolved::build(&style, &no_variables()) + .base + .text + .line_height } #[test] @@ -953,7 +958,10 @@ mod tests { ..Default::default() }; assert_eq!( - Resolved::build(&numeric, &no_variables()).base.text.line_height, + Resolved::build(&numeric, &no_variables()) + .base + .text + .line_height, Some(gpui::relative(1.5)) ); } diff --git a/packages/native/src/style/vars.rs b/packages/native/src/style/vars.rs index a9fe10d7..daaddbd9 100644 --- a/packages/native/src/style/vars.rs +++ b/packages/native/src/style/vars.rs @@ -46,12 +46,7 @@ pub(crate) struct Scope<'a> { } impl<'a> Scope<'a> { - pub fn new( - variables: &'a Variables, - current_color: Rgba, - dark: bool, - rem_size: f32, - ) -> Self { + pub fn new(variables: &'a Variables, current_color: Rgba, dark: bool, rem_size: f32) -> Self { Self { variables, current_color, @@ -541,7 +536,10 @@ mod tests { fn a_bare_number_needs_no_resolving() { let variables = scope_of(&[]); let scope = Scope::new(&variables, Rgba::BLACK, false, 16.0); - assert_eq!(scope.number(&Some(crate::style::Numeric::Number(8.0))), Some(8.0)); + assert_eq!( + scope.number(&Some(crate::style::Numeric::Number(8.0))), + Some(8.0) + ); assert!(!scope.used_a_variable()); } @@ -612,10 +610,22 @@ mod tests { use crate::style::{DimensionValue, Numeric}; let text = |t: &str| Some(Numeric::Text(t.to_string())); - assert_eq!(dimension(Some(Numeric::Number(200.0)), &[]), Some(DimensionValue::Pixels(200.0))); - assert_eq!(dimension(text("200px"), &[]), Some(DimensionValue::Pixels(200.0))); - assert_eq!(dimension(text("6rem"), &[]), Some(DimensionValue::Pixels(96.0))); - assert_eq!(dimension(text("calc(100px + 2rem)"), &[]), Some(DimensionValue::Pixels(132.0))); + assert_eq!( + dimension(Some(Numeric::Number(200.0)), &[]), + Some(DimensionValue::Pixels(200.0)) + ); + assert_eq!( + dimension(text("200px"), &[]), + Some(DimensionValue::Pixels(200.0)) + ); + assert_eq!( + dimension(text("6rem"), &[]), + Some(DimensionValue::Pixels(96.0)) + ); + assert_eq!( + dimension(text("calc(100px + 2rem)"), &[]), + Some(DimensionValue::Pixels(132.0)) + ); assert_eq!( dimension(text("calc(var(--spacing) * 30)"), &[("--spacing", "4px")]), Some(DimensionValue::Pixels(120.0)) @@ -627,10 +637,16 @@ mod tests { use crate::style::{DimensionValue, Numeric}; let text = |t: &str| Some(Numeric::Text(t.to_string())); - assert_eq!(dimension(text("50%"), &[]), Some(DimensionValue::Percentage(0.5))); + assert_eq!( + dimension(text("50%"), &[]), + Some(DimensionValue::Percentage(0.5)) + ); assert_eq!(dimension(text("auto"), &[]), Some(DimensionValue::Auto)); assert_eq!(dimension(text("AUTO"), &[]), Some(DimensionValue::Auto)); - assert_eq!(dimension(text("var(--w)"), &[("--w", "auto")]), Some(DimensionValue::Auto)); + assert_eq!( + dimension(text("var(--w)"), &[("--w", "auto")]), + Some(DimensionValue::Auto) + ); } #[test] @@ -649,6 +665,9 @@ mod tests { #[test] fn an_absent_declaration_stays_absent() { let variables = scope_of(&[]); - assert_eq!(Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None), None); + assert_eq!( + Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None), + None + ); } } From 192bf13111b20f026d2ddebe6750acc9b306ff0a Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 20:03:21 +0200 Subject: [PATCH 7/9] build(native): speed up png encoding in debug builds --- packages/native/Cargo.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index ce205fb3..9d3061e4 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -69,6 +69,24 @@ path = "examples/hello.rs" [profile.release] lto = true +# capture_screenshot encodes a png on every visual test. The encoder is +# too slow at opt-level 0 and vitest kills the worker, so build the +# image crates with opt-level 3 in debug builds too. +[profile.dev.package.png] +opt-level = 3 + +[profile.dev.package.image] +opt-level = 3 + +[profile.dev.package.fdeflate] +opt-level = 3 + +[profile.dev.package.flate2] +opt-level = 3 + +[profile.dev.package.miniz_oxide] +opt-level = 3 + # The style parse benchmark. `harness = false` because it prints its own # numbers rather than running as a test. [[bench]] From e699229936bad77b9e85ed068da39352e312a481 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:11:09 +0200 Subject: [PATCH 8/9] fix(native): keep env overrides in a map instead of setenv --- .changeset/sync-env-overrides-under-bun.md | 12 ++++--- packages/native/index.d.ts | 10 +++--- packages/native/src/renderer.rs | 33 ++++++++++++++----- packages/native/src/renderer/scrollbar.rs | 6 ++-- .../react/src/reconciler/event-registry.ts | 10 +++--- packages/react/src/testing.ts | 23 ++++++++----- 6 files changed, 58 insertions(+), 36 deletions(-) diff --git a/.changeset/sync-env-overrides-under-bun.md b/.changeset/sync-env-overrides-under-bun.md index 3719e8bf..c1eaf3ad 100644 --- a/.changeset/sync-env-overrides-under-bun.md +++ b/.changeset/sync-env-overrides-under-bun.md @@ -5,8 +5,10 @@ Push `process.env` overrides through to the Rust side under Bun. -Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`. Node -writes a `process.env` assignment through to `setenv`, but Bun only updates -its JS snapshot, so a test that set the variable after start had no effect -under `bun test`. The native module now exports `syncEnvVar`, and the test -renderer copies the known overrides across before every frame flush. +Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a +`process.env` assignment through to `setenv`, but Bun only updates its JS +snapshot, so a test that set the variable after start had no effect under +`bun test`. The native module now exports `syncEnvVar`, and the test +renderer copies the known overrides across before every frame flush. The +values land in an override map, not in the real environment, because +`setenv` races `getenv` on the dedicated UI thread of Windows and Linux. diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 788f43bd..860bb16b 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -520,12 +520,12 @@ export interface HighlightRect { } /** - * Copies one `process.env` entry into the real process environment. + * Records one `process.env` entry for `env_var` readers. * - * Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, - * which reads the C environment. Node writes a `process.env` assignment - * through to `setenv`, but Bun only updates its JS snapshot. A caller on - * Bun must push the value across with this function. + * Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a + * `process.env` assignment through to `setenv`, but Bun only updates its + * JS snapshot. A caller on Bun must push the value across with this + * function. */ export declare function syncEnvVar(key: string, value?: string | undefined | null): void diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 50d75d1c..6bafaf23 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -601,18 +601,33 @@ fn panic_message(payload: Box) -> String { .unwrap_or_else(|| "unknown panic".to_string()) } -/// Copies one `process.env` entry into the real process environment. +/// JS-side values for environment overrides such as `GPUIX_SCROLLBARS`. /// -/// Rust reads overrides such as `GPUIX_SCROLLBARS` with `std::env::var`, -/// which reads the C environment. Node writes a `process.env` assignment -/// through to `setenv`, but Bun only updates its JS snapshot. A caller on -/// Bun must push the value across with this function. +/// A map instead of `std::env::set_var`, because `setenv` races `getenv` +/// on another thread, and Windows and Linux paint on a dedicated UI thread. +fn env_overrides() -> &'static std::sync::Mutex>> { + static OVERRIDES: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + OVERRIDES.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// Reads an override, or the real process environment when JS never set one. +pub(crate) fn env_var(key: &str) -> Option { + if let Some(value) = env_overrides().lock().unwrap().get(key) { + return value.clone(); + } + std::env::var(key).ok() +} + +/// Records one `process.env` entry for `env_var` readers. +/// +/// Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a +/// `process.env` assignment through to `setenv`, but Bun only updates its +/// JS snapshot. A caller on Bun must push the value across with this +/// function. #[napi] pub fn sync_env_var(key: String, value: Option) { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } + env_overrides().lock().unwrap().insert(key, value); } /// The main GPUI renderer exposed to Node.js. diff --git a/packages/native/src/renderer/scrollbar.rs b/packages/native/src/renderer/scrollbar.rs index 00dfdc84..7f2be60a 100644 --- a/packages/native/src/renderer/scrollbar.rs +++ b/packages/native/src/renderer/scrollbar.rs @@ -41,9 +41,9 @@ pub(crate) enum Mode { impl Mode { /// The mode for this window, with the environment override on top. pub(crate) fn current(cx: &App) -> Self { - match std::env::var("GPUIX_SCROLLBARS").as_deref() { - Ok("overlay") => Mode::Overlay, - Ok("classic") => Mode::Classic, + match crate::renderer::env_var("GPUIX_SCROLLBARS").as_deref() { + Some("overlay") => Mode::Overlay, + Some("classic") => Mode::Classic, _ if cx.should_auto_hide_scrollbars() => Mode::Overlay, _ => Mode::Classic, } diff --git a/packages/react/src/reconciler/event-registry.ts b/packages/react/src/reconciler/event-registry.ts index 12c2ff1c..84c895fa 100644 --- a/packages/react/src/reconciler/event-registry.ts +++ b/packages/react/src/reconciler/event-registry.ts @@ -9,11 +9,11 @@ import type { Container, EventHandlerMap, NativeRenderer } from "../types/host.j const CONTAINERS_KEY = "__gpuixEventContainers" function containersByRenderer(): WeakMap { - const existing = Reflect.get(globalThis, CONTAINERS_KEY) as - | WeakMap - | undefined - if (existing) { - return existing + const existing = Reflect.get(globalThis, CONTAINERS_KEY) + // Take the slot only when it really holds a WeakMap. Another value there + // (from user code or a second bundle copy) would throw on .get later. + if (existing instanceof WeakMap) { + return existing as WeakMap } const created = new WeakMap() Reflect.set(globalThis, CONTAINERS_KEY, created) diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index 5354ba46..e8a870de 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -121,17 +121,22 @@ const NATIVE_ENV_OVERRIDES = ["GPUIX_SCROLLBARS"] as const * `process.env.GPUIX_SCROLLBARS = "classic"` from a test. This runs before * every frame flush to push the current values across. */ +// Resolved once. The module registry caches the require, but this also +// skips the try/catch and the property read on every flush. +let nativeSyncEnvVar: ((key: string, value?: string) => void) | undefined +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + nativeSyncEnvVar = ( + require("@gpuix/native") as { syncEnvVar?: typeof nativeSyncEnvVar } + ).syncEnvVar +} catch { + // Native module not available. Nothing to sync. +} + function syncEnvOverrides(): void { - let syncEnvVar: ((key: string, value?: string) => void) | undefined - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - syncEnvVar = (require("@gpuix/native") as { syncEnvVar?: typeof syncEnvVar }).syncEnvVar - } catch { - return - } - if (!syncEnvVar) return + if (!nativeSyncEnvVar) return for (const key of NATIVE_ENV_OVERRIDES) { - syncEnvVar(key, process.env[key]) + nativeSyncEnvVar(key, process.env[key]) } } From b0d74f99750cd520416342f68b319cc9a672deda Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:15:13 +0200 Subject: [PATCH 9/9] test(react): wash covers the first glyph of a wrapped row --- .../src/__tests__/selection-layout.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react/src/__tests__/selection-layout.test.tsx b/packages/react/src/__tests__/selection-layout.test.tsx index 1e263969..86375164 100644 --- a/packages/react/src/__tests__/selection-layout.test.tsx +++ b/packages/react/src/__tests__/selection-layout.test.tsx @@ -215,4 +215,34 @@ describe("standard events on native elements", () => { renderer.nativeSimulateClick(200, 18) expect(onClick).toHaveBeenCalled() }) + + it("washes the first glyph of a wrapped row", () => { + const { render, renderer } = createTestRoot() + render( +
+ + OOOOO OOOOO + +
+ ) + + // The 100px box wraps the text after the space, so the second word + // starts a new visual row at the left edge. Sample a strip through the + // middle of that row's first glyph cell before and after the drag. The + // wash on a continuation row started one glyph late, so nothing under + // the first glyph changed. + const strip = () => + Array.from({ length: 12 }, (_, x) => renderer.pixelAt(x + 1, 60)) + const before = strip() + const black = ([r, g, b]: number[]) => r < 30 && g < 30 && b < 30 + + const selected = renderer.dragSelect(1, 20, 99, 60) + expect(selected).toBe("OOOOO OOOOO") + + const after = strip() + const washed = before.some( + (pixel, index) => black(pixel) && !black(after[index]!) + ) + expect(washed).toBe(true) + }) })