Consume the pending changesets into a minor release. Users get the
terminal renderer, a headless Dialog with an Escape layer stack, default
Tab focus navigation, focus-visible styles and a headless Button,
createPortal, onLayout and onScrollChange, native scrollbars, new CSS
styles, registerFont, BGRA image uploads, getElementsAt, idle GC,
display-link frame pacing on macOS and Windows ARM64 builds.
Bump the native Cargo crate, napi loader version checks, native, React
and Solid packages to 0.11.0, @gpuix/cli to 0.1.2 and disktree to 0.2.1.
Fixes#73Fixes#74Fixes#75Fixes#76Fixes#77Fixes#78Fixes#84Fixes#91Fixes#93Fixes#95Fixes#96Fixes#97Fixes#98Fixes#99
Discord: https://discord.com/channels/1422625037164351591/1562091690376962111
Session: ses_ea68cd52affex3PxfjqF4PhYyY
`render(<App />, { terminal: true })` or `GPUIX_TERMINAL=1` runs the same
GpuixView on gpui_terminal, a GPUI platform in the Zed fork that paints with
the kitty graphics protocol. No native window opens.
```
macOS Linux
renderer.init ─► init_macos renderer.init ─► init_threaded
EmbeddedPlatform::Terminal gpuix-ui thread
TerminalPlatform::new_embedded() gpui_terminal::application().run()
tick() ─► pump_events()
```
- renderer.rs: `terminal_requested()` reads the option and the env var;
macOS swaps MacPlatform for TerminalPlatform inside the embedded path, so
tick() and the frame wake hook are unchanged. No per-method branches
- input.rs: `init_terminal` unbinds ctrl-c in text field contexts so it
reaches the platform and quits
- `isTerminal()` exported from @gpuix/native, @gpuix/react and @gpuix/solid
as a plain function (not a hook); browser.mjs returns false
- examples/chat.tsx, mail.tsx drop the traffic-light gap in a terminal
- AGENTS.md: terminal mode swaps the platform; no hooks for non-reactive values
- README: "Run in a terminal" section and the `terminal` option row. The same
README hunk also carries a note on `windowBackground: "blurred"` tinting
- docs/terminal-renderer.md: architecture, traps, research notes
- zed submodule: gpuix-terminal commits (gpui_terminal crate, embedded mode,
SIGWINCH-only forwarding)
- changeset (minor for native, react, solid)
Discord: https://discord.com/channels/1422625037164351591/1562055543466496043
Session: ses_ea7105a47ffeNLpB02sEyo5t9g
Before, any `focusVisible` style (even `{}`) returned early from
apply_focus_visible, so one key controlled two states: the look of the
element while it has keyboard focus, and whether it dims while another
control has it. A custom focus ring therefore could not dim.
Now the two are separate:
- `focusVisible` only styles the element while it has keyboard focus
- every other focusable element off the focus path dims to 40%, with or
without a `focusVisible`
- new style key `keyboardFocusDim: false` keeps one element at full opacity
(StyleDesc.keyboard_focus_dim, not inherited, excluded from state styles)
- `Select.Content` and `Dialog.Popup` (React and Solid) switch from
`focusVisible: {}` to `keyboardFocusDim: false`
- the window-wide `render({ keyboardFocusDim: false })` option is unchanged
Test: the focus scene now checks that `keyboardFocusDim: false` opts an
element out and that an element with a focusVisible ring still dims.
README Focus styles and the pending focus changeset describe the new rule.
Session: ses_f231682b0ffe2fr5xaNLPPWQre
GPUI stores color images as BGRA (RenderImage, Metal and DirectX atlases).
setImagePixels only took RGBA, so every upload swapped bytes 0 and 2 of
every pixel on the UI thread. BGRA sources (ffmpeg -pix_fmt bgra,
VideoToolbox, node-canvas toBuffer('raw')) now skip that pass.
- native: PixelFormat enum in img.rs; napi, test renderer, UiCommand and
wasm bindings take an optional format. Unknown formats throw
- host types: ImagePixelFormat, ImagePixelsOptions
- react/solid: setImagePixels(w, h, pixels, { format })
- waveform example writes BGRA by hand
- README: format docs, node-canvas raw is BGRA premultiplied (opaque only)
- test: rgba and bgra of the same color paint byte-identical screenshots,
misread bytes swap red/blue, unknown format throws
Session: ses_f230bc6fdffe5CqhxBXqCeacz9
`render(<App />, { keyboardFocusDim: false })` (React and Solid) and
`createTestRoot({ keyboardFocusDim: false })` (React) stop GPUIX from dimming
every other focusable element while a control has keyboard focus. Apps then
draw their own focus look with `focusVisible`.
How it reaches native:
- new batch op `["setKeyboardFocusDim", bool]`, parsed in apply_batch_to_tree,
so macOS, Windows/Linux, web and the test renderer share one path with no
per-backend method and no pending state while the web window boots
- stored as RetainedTree.keyboard_focus_dim (default true)
- keyboard_focus_path() returns None when it is off, so apply_focus_visible
never dims
- each root enqueues the op on creation (React createRoot, Solid attach), so
a bun --hot remount without the option restores the default
Docs: README Focus styles section documents the option and the 8px pointer
slop from the GPUI bump; the pending focus changeset gets both bullets.
Test: the styles.test.tsx focus scene with keyboardFocusDim: false paints the
same frame as idle after Tab.
Session: ses_f231682b0ffe2fr5xaNLPPWQre
Remove the default focusVisible ring and the inherited focusRingColor
style. Without its own focusVisible:
- a focusable control (Button, tabIndex div) renders at 70% of its own
opacity while keyboard-focused, which shows where Tab went without a
ring (FOCUS_VISIBLE_DIM, paint only)
- <input> / <textarea> draw nothing; the caret already shows focus
apply_focus_visible takes a FocusVisibleDefault (Dim | None) instead of
an inherited ring. outlineWidth/Color/Offset stay for apps that want a
ring via focusVisible.
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
- examples/chat.tsx and examples/solid/chat.tsx: every div + onClick
control (IconButton, SidebarAction, ConversationRow, thread filter,
WorkedFor, ModeToggle, GhostButton, search hits, cycle-overlay) is a
Button, so the chat is fully keyboard reachable. OverlayCard is a Dialog:
Escape, backdrop press, Tab trap, and focus in (search input via
initialFocus) and back out to the opener. Overlays stay mounted with
open={...} instead of conditional rendering.
- example-app/app.tsx: IconButton, SidebarRow, Checkbox, Add are Buttons;
Add is disabled while the draft is empty.
- Select.Content and Dialog.Popup set focusVisible: {} so the default ring
does not outline the whole menu or card; they are focused only to take
keys.
- @gpuix/solid exports the HostElement type for refs.
- chat.test.tsx: Escape closes search and focus returns to its button.
Solid chat port done by a subagent; verified with chat.automation.ts
(6/6) and a throwaway test of open, Escape, close, and focus return.
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
Focus styles work like hover: nested style objects that map straight to
GPUI's .focus() and .focus_visible() refinements (focus_visible already
means keyboard-only, via window.last_input_was_keyboard()).
A ring needs to appear without moving layout. A border changes the box
and a zero-blur box-shadow fills the whole rect, so the zed fork gains
Style::outline (width, color, offset), painted after the border as a
border-only quad grown by offset + width that follows corner radii.
GPUIX exposes outlineWidth / outlineColor / outlineOffset.
Default ring, like a browser: every element that tracks a focus handle
(div, input, textarea) gets a 2px accent focusVisible outline unless its
style sets focusVisible. focusRingColor is an inherited style prop
(transparent turns it off), resolved through Inherited like selectionColor.
Button (React + Solid), Base UI shape: tab stop, role=button, onClick on
press, Enter keydown (not isHeld), Space keyup; disabled leaves the Tab
order, focusableWhenDisabled keeps it; state style function. buttonProps()
is shared; Dialog.Trigger / Dialog.Close now use it. GPUI has no
aria_disabled, so none is emitted.
- zed: c6531c8 -> c0d597f (gpui: add Style::outline)
- tests: default ring on keyboard only, no layout change, transparent off,
custom focusVisible; Button activation in both adapters
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
Second oracle review of 6382a80 found five issues:
1. A pending focusElement request survived Tab / clicks and stole focus
when its element appeared later. PendingFocus now records the focus at
request time and is dropped if focus moved any other way first. The
check runs before the frame's autoFocus, so an explicit request still
beats same-frame autoFocus (live and test renderer now agree).
2. React read previous focus in a layout effect; in the test renderer
that read flushes and an autoFocus could already hold focus. The
DismissableLayer now reads it during render (React) / component body
(Solid), before its elements are committed, and passes it to
pushDismissLayer({ previousFocus }).
3+4. Each Popup moved focus on its own in effect order, so nested
dialogs opened together focused the outer one and closed together
lost focus. DismissLayer gains initialFocus() / finalFocus(previous);
the host calls them in stack order: only a layer landing on top takes
focus, a parent inserted under same-commit children rewrites their
return target to itself, and a child whose parent already closed skips
its restore.
5. FocusTarget function results now accept refs.
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
Dialog.Popup takes Base UI's initialFocus and finalFocus: true (default),
false (stay), a ref or element, or a function returning one of these.
The shared resolveFocusTarget lives in @gpuix/native/host.
- initialFocus defaults to the popup itself. Base UI picks the first
tabbable element, but GPUI's tab map exists only after the popup paints,
so the first Tab walks that order instead.
- finalFocus defaults to the trigger, else the element focused when the
popup opened. The Popup records it on open, so a dialog opened from app
state (no Trigger, e.g. chat.tsx overlays) returns focus on close.
- Popup no longer uses autoFocus; it focuses imperatively after reading the
previous focus.
Native fix found by the Solid test: sync_focus_handles took
pending_focus_element even when the handle did not exist yet, so a frame
rendered before the element's batch dropped the request. That is the
normal order for Solid (microtask flush) and for Windows/Linux (UI thread).
The request now stays until the handle exists. The test renderer's
focusElement uses the same request_focus path as the live renderer.
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
Oracle review found two bugs in the layer stack commit:
1. React runs child layout effects before parent ones, so a Dialog and a
nested Select opening in one commit registered Select first. The stack
put the Dialog on top and one Escape closed both. Layers now carry a
parent (DismissableLayer component + context in React and Solid), and
pushLayer inserts a parent below any child that registered first.
2. A prevented Tab still closed an open Combobox, because the input closed
on its own Tab keydown before an ancestor could prevent the default.
The Combobox now closes when focus leaves the input, like Base UI, so
it closes only when Tab really moved focus.
onBlur was untestable: the offscreen test window is never active and gpui
reports focus paths only for an active window. The zed fork gains
Window::set_active_for_tests (same body as the platform active-status
callback, extracted into active_status_changed), and the test renderer
calls it. Tooltip open-on-focus is now covered too.
- zed: 81c99f8 -> c6531c8 (gpui: let tests report a window as active)
- useDismissLayer hook replaced by DismissableLayer component
- regression tests: nested defaultOpen layers, ancestor-prevented Tab
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
Keyboard defaults run in the shared JS host, after every element onKeyDown
and the window onKeyDown, so any handler can cancel them:
- Tab / Shift+Tab move focus (GPUI focus_next / focus_prev)
- Escape closes the top layer of a per-window dismiss layer stack
- KeyEvent gets preventDefault / stopPropagation / defaultPrevented
- render({ tabNavigation: false }) turns the Tab default off
Why JS and not a GPUI KeyBinding: the event callback is a non-blocking
ThreadsafeFunction, so JS runs after GPUI finished dispatching and could
never veto a native binding.
Native:
- keystrokeId on key events, bumped in a root capture listener, so JS can
group one keystroke's element events and its trailing window event
- root listener always installed; stops Tab after all element listeners so
the platform never types "\t" into an input
- anchored: explicit transparent background paints no fallback fill
- bounds_tracker pinned at inset 0; bounds were shifted by padding
Primitives (React + Solid):
- Select / Combobox / Tooltip triggers are tab stops, also with asChild
- Select popup traps Tab; outside press no longer steals focus back
- Escape handling moved to the layer stack
- new Dialog: Root, Trigger, Portal, Backdrop, Popup, Title, Description,
Close (Base UI shape), modal Tab trap and focus restore
- Solid asChild keeps the child's handlers and children
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
URL.pathname yields /C:/... on Windows, which Bun.build rejects as a root directory. fileURLToPath returns a native path on every platform.
Session: ses_f330c012affeeWEt6hY3uyjEvQ
Build the real Wasm browser package before native browser-bundle tests so CI validates the same files that publishing includes. The test job checks out the GPUI submodule and installs the same nightly toolchain and wasm-bindgen version used by the publish job.
Allow the end-to-end tarball consumer test enough time to pack, install, and compile the Solid fixture on Windows without Bun terminating the process at its five-second default.
Session: ses_f330c012affeeWEt6hY3uyjEvQ
Point the temporary consumer override at the locally packed native tarball. Bun otherwise tries to download the Solid tarball transitive native dependency from npm, which fails while validating a not-yet-published release. Continue exercising the real packaged native and Solid code in both the preload and build-plugin checks.
Session: ses_f330c012affeeWEt6hY3uyjEvQ
Consume the pending changesets into a minor release. Users get the official Solid 1 renderer, AnimatePresence exit animations, live image uploads and scrollIntoView on host refs, native window controls, and an OS file picker.
Bump the native Cargo crate, native npm package, React package, Solid package, generated napi loader version checks, and Bun workspace lock to 0.10.0. Keep published adapter dependencies on @gpuix/native as workspace:^. CI is the only publish path for native platform binaries.
Fixes#68
Session: ses_f3b7fae0dffeZjEBH48TZSQVBq
Drop the previous GPU image when src or a live upload replaces it, and when the img node is pruned. Without drop_image, every setImagePixels frame leaked a RenderImage.
scrollIntoView now uses the virtual-list logical index, not the windowed child index. A windowStart of 50 plus child 5 now scrolls to item 55.
Attach setImage, setImagePixels, and scrollIntoView in getPublicInstance instead of createInstance, so concurrent React can abandon a host node before the methods exist. Pass the original buffer through instead of copying it with Buffer.from.
The waveform test compares two live phases. Encoded setImage and windowed scrollIntoView have screenshot and list tests.
Session: ses_f3c07efdbffeHwbaQhsE7fIz5z
Add website/src/guides/solid.mdx covering install, preload, render,
primitives, motion, Select, testing, and automation. Link it from
the Guides nav and from the README Solid section.
The Solid AnimatePresence test now wraps the exiting motion.div in
Show. A boolean && child is not a tracked hole, so the previous
test never actually unmounted.
Session: ses_f3c074549ffe2jtrJMYbbLhqMx
`<img>` refs can push image bytes without a data URL or JSON mutation.
`setImage` takes encoded PNG, JPEG, WebP, GIF, SVG, BMP, TIFF, ICO, or
Netpbm. `setImagePixels` takes packed RGBA. Prefer pixels for a live
waveform. There is no density argument. Upload a 2x bitmap into a 1x
layout box on retina.
Every host ref now has `scrollIntoView()`, which scrolls the nearest
overflow parent or `<virtual-list>` until that node is visible.
The waveform example paints a generated 2x RGBA buffer through
`setImagePixels`.
Session: ses_f3c07efdbffeHwbaQhsE7fIz5z
Resolve the native renderer through the package browser condition and expose CPU throttling from a browser-safe subpath so both public adapters bundle for WebGPU without importing node:module.
Add browser bundle coverage to the native package suite and run those package checks in CI after both adapters are built.
Make Solid motion completions generation-aware, reject stale targets, and keep presence removal idempotent. Replace the native motion invariant panic with a safe target fallback.
Session: ses_f4102f732ffesSfYYRRHlXA2j9
Introduce @gpuix/solid for stable Solid 1 with a small universal host tree, shared native mutation and event infrastructure, native-backed testing and automation, motion, AnimatePresence, Select, Combobox, and Tooltip.
Compile reactive JSX through an OpenTUI-style Bun preload and exported Bun.build plugin, with no Vite or wrapper CLI. Add direct workspace, real native renderer, imported TSX, and installed-tarball end-to-end coverage.
Automatically flush signal mutations outside event boundaries while keeping failed native batches available for retry.
Session: ses_f4102f732ffesSfYYRRHlXA2j9
Co-authored-by: juan <47119916+jhomra21@users.noreply.github.com>