mirror of
https://github.com/remorses/gpuix.git
synced 2026-10-29 03:04:52 +08:00
@gpuix/solid@0.10.0
74
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9d39e7f38a |
Document and publish the Solid adapter
Add the Solid quick start, shared native runtime architecture, package build instructions, and framework parity rules to the public contract and contributor guide. Build and test Solid in the platform matrix, then publish it after @gpuix/native beside the React adapter. Session: ses_f4102f732ffesSfYYRRHlXA2j9 |
||
|
|
e256ed1345 |
Add native desktop window controls
Expose minimizeWindow, zoomWindow, and toggleFullscreen through the native renderer and React renderer contract. Dispatch macOS operations directly through the embedded window and route Windows, Linux, and FreeBSD operations through the GPUI UI command channel. Document the desktop-only API, regenerate the N-API declarations, and add a production binding regression test plus a minor changeset for issue #68. Session: ses_f41046982ffe9HBet3NGzDDJXd |
||
|
|
846de94d8d |
Let Select follow Base UI, and export library APIs for gpuix-ui.
Select no longer walks children by `child.type === SelectItem`. Root `items` is optional and only a label lookup for SelectValue while the popup is closed. Keyboard nav and clicks read mounted SelectItem children through registerItem. Disabled lives on Item. A styled wrapper around Item works. Without items, SelectValue shows the raw value. Register items in useLayoutEffect, not a callback ref, so a middle item can re-render without jumping to the end of the list. selectValue rejects unmounted and root-disabled items. A late-mounted selected item becomes the highlight. Native: - getFocusedElementId, focusNextWithin, focusPreviousWithin walk GPUI's painted TabStopMap via focus_next_among / focus_prev_among - visibility: hidden maps to GPUI invisible() - getElementBounds is typed on NativeRenderer JS: - export Props, GpuixTheme, and host prop types from @gpuix/react - export FloatingLayer, useFocusTrap, and friends from @gpuix/react/floating AGENTS.md: built-in headless controls follow Base UI's Root-data vs children split. Fixes #59 Session: ses_f83f90209ffeVmkdjhaBehDTP7 |
||
|
|
58397f4e58 |
Tell agents to leave bun --hot example windows running.
When someone asks to open an example so they can look, relaunching or killing the session after a screenshot drops the window. A save remounts React on the same host. bun --hot still drops useState; that is the desktop remount path, not Fast Refresh. Session: ses_f93fdf898ffeH0OlmQZz6Vz38t |
||
|
|
5f066f3c55 |
Show a stack overlay after a JavaScript runtime error.
A throw used to keep the window alive but empty. render() now paints the error message and stack, and Reload remounts the last tree. Unhandled React render errors, uncaughtException, and unhandledRejection all take this path. The overlay is scheduled on a microtask keyed to the failing root, so a later remount does not paint a stale overlay. Process handlers uninstall on resetRender. Save under bun --hot still remounts too. Session: ses_f941afdedffezh2lODSRNO04A8 |
||
|
|
0bb3c94cda |
Add http(s) src support to img.
Desktop GPUI defaults to NullHttpClient, so a remote URL never loaded. Install Zed reqwest_client on native apps so img(SharedUri) can fetch in the background. Wasm already has fetch. A definite width and height now also set aspect_ratio. GPUI otherwise fills that from the bitmap after decode and jumps the box. Tests use a FakeHttpClient that returns the same SVG as the disk fixture, so the dispatcher never parks on a real network. Set both width and height. Failed loads still show the existing fallback. A loading URL paints an empty box of the declared size. Session: ses_f9810208bffeUeHRs8rtxe7KaD |
||
|
|
248752177e |
Keep the macOS window alive after a JavaScript runtime error.
On macOS, JS owns the event loop. startFrameLoop calls renderer.tick() about every 8ms, and that tick pumps AppKit. A throw that escaped the timer stopped the next setTimeout, so the window froze. bun then often exited, which looked like a crash. startFrameLoop now catches errors from tick() and still schedules the next pump. Native event callbacks catch throws from React handlers so they do not become napi_fatal_exception. render() installs uncaughtException and unhandledRejection listeners so bun stays alive, and it starts the frame loop before the first React flush so a throw during mount still leaves AppKit pumping. The loop is not restarted on remount. The error is logged. There is no auto-reload. Save under bun --hot to remount. Session: ses_f97da32b5ffejnj7pvF2xzbkXL |
||
|
|
2c807b2e13 |
docs: allow collaborator pull requests
Permit monotykamary, alongside remorses, to open pull requests without prior issue approval. Update both the top-level rule and the external contributor section so the policy stays consistent. Session: ses_f9e107cc3ffeh2N63MF1cirKc1 |
||
|
|
3bb1ac644f |
docs: complete keyboard API reference and update website tooling
Document element and renderer keyboard callbacks, action dispatch order, and imperative focus traversal as part of the public API contract. Update the documentation site to Holocron 0.33.0 and refresh its lockfile. The website typecheck and lint pass with the new dependency graph. Session: ses_fa3b8830cffeGdTgarIef6r2Kn |
||
|
|
53b3a8920f |
docs: keep automated windows in background
Make every native example honor GPUIX_BACKGROUND and make every current real-window automation example pass that flag. Human launches still focus normally, while agent-driven launches avoid taking the user keyboard on macOS and Windows. Explain why live input, clicks, and screenshots do not require activation. Correct stale guidance that claimed fill and press were unavailable, and document that Linux currently ignores the focus option. Validated with the examples and example-app test suites, type checking, lintcn, a live screenshot, and a macOS foreground-process check. Session: ses_fb208083cffewN8NfeygDAdslz |
||
|
|
4369d55c9e |
Require atomic renderer mutation batches
Reduce the renderer mutation contract to one applyBatch call per React commit across napi, wasm-bindgen, and the GPU-backed test renderer. - replace the dynamic batching Proxy with an explicit commit-phase facade - keep application commands on the original renderer instance - remove direct create, append, style, text, prop, and commit entry points - remove the unused removeChild and setCustomPropValue wire operations - require raw JSON style and custom-prop values and boolean event flags - regenerate native declarations and update architecture documentation - update serialization benchmarks for the smaller protocol - add a minor changeset for @gpuix/native and @gpuix/react Validated with 204 Rust tests, 359 React tests, 47 example tests, 6 starter-app tests, release native and Wasm builds, lintcn, and both serialization benchmark stages. Session: ses_fb7d2b638ffen9PHc0FVU3DpAv |
||
|
|
4a24b43458 |
Publish the CLI independently
Move @gpuix/cli into a focused publish job that runs its integration test and publishes without waiting for native platform test jobs. This keeps an unrelated Windows GPU example failure from blocking the platform-independent scaffolder. Session: ses_fb87b7e28fferSvDT71z3OadR1 |
||
|
|
6e75327cdc |
Add the GPUIX project scaffolder
Publish a new @gpuix/cli package with a goke-powered `gpuix new` command. The command downloads the official example app, replaces its workspace React dependency with the latest npm release, and installs the generated project with Bun. Update the quickstart, example documentation, workspace metadata, release notes, and CI publish order. Add a real GitHub-backed integration test and shell completion support. Session: ses_fb87b7e28fferSvDT71z3OadR1 |
||
|
|
3d9eb0ece4 |
Correct the identity docs, and mark the release minor
- `README.md` claimed **every element type** records painted bounds. `<virtual-list>` records none: gpui's list is not an interactive element, so there is nothing to record a box against. It is safe today only because `VirtualListProps` does not extend `Props` and so takes no `testId`. The claim is now scoped to elements that accept `testId`, and the exception is named - `AGENTS.md` said the two id namespaces cannot collide, which is true only for as long as nobody writes `.id(<index>)`. `impl From<usize> for ElementId` makes that the idiomatic gpui row id, and it lands in the same `Integer` namespace as a host id. Every per-row id in this crate is a formatted name for that reason, and the rule is now written down - the changeset is `minor`, not `patch`. `VirtualListShared.style` became `Omit<StyleDesc, "hover" | "active">`, so an app passing either stops compiling - the changeset now records that a filled `<text>` takes mouse hits. Routing `<text>` through the same builder as `<div>` means `should_occlude` applies to it, so a label with an opaque background stops clicks reaching what is behind it, like an HTML element with a background. The old text builder inserted no hitbox at all. `element-identity.test.tsx` pins the new behaviour - the `<img>` element-state test explained the offscreen window wrong. The `focus: false` comes from gpui's `VisualTestContext::open_offscreen_window`, not from anything in `test_renderer.rs` Session: ses_fc10c354effe0X3SbzUKVPca44 |
||
|
|
6319b0d2c9 |
AGENTS: name the bounds hook on_painted in its own heading
The section body was updated when the gpui listener moved from prepaint to paint, but the heading kept the old name, which is the line a reader skims. Session: ses_fc10c354effe0X3SbzUKVPca44 |
||
|
|
32ffa54958 |
Document element identity, bounds, and the one-root rule
`AGENTS.md` replaces the "custom elements are invisible to automation" section, which is now wrong, with the two rules a new element has to follow: - give it a host-derived GPUI id, or it silently loses hover, active, pointer capture, implicit scroll, its accessibility node, and any state gpui keeps. `<img>` had no id, which is why an animated GIF never left frame zero - call `apply_interactive_styles`, never `apply_styles`, on a stateful root Plus how bounds are recorded, and the trap behind it: containers add a `bounds_tracker` child, leaves and `<anchored>` use gpui's `on_painted`, and neither may move to prepaint because `gpui::list()` prepaints a speculative row range and then rolls it back. `README.md` drops the claim that `<img>`, `<svg>`, `<anchored>`, `<diff>` and `<markdown>` have no painted bounds, says `hover` and `active` work everywhere except `<virtual-list>`, and states the one-root-per-renderer rule. Session: ses_fc10c354effe0X3SbzUKVPca44 |
||
|
|
329a52f84c |
fix: keep the reader pixel-stable when an infinite-scroll page lands
An older page landing while the reader waited in the loading row moved the screen. Three stacked causes, three layers of fix: 1. gpui anchors a list on the item at the viewport top. While the reader waits in the void that item IS the void, so the splice replaced the screen under it. The old restore (`scrollToItem(listId, index)`) snapped the anchor message to the viewport top with offset 0, itself a jump of up to EDGE_HEIGHT pixels. No non-negative ListOffset can express "keep this row where it is": the rows above it are freshly inserted and unmeasured. The gpui fork (submodule commit 8b94defe56) now accepts a negative `offset_in_item` in `ListState::scroll_to`; `layout_items` resolves it by walking upward with real measured heights, which is what makes the restore exact rather than estimate-based. 2. On the live renderer `scrollToItem` applied `scroll_to` immediately, but the just-committed prepend only reaches the ListState at the next `GpuixView::render`, so `splice_focusable` shifted the freshly restored anchor a second time. The test renderer hid this because its wrapper flushes first. Virtual-list scrolls are now queued in `PENDING_VIRTUAL_LIST_SCROLLS` and applied right after that frame's splice, then the queue is cleared (one-shot). 3. Scrolling DOWN jumped for a different reason: a reader resting at the bottom sits on gpui's at-end sentinel (`logical_scroll_top` is None, reported as itemIndex == item count), where any offset-in-void math is meaningless. `getListScrollTop` returns `[itemIndex, offsetInItemPx, viewportHeightPx]`; the app converts the sentinel with `EDGE_HEIGHT - viewportHeight` so the void's old top edge stays put and the new page fills the blank space below the text. New API on all four surfaces (napi, UiCommand, wasm, test renderer): - `scrollToItem(listId, index, offsetInItem?)` — px, may be negative - `getListScrollTop(listId)` — logical anchor plus viewport height `examples/infinite-chat.tsx` drops the snap-to-top workaround and restores both directions. The loading row shrank from 560px to 160px. Tests, each verified to fail against the previous behaviour: - examples: "keeps the message under the reader at the same pixel when an older page lands", "keeps the trailing messages at the same pixel when a newer page lands", "lands a newer page inside the void the reader is staring at" - packages/react: scrollToItem offset roundtrip, negative-offset normalization ([50,-100] over 40px rows resolves to [47,20]), top clamp Session: ses_fbc66676dffeMDwZ3jJhimdkw6 |
||
|
|
dbeca11374 |
Document background launch as the default for agent-driven runs
`focus: false` shipped in the previous commit with no guidance on when to
reach for it. The answer is: whenever an agent starts the app. An agent that
launches the window to check its own work takes the keyboard away from
whoever is typing, mid-sentence, once per iteration, and it gains nothing by
doing so.
**README**
The `Background launch` section now leads with that rule, and a new
`Let an agent drive the app` section documents the environment-variable
pattern so a human run is unchanged and an agent run stays out of the way:
render(<App />, { focus: process.env.GPUIX_BACKGROUND !== '1' })
bun app.tsx # you: window comes to the front
GPUIX_BACKGROUND=1 bun app.tsx # agent: opens behind your editor
**AGENTS.md**
The same rule as an instruction, in `Drive the live window`, so an agent
follows it without being asked.
**examples/chat.tsx**
The AGENTS.md snippet launches `chat.tsx` with `GPUIX_BACKGROUND=1`, but
`chat.tsx` read no such variable, so the documented example would have
silently kept stealing focus. Its `render()` now honours it. This is the app
AGENTS.md tells agents to launch, so it is the one that most needs the flag.
**A claim I had to retract**
The first draft said typing works on a background window. It does not.
`fill()` and `press()` throw `keystrokes are not live yet`, because the live
renderer implements no `simulateKeystrokes`. That is a pre-existing `launch()`
gap and is unrelated to focus; it fails on a focused window too. All three
documents now say so and point at `createTestRoot()` for anything that types.
**Verified rather than assumed**
Every claim was run against a real launched window, then the probe deleted:
click() on an unfocused window works, count=2 after two clicks
screenshot() on an unfocused window works
both on a show:false window works, never on screen
focus not stolen Chrome stayed frontmost
fill() / press() on a live process throws
The exact AGENTS.md snippet was then run end to end: it launched `chat.tsx`,
clicked `sidebar-collapse`, wrote a screenshot of the fully painted app, and
left Chrome frontmost throughout.
`examples` 36 tests pass.
Session: ses_fc17399a8ffeTt72KZetsU15ty
|
||
|
|
594ba31629 |
Launch a window without stealing focus
`render()` takes `focus` and `show`. `focus: false` opens the window behind
whatever app you were typing in, like `open -g`. `show: false` opens nothing
at all, so the process boots with a live React tree and an empty screen.
render(<App />, { title: 'Notes', focus: false })
**Two knobs, not one**
`gpui::WindowOptions` already had `focus` and `show`, and `to_gpui_window_options`
simply never set them. But mapping the fields alone does nothing, because
`init()` called `cx.activate(true)` unconditionally on both init paths.
On macOS the window flag only decides whether the window becomes *key inside*
the app; activation is what pulls the whole process forward. Both had to move
together, so `activate` is computed before the closure (the options are moved)
and the call is gated on it.
**Revealing it later**
`activateWindow()` brings the window forward and focuses it. It is the only way
to reveal a `show: false` window. macOS goes through `update_window`, the
threaded platforms through a new `UiCommand::ActivateWindow`. It runs
`cx.activate(true)` alongside `Window::activate_window()`, because the latter
only makes the window key within the app.
**Platform support**
Straight from GPUI's `WindowParams`:
| Platform | `focus: false` | `show: false` |
|----------|------------------------------------|---------------|
| macOS | orders in front without becoming key | honored |
| Windows | `SW_SHOWNOACTIVATE` | honored |
| Linux | ignored | ignored |
The macOS Dock icon still appears. `did_finish_launching` hardcodes
`NSApplicationActivationPolicyRegular`, so a menu-bar-agent app would need a
`remorses/zed` fork commit. Searched upstream first per AGENTS.md: no issue and
no PR asks for a configurable policy, and the only code hit is that hardcoded
call. Left for a follow-up.
**Verified**
Five new `window_options_tests` cover the mapping, including that an unset
option still defaults to true and that the existing fields keep mapping.
Focus stealing is not unit-testable, so I drove real windows and read the
frontmost process id and the AppKit window count through `osascript`:
default frontmost=app windows=1
focus: false frontmost=other windows=1
show: false frontmost=other windows=0
after activateWindow() frontmost=app windows=1
`cargo test --lib` 137 pass, `packages/react` 302 pass, `examples` 35 pass.
`x86_64-unknown-linux-musl` cross-builds, which type-checks the threaded
branch; Windows cannot cross-compile here because `onig_sys` needs Windows C
headers, and CI covers it.
Session: ses_fc17399a8ffeTt72KZetsU15ty
|
||
|
|
0b257d0a36 |
ci: one target per OS, and ship the chat example as an archive
Three changes to the release pipeline, all found while cutting 0.5.0. **One target per OS.** The matrix built six targets; it now builds the architecture each OS is mostly used on and nothing else: | OS | Target | Renderer | |---------|---------------------------|---------------| | macOS | aarch64-apple-darwin | Metal | | Linux | x86_64-unknown-linux-gnu | Vulkan / wgpu | | Windows | x86_64-pc-windows-msvc | Direct3D | Every extra target is a full gpui build, and gpui is most of the wall clock of this workflow, so a release was paying twice over for platforms almost nobody installs. Intel macOS, arm64 Linux and arm64 Windows lose their prebuilt binary. `napi.targets` is trimmed to match; the two lists must stay identical or the published loader looks for a platform package CI never built. Both test jobs already pull targets that stay. **The chat example gets an extension.** A GitHub release asset is served as raw bytes, so a download lost the executable bit and, on macOS and Linux, arrived with no extension at all. The build job now packs those two into `example-chat-<target>.tar.gz`, which keeps the mode and names itself. Windows keeps its `.exe`. Verified end to end with the real 0.5.0 macOS binary: pack, drop the mode to 644 like a browser download, `tar -xzf`, and the extracted file is `-rwxr-xr-x` and opens its window. The download also drops from 83 MB to 33 MB. **Create the GitHub release before CI reaches the publish job.** The upload step exits 0 when no release exists for the tag, so a release created afterwards gets no binaries, and the only way to add them later is to move ~100 MB through a laptop. There is no API that attaches an artifact to a release: the assets endpoint wants the raw bytes in the request body, and `gh release upload` cannot read stdin (cli/cli#5820). AGENTS.md now spells out the order, and what to do about the tags when CI fails and you push fixes. Session: ses_fc073cf53ffem2NBtBXPFaCgyF |
||
|
|
7baa36fdcf |
Install the macOS application menu bar
GPUI never calls NSApplication.setMainMenu:, and GPUIX never called
cx.set_menus, so NSApp.mainMenu stayed nil. macOS painted nothing next to
the Apple menu. Verified on the running example app through the
accessibility tree: 'menu bars 0' before, 'Apple, bun, Window' after.
The empty bar was the smaller half of the bug. AppKit only provides the
standard shortcuts through menu items, so cmd-Q, cmd-H, opt-cmd-H, cmd-M
and cmd-W did not exist at all. There was no way to quit a GPUIX app from
the keyboard.
Activation policy was not the cause. did_finish_launching already sets
NSApplicationActivationPolicyRegular, embedded mode included.
Apple <appName> Window
|- Services |- (AppKit window tiling)
|- Hide <appName> cmd-H |- Minimize cmd-M
|- Hide Others o-cmd-H |- Zoom
|- Show All |- Close Window cmd-W
'- Quit <appName> cmd-Q '- (open windows)
crate::app_menu binds the keys, registers the handlers, then calls
set_menus, in that order: gpui::App::set_menus reads each key equivalent
out of the keymap, so a binding added afterwards would show no shortcut.
Quit, Hide, HideOthers and ShowAll are cx.on_action globals.
MinimizeWindow, ZoomWindow and CloseWindow go through
with_window_menu_actions on the root element in GpuixView::render,
because that is the only place in GPUIX where a Window exists.
New appName window option for the name inside 'Hide X' and 'Quit X'. It
defaults to title.
Two things AppKit decides, both found by reading back the live menu
rather than by trusting the code:
- the title of the application menu is the executable name, not the Menu
name passed to set_menus. 'bun app.tsx' shows 'bun', and only a .app
bundle changes it. The items inside do use appName
- the menu named Window is handed to setWindowsMenu:, which prepends
AppKit's own tiling items, 'Enter Full Screen' included. The first
version added that item too and produced two entries sharing a
shortcut, so it is gone
No Edit menu, on purpose. AppKit consumes a menu key equivalent before
the window sees the key event, so an Edit menu carrying cmd-C would take
the keystroke away from the selection listener in text::paint and from
the per-focus clipboard handling in custom_elements::input. An Edit menu
needs those handlers moved into GPUI actions first.
Tests: 198 Rust (2 new), 340 react, 36 examples, 6 example-app.
Session: ses_fc0edf605ffesXw3qtyEm0tZgG
|
||
|
|
2daf9881ba |
Decode mutation batches into typed ops, and share styles by content
applyBatch built a serde_json::Value tree, deep-cloned every style payload out of it, and parsed the clone a second time into a StyleDesc. Each style was allocated three times. The batch then landed in a Vec<BatchOp> whose SetStyle variant held a 1.4 KB StyleDesc inline, so a 220k-op mount reserved over 300 MB before it parsed a single op. The batch now deserializes straight from its JSON bytes into typed ops through a hand-written SeqAccess visitor. Strings borrow out of the input, so an element type or a text run is copied once instead of twice, and the intermediate tree is gone. JSON is unchanged; only the decode changed. Styles are hash-consed by raw payload. A 10,000-turn chat sends 59,320 setStyle ops carrying 90 distinct styles, and every element now shares one Arc. RetainedElement drops from 1624 bytes to 248, and because the element map stores values inline that shrinks the whole table. Measured on ChatApp at 10,000 turns, 221,764 ops, 72,010 elements: | metric | before | after | |---------------------|---------:|---------:| | parse and apply | 127.1 ms | 30.2 ms | | heap churn | 900.5 MB | 104.5 MB | | allocations | 1476196 | 186091 | | retained tree | 224.5 MB | 42.6 MB | | bytes per element | 3116 B | 592 B | | chat mount | 351.3 ms | ~200 ms | | chat wheel p50 | 8.39 ms | ~3.9 ms | Atomicity is preserved, and is now structural. resolve_styles runs between decode and apply and borrows only the style table, so the borrow checker proves no element was touched when it returns Err. An earlier draft interned inside the apply loop, which left a malformed style at the end of a batch half applied; batch_tests covers that. The style table is swept after a batch and after a direct setStyle, and the sweep is amortized at max(swept_at * 2, 64) so a one-element commit does not scan a large table. Anything with strong_count == 1 is released, which is what keeps a dragged element from growing the table forever. Interning deliberately lives in Rust, not in the protocol. commitUpdate resends the full style on every commit and a JSX style literal is a fresh object each render, so a JS-owned table would grow without bound and the update path would send a definition plus a reference where it sends one op today. Measured: JS-side interning is 1.13x on encode and 1.6x on the wire, which does not pay for that. Also here: - RetainedTree::elements uses FxHashMap. It is probed once per ancestor hop in mark_changed and twice per child per frame in build_virtual_list - set_style compares with Arc::ptr_eq and falls back to PartialEq on a pointer miss, so a reordered but equal style is still not a change - apply_batch_to_tree is pub, so examples/bench_serde.rs times the real function instead of a replica that would drift - the dead Value-era helpers batch_id, batch_payload, batch_decode and batch_str are gone - docs/serialization-benchmark.md records what was measured and what was rejected, including MessagePack at 1.24x and a hand-packed compact style that measured slower than Box<StyleDesc> once styles are interned Tests: 196 Rust (11 new protocol tests), 340 react, 36 examples, 6 example-app, 9 perf. Session: ses_fc1e82b7bffebLmTFbNQFyGIGP |
||
|
|
848c61779c |
Make the virtualized match offset impossible to half-specify
Follow-up to an oracle review of
|
||
|
|
7a3e35693b |
AGENTS: name the real clean script in the dev server warning
The rebase onto main landed `a9cda59`, which splits the dist wipe out of `build` into a separate `clean` script. My note still described the hazard as `rm -rf dist` / `rimraf dist`, and no `rimraf` exists in this repo. Point at `bun run clean` instead, and say why the split matters: `build` is plain `tsc` precisely so a rebuild does not pull `packages/react/dist` out from under a running web dev server. Session: ses_fc233909fffeHxAGjrf2jkWSet |
||
|
|
5805701c3e |
Explain why automation types into GPUI's hidden element, and drop a duplicate canvas
Follow-up to 965cc48. The selector fix was right, but the code and docs never
said what that element is or why automation touches it at all.
A GPUI web page has two event surfaces:
<canvas> pointer events listen_canvas()
[data-gpui-input] in body keyboard + IME listen_input()
`gpui_web`'s `listen_input` attaches `keydown` and `keyup` to the hidden
element, not to the window and not to the canvas. Dispatching a synthetic
`KeyboardEvent` at that element is therefore the only way anything can type
into a browser GPUIX app, automation included.
That element was a hidden `<input>` until upstream Zed PR #63201 ("gpui: Web
text input improvements") replaced it with a `<textarea>`-backed `ImeMirror`,
because a single-line input silently strips newlines from an assigned value and
desynchronises the mirror from the document.
Our client still queried `input[data-gpui-input]`, so the moment the submodule
moved past that commit every browser keystroke failed. The previous commit fixed
the selector; this one records the cause next to the constant, in AGENTS.md and
in the changeset, so the next GPUI bump does not repeat it.
The error message now names the selector it looked for instead of saying "GPUI
browser input is unavailable", which described neither the element nor the fix.
The editor painted two full-size overlay canvases: `selection_start_region(false)`
and `bounds_tracker(id, None)`. They had identical geometry and adjacent paint
order, and `bounds_tracker` already records a start region when given
`Some(selectable)`. Merged into `bounds_tracker(ctx.id, Some(false))`.
`selection_start_region` had exactly one caller, so it is gone, along with its
comment claiming inputs "do not go through `bounds_tracker`" — which my previous
commit had just made false.
Rust `cargo test --lib` 126 passed. `packages/react` 281 passed, 13 skipped,
including the 27 selection tests that cover caret-vs-document drag behaviour.
`examples` 17 passed.
In Chrome against the dev server, on the chat composer: the mirror is a
`TEXTAREA`, `bounds()` returns a real box, `click()` and
`fill('typed via IME mirror')` both land, and `press('backspace')` leaves
`typed via IME mirro`.
Session: ses_fc233909fffeHxAGjrf2jkWSet
|
||
|
|
ff6daf5878 |
Make <input> and <textarea> reachable from the automation API
Both editors were unusable through the locator API. Every path threw.
await app.getByTestId('composer').click()
// AutomationError: Element has no painted bounds
await app.getByTestId('composer').fill('hello')
// AutomationError: GPUI browser input is unavailable
`automation::bounds_tracker` is what puts an element in the bounds registry,
and `build_element` only attaches it for `<div>` and `<text>`. A custom element
paints itself, so it has no bounds unless its own builder records them.
`<code>` did. The editor did not.
Fixed by attaching the tracker to the editor root, which is already
`relative()`, so the `absolute().size_full()` canvas measures the right box.
The only previous workaround was a hard-coded pixel coordinate, which breaks
the moment the layout moves. That is how I had to drive the chat composer while
testing browser HMR.
`<img>`, `<svg>`, `<anchored>`, `<diff>` and `<markdown>` still have no bounds.
`getByText` works inside them because text registers separately, but
`getByTestId(..).click()` does not. Recorded in the README and AGENTS.md.
`dispatchBrowserKeystroke` looked for `input[data-gpui-input]`. GPUI's IME
conduit is a `<textarea>` (`gpui_web/src/ime_mirror.rs`), because a single-line
input silently strips newlines from an assigned value and would desynchronise
the mirror from the document. The selector could therefore never match, and
every browser keystroke through the automation API failed.
It now matches on the attribute alone, exported as `IME_MIRROR_SELECTOR`, so a
future tag change cannot silently break it again.
`automation.test.tsx` gains a failing-first test that asserts an `<input>` and a
`<textarea>` both report non-zero bounds and that the textarea lays out below
the input. It failed with "Element has no painted bounds" before the Rust change.
`fill()` is browser-only, so it was verified in Chrome against the running dev
server: `bounds()`, `click()` and `fill('filled by automation')` all succeed and
the text appears in `getPaintedText()`.
AGENTS.md records that `bun run build` in `packages/react` must not run while
the web dev server is up. That script is `rm -rf dist && tsc`, and
`packages/react/dist` sits in the dev server's module graph, so deleting it
under a live server permanently corrupts the registry with `Failed to load
bundled module 'packages/react/dist/index.js'` until the server restarts. I hit
this twice.
Session: ses_fc233909fffeHxAGjrf2jkWSet
|
||
|
|
7d6e1f97ed |
Serve the browser example through Bun's dev server with React Fast Refresh
An edit to `examples/chat.tsx` now hot-updates the running page instead of
reloading it. Components swap in place and `useState` survives, so the GPUI
canvas, the composer text and the scroll position all stay where they were.
A reload re-downloads a ~19 MB Wasm module; this avoids that entirely.
`scripts/web.ts` drops the `Bun.build` step and the hand-written static file
map. It now registers `examples/web.html` as a Bun HTML route with
`development: { hmr: true }`, so Bun bundles the page, watches the graph and
applies the React Fast Refresh transform.
Fast Refresh never talks to a renderer directly. It goes through the React
DevTools global hook:
Bun HMR runtime
-> refreshRuntime.injectIntoGlobalHook(window)
@gpuix/react reconciler.ts
-> reconciler.injectIntoDevTools()
-> hands the hook scheduleRefresh + setRefreshHandler
react-refresh
-> performReactRefresh() -> scheduleRefresh(root)
That injection already existed. What was missing was a bundler that runs the
transform, and a test proving the wiring holds.
`examples/web-chat.tsx` must NOT call `import.meta.hot.accept("./chat", ...)`.
Bun runs an importer's dependency-accept callback even when the imported module
already self-accepted for Fast Refresh, so the callback remounted the tree on
top of a successful refresh and wiped every hook. That looks exactly like Fast
Refresh being broken, and it is not.
The Wasm half is a singleton: `WebGpuixRenderer::init` fails with "GPUIX web is
already running" once its thread-local app exists, and gpui_web appends its own
canvas to <body>. Living in node_modules is not what protects it, because Bun
bundles it into the same client registry. What protects it is that Bun re-runs
only the changed module and then walks upward through its importers, so an
unchanged dependency stays evaluated and cached.
packages/native/.cargo/config.toml links with `--shared-memory`, so the
generated `WebAssembly.Memory` is `shared: true` and the document must be
cross-origin isolated. `Bun.serve` has no way to add headers to an HTML route
(oven-sh/bun#16873), so the bundled document is registered on a private path and
re-sent from `/` with COOP + COEP. Only the top-level document needs them:
`require-corp` constrains cross-origin subresources, and every asset the dev
server emits is same-origin.
`packages/react/src/__tests__/fast-refresh.test.tsx` mounts through the GPU test
renderer, clicks to set state, swaps the component family, and asserts the new
output keeps the old state. It fails when `injectIntoDevTools()` is removed.
This matters because the failure mode is silent. Without the injection there is
no error and no page reload: Bun still accepts the update and still calls
`performReactRefresh()`, which iterates zero mounted roots and schedules
nothing. The bundle changes and the painted UI stays stale.
Do not assert on the return value of `injectIntoDevTools()`. It ends in
`hook.checkDCE ? true : false` and react-refresh installs no `checkDCE`, so a
working injection still reports `false`.
- `--build-only` / `--rebuild` / auto-build-if-missing in scripts/web.ts, and
the missing check tests both wasm-bindgen outputs, not just the `.wasm`
- `examples/tsconfig.json` gets `noEmit: true`; nothing there is ever compiled
by tsc and a stray `tsc -p .` littered a `.js` next to every `.tsx`
- `@types/bun` as a root devDependency plus a `typeRoots` entry so
`scripts/tsconfig.json` types `Bun.serve` and the `*.html` bundler import
- README documents that automation locator bounds are canvas pixels, not CSS
pixels. Do not use `window.devicePixelRatio`; a tool can override the viewport
scale after GPUI has sized its canvas. Use `canvas.width / canvas.clientWidth`
Validated in Chrome with playwriter: text edits and a module-level colour
constant edit both repaint the canvas with state preserved, and a revert
restores a byte-identical screenshot.
Session: ses_fc233909fffeHxAGjrf2jkWSet
|
||
|
|
4006d9998e |
Make "thin layer on GPUI" the first rule, and drop "stay close to HTML"
**New first section: GPUIX is a thin layer on GPUI** Read the GPUI docs and the GPUI source before writing native code. The submodule is right there. When a GPUIX element needs something GPUI does not do, the order is: find the existing GPUI API, search zed issues and PRs, fix it in the `remorses/zed` fork, and only then add GPUIX code. Never paper over GPUI in `packages/native`. Anything that re-applies state after GPUI computed it, patches a value GPUI owns, or reaches around a GPUI invariant breaks on the next submodule bump. When unavoidable, the comment must say what GPUI does, why that is not what GPUIX needs, and which GPUI call makes it safe. **Removed: Stay close to HTML** "GPUIX should feel like HTML and the DOM" invited exactly the kind of change the new rule forbids: reaching for browser behaviour as the justification instead of the GPUI API. GPUI is the reference now. The concrete knowledge in that section survives as **Mouse capture is armed by the press**, described in terms of GPUI's window-level mouse listeners rather than `setPointerCapture`. Session: ses_fc16226ffffeOn4QTVIbw4K7Nz |
||
|
|
8aef4384aa |
Number virtualized highlight matches from an app-supplied offset
`activeIndex` promised "the nth match in the document" and could not keep that
promise inside a `<virtual-list>`. The list mounts a window of its rows, ordinals
are handed out during paint, and paint only ever sees that window, so the
sequence restarted at 0 on the first mounted row.
The documented pattern in the README was wrong because of it. It counted matches
across all rows with `findRanges`, then fed that document-wide cursor straight
back as `activeIndex`, which native read as an index over the painted rows:
hits [{"t":"fox 50","a":false},{"t":"fox 51","a":false},
{"t":"fox 52","a":false},{"t":"fox 53","a":false}]
`activeIndex: 52` over a window starting at row 50 marked nothing at all. Shift
the window and it marks an arbitrary visible row instead. There was no test for
`highlight` together with `<virtual-list>`, which is why it shipped.
indexOffset
-----------
The app owns the window, so the app says where it starts. `indexOffset` is the
number of matches above the mounted rows, and the per-frame ordinal cursor begins
there instead of at 0:
document mounted window paint
match 0..49 above the window, indexOffset = 50
match 50 ─────────────► row 50 ─────────► ordinal 50
match 51 ─────────────► row 51 ─────────► ordinal 51
match 52 ─────────────► row 52 ─────────► ordinal 52 <- active
match 53 ─────────────► row 53 ─────────► ordinal 53
match 54.. below, never built
This is the same contract `<virtual-list>` already has with `windowStart` and
`itemCount`: native cannot know what it never built, so the app tells it.
A cursor outside the window now marks nothing, which is correct. It used to mark
whatever row happened to be at that position in the viewport.
`index_offset` is excluded from `matcher_hash`, exactly like `active_index`, so
scrolling a find bar never rescans or re-folds any text.
`useTextSearch` takes it through as an option, next to the `total` it already
accepted for the same reason.
Testing
-------
The React test renders a 1000-item list with `windowStart={50}`, scrolls to row
50 so the rows really paint, and asserts `activeIndex: 52` lands on "fox 52".
Without the offset that assertion finds no active wash at all. A second case
asserts a cursor past the window marks nothing.
Three native tests cover the offset shifting the whole sequence, a cursor above
the window, and `matcher_hash` ignoring the offset.
The `highlight` prop has not been released, so this updates its pending changeset
rather than adding a fix entry for an API nobody has.
Session: ses_fc15ed901ffedvtR2Z3cCZPIAR
|
||
|
|
12fb3449c7 |
Simplify the highlight internals, no behaviour change
175 native tests, 336 react, 36 examples. Find-bar timings are unchanged:
keystroke p50 1.95ms, cursor p50 1.89ms on the 1000-turn chat.
Identify a declaration by its element id, not by an Arc address
---------------------------------------------------------------
`declaration_id()` read `Arc::as_ptr(&ctx.set)` as the per-frame identity of a
`highlight` declaration, guarded by a comment arguing the allocation stays alive
for the whole frame so the address cannot be reused. That is a real invariant to
have to hold in your head, and it was never necessary: the declaring element id
was already sitting in `Inherited` right next to the context.
`HighlightContext` now carries `declaration: u64` and `declaration_id()` is gone.
Three things fall out of that:
- `HighlightContext.set` was `Arc<HighlightSet>` only so the pointer trick had
something stable to point at. Nothing ever shared it; only `matches` is
shared, when a colour or `activeIndex` change reuses the located matches. It
is a plain value now, one less allocation per resolve.
- `Inherited.highlight_declaration` is gone. It was a second field that had to
agree with `highlight`, and `build_virtual_child` reads
`inherited.highlight.declaration` instead. Two fields that must agree is a
state where they can disagree.
- The ordinal maps key on `u64` instead of a pointer-sized `usize`.
Fold `ordinal()` into `wash()`
------------------------------
Two functions with one call site each, always called together, where the first
returned a number the second immediately consumed. They are one function now, so
the ordinal cannot be computed without producing the wash it belongs to.
`MatchId` still moves into the slot tuple once, so a `Native` id does not clone
its `Arc<str>` on the lookup.
Straighten resolve_highlight
----------------------------
Its three match arms each ended by returning `(context, identity, total)`, and
each computed `identity` and `total` the same way from `matches`. The match now
yields just the context, and the two derived values are read once afterwards,
inside the branch that actually needs them. `build_element` loses a two-arm
`match` that assigned the same two fields in both arms.
One paint call site
-------------------
`selectable_text` matched on `HighlightSource` and called
`paint_highlight_washes` with identical arguments from both arms. The match
yields the washes instead. `Vec::new()` does not allocate, so the `None` case
still costs nothing.
Session: ses_fc15ed901ffedvtR2Z3cCZPIAR
|
||
|
|
691ca1fe26 |
Test the todo starter through the automation client
`example-app` had a `screenshot.ts` and nothing that asserted behaviour.
`app.test.tsx` mounts `TodoApp` on the GPU test renderer and drives it
with the same locator API, in-process, with no child window:
const { render, renderer } = createTestRoot({ width: 940, height: 660 })
render(<TodoApp />)
const app = await connectTest(renderer)
await app.getByTestId('view-inbox').click()
await app.getByTestId('row-t5').hover()
await app.getByTestId('delete-t5').click()
Five tests: the active view paints every row, delete removes the hovered
row, the composer prepends, a completed task moves between views, and a
task added past the viewport height stays on screen.
**`app.tsx` changes**
`render()` now sits behind an entry-point check, the same one `chat.tsx`
uses, so importing the file does not open a window.
Rows carry `row-<id>`, `toggle-<id>`, `star-<id>` and `delete-<id>`. The
trash button only exists while the row is hovered, so a test must
`hover()` before it can `click()`.
**Why `getPaintedText()` and not `getAllText()`**
Both would work here, since every row is a `<text>`. `getPaintedText()`
is the one that proves the row is actually on screen, which is the whole
point of the fifth test.
Session: ses_fc16226ffffeOn4QTVIbw4K7Nz
|
||
|
|
01f5788f0f |
Keep a virtual-list pinned to the top when rows are prepended
`gpui::ListState` anchors on a **logical item**, and `splice_focusable`
shifts that anchor by the number of rows inserted before it. A prepend
therefore keeps the rows already on screen and pushes the new one above
the viewport. Correct for a history pane, wrong for a feed.
A browser anchors the same way and suppresses it at `scrollTop: 0`.
`VirtualListEntry::sync` now copies that: it remembers a top-aligned,
non-`followTail` list whose `logical_scroll_top()` is `{0, 0}` and calls
`scroll_to(default)` after the splice. Scrolled anywhere else, the rows
under the pointer still do not move.
**How it looked**
`example-app` prepends the newest task. Adding through the composer:
add 1..10 top row = task N offset 0 looks fine
add 11 top row = task 11 offset -32 content now overflows
add 12 top row = task 11 offset -76 new row is off screen
add 20 top row = task 11 offset -428 list looks frozen
**Why nobody caught it**
While the content is shorter than the viewport, gpui's "does not fill"
branch walks back up to item 0 and re-anchors there on every layout, so
the drift is invisible. It appears on the frame the list first overflows.
The regression test grows a 160px list from 2 rows to 12 rather than
starting tall, and a second test asserts the anchor still holds below the
top.
Session: ses_fc16226ffffeOn4QTVIbw4K7Nz
|
||
|
|
c444c28ebf |
Number highlight matches in paint order
A review found `activeIndex` pointing at the wrong match whenever a native
element and retained text sat under the same declaration.
`resolve()` numbered every retained match first, then native runs continued
after those counts. That is not document order:
<div highlight={{ query: 'x', activeIndex: 0 }}>
<code code="x" /> paints first, but was numbered 1
<text>x</text> paints second, but was numbered 0 -> active
</div>
Only paint knows the order. Retained matches are located before the frame,
native text exists only during it, and a subtree can interleave them. So
ordinals are now allocated in `search::ordinal` during paint, from one
per-frame sequence per `(declaration, spec)`, for both kinds.
Two things in there look redundant and are not. `MatchId::Retained` carries the
build-time index, so a match split over several interpolated runs takes exactly
one ordinal instead of two. The `assigned` memo makes a row gpui paints twice
keep the numbers it got the first time rather than advance the cursor again.
Deciding colour at paint made `ResolvedHighlights` and `colorize()` pointless:
the cache now holds one `HighlightContext { set, matches }` that serves retained
and native runs alike, and `NativeHighlight` with its per-spec offsets is gone
along with `MatchSet::per_spec`.
onHighlight after the first render
----------------------------------
`reported` was written even when the element had no listener, so mounting
without `onHighlight` and adding it later reported nothing, ever. The listener
is now passed into `resolve_highlight`, which only records `reported` when it
actually queues an event.
Virtual rows re-resolve
-----------------------
`build_virtual_list`'s `cx.processor` closure captured the resolved ranges, but
gpui calls it during layout and prepaint, after the root render returned. On
Windows and Linux the Node thread can commit new text in that window, so the row
would paint a wash over the wrong glyphs, or at a byte offset that is no longer
a character boundary. `Inherited` now carries the declaring element id and
`build_virtual_child` re-resolves against the tree it just locked.
Word boundaries agreed with Rust
--------------------------------
`findRanges` used `\p{L}\p{N}_` while Rust uses `char::is_alphanumeric`, which
is the Unicode Alphabetic property. `"foo\u0345"` with `wholeWord` matched in JS
and not in Rust, so a virtual-list count could disagree with what native painted.
The JS class is now `\p{Alphabetic}`.
Two smaller fixes
-----------------
An explicit range covering only the newline between two groups counted as a
match while painting nothing, shifting every later ordinal. It only counts when
it intersects a group now.
`GroupList::collect` took `content` from any element type, but `build_custom`
renders from props and ignores `content`, so `setText` on a custom element could
invent matches that never paint. Own content is collected for `div` and `text`
only, matching what the renderer paints.
Testing
-------
169 native, 307 react, 17 examples. New coverage for paint-order across both
kinds in either arrangement, a split match taking one ordinal, a listener added
after the first render, and combining marks at a word boundary.
Session: ses_fc21bea16ffe1AkeGWr9j3cQTa
|
||
|
|
bb138bafd9 |
Add a highlight prop for text search
A `highlight` prop on any element paints a background wash behind matched or
explicitly given text ranges. It scopes to the declaring element's subtree, so
the root searches the window and a container searches that container. Nothing
resolves and nothing paints unless an element declares one.
<div highlight={{ query: 'fox' }}>
<text>the quick brown fox</text>
<code code={source} language="ts" />
</div>
Every string GPUIX paints already funnels through `crate::text`, so one hook
there reaches `<text>`, `<code>`, `<markdown>` and `<diff>` with no per-element
props. `useTextSearch` drives a find bar; `findRanges` is the same matcher in JS
for row data a virtual list never mounted.
There is deliberately NO joined document for a query
--------------------------------------------------
`shouldSetTextContent` is false, so React makes a separate host text node for
every interpolated string. `<text>Hello {name}!</text>` is three painted runs of
one logical line, and a per-node matcher cannot find `Hello Tommy`. So text has
to be merged before matching.
Merging the WHOLE subtree into one string was the obvious approach and it is
wrong: a 5k-row chat becomes one megabyte string, and the query is part of the
cache key, so a find-bar keystroke rebuilds it. Instead `GroupList::collect`
merges only what React split: adjacent primitive text children of one parent.
A match never crosses a group, which is what a browser does across a paragraph
boundary. A joined document is built only when a spec supplies explicit
`ranges`, because only then does anyone index into it.
Grouping is structural. It never reads `display`, which only knows `flex` and
`grid` here, and every text node already sits inside a `div`.
Two caches, and why search_revision exists
------------------------------------------
GroupList keyed by search_revision (walk + fold)
MatchSet keyed by search_revision + matcher (scan)
washes derived colorize() (O(matches))
`highlight` is itself a custom prop, so `subtree_revision` moves on every
keystroke. Keying the group list on it makes the cache do nothing for the one
case it exists for. `search_revision` moves on text, structure, and a nested
declaration appearing or disappearing; not on style, not on a native element's
own props, not on the query.
`matcher_hash` excludes `activeIndex` and the colours, so moving a find cursor
only re-colours matches that were already located.
A timing budget cannot police this. On the 1000-turn chat the broken version is
2.7ms against 1.9ms, because most of that text lives in native element props
rather than retained `<text>`. `highlight_cache_tests` in `renderer.rs` compares
`Arc` identity instead and fails outright.
Native elements match their own painted string
----------------------------------------------
`<code>`, `<markdown>` and `<diff>` build text inside `render()`, so the
build-time resolver never sees it. Rather than a second traversal that
re-derives their text and `sub` values, which would drift from markdown's
render-time counter, they match the exact string they are about to paint
through `washes_for_native_run`.
Their matches share one ordinal sequence per declaration, continuing after the
retained matches via `NativeHighlight::offsets` and memoised per run so a row
gpui paints twice keeps its numbers. Numbering each run from zero made
`activeIndex: 1` mark the second match of every code line active.
Copy grouping, and destroy_element
----------------------------------
Two pre-existing bugs surfaced while building this, each with its own changeset.
`join_spans` put a newline between every span, so copying the interpolated line
above yielded `"Hello\nTommy\n!"`. Runs now carry a group and same-group runs
join with nothing. Copy and search share `search::group_id`, because deriving
one from `element.parent` and the other from adjacency silently diverges when a
non-text sibling sits between two leaves.
`destroy_element` never unlinked the child from its parent's `children` and
never called `mark_changed`. React sends `removeChild` first so it was hidden,
but the batch and napi APIs both allow a direct destroy.
Contract
--------
Matches are non-overlapping and leftmost-first. Case-insensitive matching uses
Unicode lowercasing, not full case folding, so `ff` does not match `ff`; `fold`
carries an offset map because lowercasing changes byte length. Explicit ranges
are UTF-16 code units and a boundary inside a surrogate pair is rejected, never
snapped. A word boundary is any code point that is not a letter, a digit, or
`_`. `userSelect: "none"` does not opt out of search, because a browser still
finds that text; only real chrome does.
`onHighlight` is queued during build and flushed after the root build returns,
keyed on `MatchSet::identity()`. Emitting inline lets a `setState` in the
handler re-enter the build; keying on the count alone misses a query swap that
finds the same number of hits.
Testing
-------
A wash is a quad, so `getPaintedText()` cannot see it. `getPaintedHighlights()`
reports the matched range in UTF-16 units plus the boxes drawn, one per visual
row, on all three renderers including the browser build.
167 native tests, 305 react, 17 examples.
Known limits, documented rather than hidden: `onHighlight` counts retained text
only, and a `<virtual-list>` row that was never built cannot be matched,
counted, or scrolled to. `findRanges` exists for exactly that.
Session: ses_fc21bea16ffe1AkeGWr9j3cQTa
|
||
|
|
555fa30af9 |
Measure the real pan cost, and fix the timeline review findings
The pan perf test never called `flush()`, so it timed the React update and none of the GPUI build, layout and paint that follows. That hid the single most important fact about a pannable surface. | Wheel pan, 3,259 clips over 26 tracks | p50 | |---|---| | Culled | **7.7 ms** | | `memo(ClipLayer)` only, no culling | **92 ms** | | `memo` only, as the old test reported it | 0.6 ms | `<virtual-list>` is the only thing that virtualizes. A surface that owns its own offset places children absolutely, so GPUI lays out every retained child every frame. `memo` and culling fix different halves: ``` memo(Layer) ► cuts React work and the applyBatch mutations cull in JS ► cuts GPUI build, Taffy layout, and paint ``` The cull-off case now has a budget too, so the culled path cannot quietly drift into it, and the drag samples use the new unflushed `dispatchMouseMove` so one sample is one frame, like the pan. Three correctness fixes in the example: - a clip could be dragged past the last frame of the project, where nothing could reach it again. Both the wanted position and the snapped position clamp now, and a snap the clamp overrode no longer paints a guide - the playhead could scrub past both ends - collapsing a track only hid its clips and left an empty band. The row now shrinks to an 18px header strip and everything below moves up Documentation corrections, all from the same review: - `AGENTS.md` still said to keep `occlude()` for absolute overlays, which contradicts the rule three sections down - `BlockMouseExceptScroll` is not DOM bubbling. GPUI hit-tests one flat list of painted boxes, so the wheel reaches **any** scroller behind the element, not only an ancestor. A new test covers an absolute card over an unrelated sibling scroller - `pointerEvents: "none"` stops the element blocking hitboxes behind it. It does not disable that element's own listeners New README material worth keeping: the culling section above, the left-button capture rule, and the readout pattern. Every timeline test paints its state into a `<text testId>` and asserts an exact number, which survives a layout tweak far better than a screenshot diff. Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT |
||
|
|
3d759b21c3 |
Drop the VirtualList wrapper, and fix the starter after review
Oracle review of
|
||
|
|
813ece49e0 |
examples: a video-editor timeline
```bash cd examples && bun --hot timeline.tsx ``` The question was whether GPUIX can carry a real editing surface, so the example drags clips between tracks, trims both edges with snapping, scrubs a playhead, marquee-selects, zooms under the pointer, and pans on both axes with a frozen ruler and a frozen track column. Data is mocked from a seeded generator, so every run paints the same project. ``` ┌──────────────────────┬─────────────────────────────────────────────┐ │ tools 00:03:50 │ Ruler 0 5 10 15 20 25 │ ◄ pans X ├──────────────────────┼─────────────────────────────────────────────┤ │ Caption │ ▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓▓ │ │ ▸ Track 10 │ █████████████ ▎playhead │ ◄ pans X and Y │ ▸ Track 9 │ ▓▓▓▓ ▓▓ │ │ Audio 1 │ ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿ │ │ ▲ pans Y │ │ └──────────────────────┴─────────────────────────────────────────────┘ ``` Two patterns in it are worth copying. **React owns the scroll offset.** A native `overflow: "scroll"` grid cannot drive a frozen header: GPUI moves the grid on the wheel frame, and the `onScroll` callback that would move the ruler lands a frame later, so the two tear apart during a fast pan. One `onScroll` listener on a non-scrolling parent keeps `scrollX` and `scrollY` in state, and all three panes translate from the same numbers. Zed does the same. A media-bin panel beside the preview stays on the native two-axis path for comparison. Each translating wrapper sets `pointerEvents: "none"`, because a positioned box takes hits with no fill and would otherwise swallow every press meant for the ruler or the empty grid behind it. **A drag needs no overlay.** Every clip, trim handle, the ruler, the grid and the zoom slider carry `onMouseDown`, `onMouseMove` and `onMouseUp`, which arms GPUI's pointer capture, so a release past the window edge still ends the gesture. A full-window overlay mounted on the press cannot do that: capture is armed by the press, and the overlay does not exist yet at that moment. I built the overlay first and it failed exactly that test. `timeline.test.tsx` drives all of it through the automation API and asserts exact numbers read from three `<text testId>` readouts, not screenshots. `timeline.perf.test.tsx` times mount, pan and drag on a 3,259-clip, 26-track project: | Sample | p95 | |---|---| | mount | 131ms | | pan, culling on | 1.90ms | | pan, culling off | 0.64ms | | drag move | 11.7ms | Drag samples are not comparable to pan samples. `nativeSimulateMouseMove` flushes before and after the event, so each one contains two complete GPUI paints. Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT |
||
|
|
d96413d4c2 |
Let the wheel pass under absolutely positioned items
Four hit-testing and style corrections that a pannable canvas needs. **Absolute no longer steals the wheel.** `position: "absolute"` and `"fixed"` used `occlude()`, which is `BlockMouse` and ends the hit test, so an ancestor's `onScroll` never fired. That made a pannable surface impossible: a timeline clip, a graph node, any absolutely placed item swallowed the gesture before the pan listener ran. HTML does not behave that way either. Now only `pointerEvents: "auto"` uses `BlockMouse`; everything else that occludes uses `block_mouse_except_scroll()`, so clicks and hovers still stop but the wheel reaches the scroller. `<anchored>` occludes through its own `occlude` prop, so menus and tooltips never depended on the old rule. `floating-controls.test.tsx` imported `hasNativeTestRenderer` from `../index`, which does not export it, so all 13 overlay tests were silently `describe.skip`. Fixed, and they pass against the new rule. **Every CSS cursor GPUI can paint.** Only `pointer` and `default` worked, so `col-resize` on a trim handle was dropped. `cursor` is now a typed union in `StyleDesc`. `nwse-resize` maps to `ResizeUpLeftDownRight` following the macOS implementation, not the GPUI doc comment, which names the opposite CSS value. **`position: "fixed"` lays out.** It blocked hits like `absolute` but stayed in normal flow, so a box drifted when its siblings changed. Taffy has no viewport-fixed mode and GPUI has no scrolling document, so the two are the same layout. **Two-axis scroll moves both axes.** GPUI zeroes the smaller of the two deltas unless `allow_concurrent_scroll` is set, so one diagonal wheel moved one axis. A browser moves both, and a two-axis container is exactly where a user expects it. Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT |
||
|
|
d1d58e0f81 |
Run the GPU test renderer on Windows
`TestGpuixRenderer` hardcoded `gpui_macos::MacPlatform`, so a Windows user could install `@gpuix/native` and still not write a test for their own app. It now asks `gpui_platform::current_platform()`, which picks Metal on macOS and DirectX on Windows, and `render_to_image` works on both. | Platform | Test renderer | PNG capture | |---|---|---| | macOS | Metal | Yes | | Windows | DirectX | Yes | | Linux | Not yet | Waiting for GPUI's wgpu headless renderer | Screenshots on Windows go through the dedicated UI thread, so `captureScreenshot` sends a `CaptureScreenshot` command and waits for the next frame rather than reaching into the window from Node. `getWindowSize` on that same thread answered a hardcoded 800x600. Every caller that turned a mouse position into layout coordinates therefore pointed at the wrong place on any window that was not exactly that size. It now reads `window.viewport_size()`. CI stops loading the binding and calling it a day. `test-windows` now installs dependencies, renders one element through `TestGpuixRenderer`, and runs the full `packages/react` and `examples` suites on Windows. `build:release` gains `--no-default-features` so the stripped build really drops test support, while the published default build keeps it. Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT |
||
|
|
0e5e9f1efd |
Tell agents to search Zed before touching GPUI
This session hit a GPUI bug and started writing a fork patch before finding that upstream had already shipped the fix. Zed is large and moving, so the answer is often an existing issue, an open PR to wait for, or a merged PR that only needs a submodule bump. Also records the gh search flags that actually work. gh mangles the inline form: repo: inside the query fails outright, and in:title with repo: silently drops the repo filter and returns unrelated results. Use --repo and --match instead. Session: ses_fd13bc1a1ffecH1QUVugD0YnOj |
||
|
|
052a8b4d14 |
Use GPUI pointer capture instead of a GPUIX workaround
GPUI now rebinds capture_pointer across frames by element identity (86a16bb4c1 on the gpuix fork). A node with onMouseDown and onMouseMove calls el.capture_pointer() and lets GPUI route move and up, including after a remount or after the pointer leaves the box. If the captured node is not painted, GPUI releases capture so a replacement target can receive the rest of the gesture. While captured, only that hitbox is hovered, so siblings do not get hover-gated events. Remove the thread-local POINTER_CAPTURE state, the extra canvases, and the handler gates. AGENTS.md now says: stay close to HTML names, but follow GPUI when it differs. Do not fight or work around it. Session: ses_fc816ec2fffe1mkkD36l3L2dDC |
||
|
|
5b2dfff12b |
Capture the pointer when a node listens for mouseDown and mouseMove
HTML setPointerCapture keeps move and up on the same element after the pointer leaves the hitbox. GPUIX now does that automatically when one node has both onMouseDown and onMouseMove. GPUI capture_pointer stores a per-frame HitboxId. A remount after mouseDown allocates a new id, so this tracks the stable GPUIX element id and re-registers window listeners each paint. Hover-gated move and up are suppressed while any capture is active, so siblings do not also receive the drag. Capture is released on bubble mouseUp, and cleared when a renderer or GpuixView is created so tests do not leak state. A node with only mouseDown and mouseUp does not capture. Release outside still cancels the click. A full-window overlay is no longer required for clips, resizers, or sliders. The overlay remount path still works. Fixes #20 Session: ses_fc816ec2fffe1mkkD36l3L2dDC |
||
|
|
4f3254ab03 |
Pin GPUIX to the reusable root key API
Advance the zed submodule to the reachable remorses/zed gpuix commit that stores window root-key listeners in frame paint data and preserves them across cached view reuse. Replace the earlier global-key call with Window::on_root_key_event and keep browser copy on the same compact GPUI listener used by desktop, without a GPUIX document listener or extra web-sys features. Track the gpuix fork branch in .gitmodules and document that reusable GPUI fixes belong there when they remove GPUIX workarounds. Keep gpui-macos-embedded limited to the focused upstream AppKit PR. Validation: GPUI cached-view regression, BrowserWebGPU copy and paste round trips, Wasm release build, 124 native tests, 26 React input and selection tests, 11 chat tests, rustfmt, and lintcn. Session: ses_fcaa8e467ffeRyHjx14CICsOfM |
||
|
|
188ce2ac29 |
Route copy shortcuts through the GPUI dispatch root
Use the new GPUI root-level key listener so selectable text receives copy shortcuts regardless of which child owns focus. Document the fork-first workflow for reusable GPUI platform fixes and advance the embedded fork to the pushed key-dispatch API. Validation: GPUI root-listener test, 124 native tests, native and Wasm release builds, and Playwriter verification of BrowserWebGPU, sidebar, model picker, automation, scrolling, and Syntect token colors. Session: ses_fd23d320cffeTXMP6XvLBmNC2a |
||
|
|
3cc4196d87 |
Replace Tree-sitter syntax highlighting with Syntect fancy-regex.
Native <code>, <diff>, and Markdown fences still detect language the same way (fence tag, then path, then shebang) and still cache HighlightKind spans, not colours. Theme recolouring does not reparse. The highlighter walks Syntect scope stacks and maps them onto the existing HighlightKind contract. Ambiguous TextMate scopes are refined from token text so JSON null is Constant, Rust let stays Keyword, and u32 is TypeBuiltin. Tree-sitter, tree-sitter-highlight, and the per-language C grammars are gone. Syntect uses the pure-Rust fancy-regex engine. two-face supplies TypeScript, TSX, and TOML, which the default Syntect dump does not include. JSX uses the TypeScriptReact grammar because the fancy pack excludes JavaScript (Babel). ``` source ► detect LanguageId ► Syntect ParseState (fancy-regex) ► scope stack ► HighlightKind ► theme.syntax.color(kind) ``` Session: ses_fcbc00d64ffedGMZjKNDlNkwQw |
||
|
|
f4cde938e4 |
Tell external contributors to open an issue, not a pull request.
AGENTS.md now starts with a remorses check and replaces Contributing with External contributors. Unsolicited PRs will be closed. A PR is OK only after remorses says so on the issue. The section also copies the repo rules outsiders miss: do not edit generated napi files, do not publish locally, do not touch the zed submodule, use release native builds, run bun run test, add a changeset, and include harness agent model plus every user prompt in a GPUIX PR body. The old gpuix/packages/native path is now packages/native. Session: ses_fcb34222fffeedDTVhecHbRYvw |
||
|
|
5a92f023a1 |
Attach a standalone chat example binary to each GitHub release.
CI already built @gpuix/native for every napi target. Each of those jobs now also compiles examples/chat.tsx with bun build --compile against that target's .node, and uploads example-chat-<rust-target>. On a main push, publish attaches those files to the existing @gpuix/react@x.y.z GitHub release. compile-chat.ts now accepts CI env: - COMPILE_OUT names the binary (Windows still gets .exe) - COMPILE_TARGET is the bun compile target, e.g. bun-darwin-arm64 - COMPILE_SKIP_ICONS and COMPILE_SKIP_APP skip rsvg/magick and the macOS .app wrap, which CI runners do not have Icons are also skipped when rsvg-convert or magick is missing. The Windows icon is optional. A compiled binary starts because chat.tsx treats Bun.isStandaloneExecutable as an entry point, not only Bun.main. The publish job downloads only bindings-* for npm, then example-chat-* for the release upload. Missing release tags or empty artifacts skip instead of failing the publish. README and AGENTS document the download names. The changeset is a react minor. Session: ses_fcb34222fffeedDTVhecHbRYvw |
||
|
|
f1d320d898 |
Merge origin/main into local main.
Incoming brought color functions, per-side borders, live mouse on Windows and Linux, automation session hardening, custom-element lifecycle, and the canonical CI example suite. Conflict resolutions - ci.yml keeps the incoming React + examples test steps. The examples suite already runs chat.perf.test.tsx, so the extra chat-only step is not needed. - renderer.rs keeps incoming recv_ui_response, painted element_bounds, cross-platform mouse and clock control, and the unsupported-OS errors. Local windowed VirtualList and selection start regions stay. The zed submodule stays on 4d80927. Incoming pointed at the older Windows delay-load commit. Overlay stats and the Minimal-mode fix need the newer pointer. Session: ses_fccd34c30ffejLDt6w20KI87xR |
||
|
|
a7d0f9b0fc |
Document release builds, changeset rules, and how to open PRs.
AGENTS.md now states - Never ship or start the app on a debug native build. GPUI paint is many times slower and looks like an app bug. Rebuild release after any debug build before judging frame time. - Always add a changeset before the work is done. Fixes and Closes lines belong in the changeset so changepub can copy them. - A PR to Zed is upstream zed-industries/zed main, from a worktree, never from this submodule checkout. - A PR to remorses/gpuix must name harness, agent, and model, then include every user prompt in a details block. CLAUDE.md points at AGENTS.md so Claude Code loads the same guide. Session: ses_fccd34c30ffejLDt6w20KI87xR |
||
|
|
0298194458 |
Simplify the chat performance test and throttle helpers.
The suite was logging React production vs development, implied FPS, and mean times. Those numbers did not change the budgets. Overlay stats include the setup flush after reset, so they are logged only. A throttled run still skips the default budgets. Also - readMacCpuThrottle() only reads THROTTLE. GPUIX_THROTTLE is gone. - profile-chat-scroll.tsx times dispatchScrollWheel(), not a later flush(). The wheel already draws. - Drop the NODE_ENV=production notes from AGENTS.md. Development React stays the default so warnings remain. Session: ses_fccd34c30ffejLDt6w20KI87xR |