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/.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/.changeset/sync-env-overrides-under-bun.md b/.changeset/sync-env-overrides-under-bun.md new file mode 100644 index 00000000..c1eaf3ad --- /dev/null +++ b/.changeset/sync-env-overrides-under-bun.md @@ -0,0 +1,14 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Push `process.env` overrides through to the Rust side under Bun. + +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/Cargo.toml b/packages/native/Cargo.toml index 827a49ae..4951f63a 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -102,6 +102,24 @@ lto = true # that with "mis-aligned LINKEDIT string pool", so the addon fails to load. strip = "none" +# 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]] diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 5cf312e4..eaf27b81 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -561,6 +561,16 @@ export interface HighlightRect { height: number } +/** + * 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. + */ +export declare function syncEnvVar(key: string, value?: string | undefined | null): void + export interface WindowInsets { safeArea: EdgeInsets ime: EdgeInsets diff --git a/packages/native/index.js b/packages/native/index.js index b1ba2828..5a13551b 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/automation.rs b/packages/native/src/automation.rs index be68169e..7de3800e 100644 --- a/packages/native/src/automation.rs +++ b/packages/native/src/automation.rs @@ -110,6 +110,8 @@ pub fn bounds_tracker( }, ) .absolute() + .top_0() + .left_0() .size_full() } diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index c9f4ff26..69d0e99c 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -434,9 +434,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 @@ -624,7 +624,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); @@ -707,8 +707,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] @@ -730,7 +736,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)); } @@ -754,7 +763,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 7d32ba4f..213054f5 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -702,6 +702,35 @@ fn panic_message(payload: Box) -> String { .unwrap_or_else(|| "unknown panic".to_string()) } +/// JS-side values for environment overrides such as `GPUIX_SCROLLBARS`. +/// +/// 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) { + env_overrides().lock().unwrap().insert(key, value); +} + /// The main GPUI renderer exposed to Node.js. #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] #[napi] @@ -3253,7 +3282,6 @@ impl GpuixView { } } - impl GpuixView { /// Sync focus handles with the current element tree. /// Creates handles for new focusable elements, subscribes on_focus/on_blur, @@ -3465,7 +3493,6 @@ impl gpui::Render for GpuixView { } } - // ── Event emission ─────────────────────────────────────────────────── /// Helper to convert a GPUI Point to (f64, f64). @@ -3504,7 +3531,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 1903034d..47222874 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -204,13 +204,8 @@ pub(super) fn build_element( highlight_set: ctx.highlight.clone(), 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/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/native/src/style.rs b/packages/native/src/style.rs index 69eecd0b..223dfdc4 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -675,7 +675,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()); } @@ -698,7 +701,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")); } @@ -724,12 +730,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() @@ -748,7 +758,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/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 + ); } } diff --git a/packages/native/src/text/paint.rs b/packages/native/src/text/paint.rs index 1058e086..62a4268f 100644 --- a/packages/native/src/text/paint.rs +++ b/packages/native/src/text/paint.rs @@ -679,11 +679,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. @@ -716,6 +724,7 @@ pub fn range_rects( break; } cur = next; + row_is_continuation = true; } rects } diff --git a/packages/react/src/__tests__/automation-stdio.test.ts b/packages/react/src/__tests__/automation-stdio.test.ts index 6c98ee49..ff96925d 100644 --- a/packages/react/src/__tests__/automation-stdio.test.ts +++ b/packages/react/src/__tests__/automation-stdio.test.ts @@ -111,11 +111,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) 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) + }) }) diff --git a/packages/react/src/reconciler/event-registry.ts b/packages/react/src/reconciler/event-registry.ts index a67bb19d..0e5ac8ed 100644 --- a/packages/react/src/reconciler/event-registry.ts +++ b/packages/react/src/reconciler/event-registry.ts @@ -1,35 +1,55 @@ import type { EventPayload } from "@gpuix/native" import type { Container, EventHandlerMap, NativeRenderer } from "../types/host.js" -/** One renderer, one root. This map is also the ownership guard: a renderer - * owns one window, one native root id, and one event handler map, so a second - * root would replace all three without the first root ever knowing. */ -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. +/// +/// The map is also the ownership guard: a renderer owns one window, one native +/// root id, and one event handler map, so a second root would replace all three +/// without the first root ever knowing. +const CONTAINERS_KEY = "__gpuixEventContainers" + +function containersByRenderer(): WeakMap { + 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) + return created +} export function attachRoot(renderer: NativeRenderer, container: Container): void { - const owner = containersByRenderer.get(renderer) + const containers = containersByRenderer() + const owner = containers.get(renderer) if (owner && owner !== container) { throw new Error( "This renderer already drives a mounted GPUIX root. One renderer owns one window, one native root id, and one event map, so a second root would silently take both over. Unmount the first root first." ) } - containersByRenderer.set(renderer, container) + containers.set(renderer, container) } /** Only the owner may detach. Otherwise unmounting a rejected or stale root * would delete the live root's event mapping and every handler would go dead. */ export function detachRoot(renderer: NativeRenderer, container: Container): void { - if (containersByRenderer.get(renderer) === container) { - containersByRenderer.delete(renderer) + const containers = containersByRenderer() + if (containers.get(renderer) === container) { + containers.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 15952afb..06742786 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -137,6 +137,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 { @@ -174,11 +177,12 @@ export function render(node: ReactNode, options: RenderOptions = {}): Root { void resolveClassName 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") diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index e9382e7a..51215786 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -120,6 +120,36 @@ 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. + */ +// 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 { + if (!nativeSyncEnvVar) return + for (const key of NATIVE_ENV_OVERRIDES) { + nativeSyncEnvVar(key, process.env[key]) + } +} + // ── Test element tree ──────────────────────────────────────────────── export interface TestElement { @@ -207,6 +237,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() } @@ -520,7 +551,11 @@ export class TestRenderer implements NativeRenderer { this.native.flush() } - /** Scroll ancestors until the element is in view, as web scrollIntoView. */ + /** 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)