Commit Graph
74 Commits
Author SHA1 Message Date
Tommy D. Rossi 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
2026-09-21 10:40:12 +02:00
Tommy D. Rossi 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
2026-09-20 15:51:25 +02:00
Tommy D. Rossi 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
2026-09-07 17:49:32 +02:00
Tommy D. Rossi 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
2026-09-04 17:40:20 +02:00
Tommy D. Rossi 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
2026-09-04 17:40:04 +02:00
Tommy D. Rossi 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
2026-09-04 17:39:31 +02:00
Tommy D. Rossi 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
2026-09-03 20:50:12 +02:00
Tommy D. Rossi 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
2026-09-02 13:46:11 +02:00
Tommy D. Rossi 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
2026-09-01 15:25:41 +02:00
Tommy D. Rossi 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
2026-08-29 16:59:30 +02:00
Tommy D. Rossi 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
2026-08-28 14:48:39 +02:00
Tommy D. Rossi 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
2026-08-28 13:11:26 +02:00
Tommy D. Rossi 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
2026-08-28 10:51:43 +02:00
Tommy D. Rossi 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
2026-08-28 09:54:01 +02:00
Tommy D. Rossi 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
2026-08-28 09:54:01 +02:00
Tommy D. Rossi 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
2026-08-28 09:54:01 +02:00
Tommy D. Rossi 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
2026-08-27 17:23:25 +02:00
Tommy D. Rossi 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
2026-08-27 13:29:05 +02:00
Tommy D. Rossi 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
2026-08-27 13:29:05 +02:00
Tommy D. Rossi 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
2026-08-26 21:50:55 +02:00
Tommy D. Rossi 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
2026-08-26 19:46:16 +02:00
Tommy D. Rossi 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
2026-08-26 19:41:40 +02:00
Tommy D. Rossi 848c61779c Make the virtualized match offset impossible to half-specify
Follow-up to an oracle review of 8aef438. The offset was correct but easy to
misuse in three distinct ways, all of which fail silently as a find cursor on
the wrong row.

Name it after what it counts
----------------------------

`indexOffset` -> `matchIndexOffset` on `HighlightSpec`, `index_offset` ->
`match_index_offset` in Rust.

It counts MATCHES above the window, never rows. `windowStart` is right next to
it in a `<virtual-list>` and is a row index, so the old name invited exactly the
wrong value. Rows hold different numbers of matches, so a row index can never
stand in for it.

Kill the invalid state in useTextSearch
---------------------------------------

`total` and `indexOffset` were two independent options and the doc claimed the
offset was "ignored when `total` is unset". It was not: the hook always passed
it, so this made EVERY active wash disappear.

    useTextSearch({ query: 'fox', indexOffset: 50 })

Native numbers the visible matches from 50. The hook still takes its count from
`onHighlight`, say 4, and clamps `active` into `0..3`. No ordinal can ever match,
so nothing is ever active.

The two are one concept, so they are one object now:

    matches?: { total: number; indexOffset: number }

Native counts and numbers the SAME window, so an app that overrides one must
override the other. Supplying one alone is now unrepresentable rather than
documented-as-ignored.

Refuse a malformed offset instead of reading it as zero
-------------------------------------------------------

`as_u64().unwrap_or(0)` turned a negative, fractional or string value into 0.
That is the worst possible outcome: every match renumbers and the only symptom
is the cursor landing somewhere else.

A present-but-malformed `matchIndexOffset` now logs a warning and rejects the
whole spec, so nothing paints and the reason is in the log. `null` and an absent
key still mean 0.

Prove the contract is a match count
-----------------------------------

The virtual-list test used one match per row, so the row index and the match
index happened to be equal and a row offset would have passed it. The new test
uses rows holding 2, 2, 1, 0 and 3 matches with the window starting at row 2,
where 4 matches sit above. Offset 4 and `windowStart` 2 are deliberately
different, and the test asserts that passing 2 marks nothing.

Two docs claims that were too strong
------------------------------------

The changeset said native and `findRanges` "can never disagree". They agree only
on the SAME string. They differ when a row holds several structural groups but
the app scans one concatenation, when the row is `<markdown>` whose painted runs
are not its source, and on lone UTF-16 surrogates that a Rust string cannot hold.
Both the README and the changeset now say the app must call it on the same
logical lines native paints.

Testing
-------

31 react highlight tests, 180 native, 36 examples, 5 example-app. Highlight perf
unchanged: keystroke p50 2.65ms, cursor p50 2.21ms.

`cargo test --lib` cannot run in this checkout right now; another session has
`renderer.rs`, `retained_tree.rs` and `test_renderer.rs` mid-edit and the lib
test target does not compile. All 8 errors are in those three files and none in
`search.rs`. The suite was green at 180 before that landed.

Session: ses_fc15ed901ffedvtR2Z3cCZPIAR
2026-08-26 18:19:04 +02:00
Tommy D. Rossi 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
2026-08-26 17:59:44 +02:00
Tommy D. Rossi 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
2026-08-26 17:59:44 +02:00
Tommy D. Rossi 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
2026-08-26 17:59:44 +02:00
Tommy D. Rossi 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
2026-08-26 17:59:44 +02:00
Tommy D. Rossi 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
2026-08-26 17:57:41 +02:00
Tommy D. Rossi 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
2026-08-26 17:36:18 +02:00
Tommy D. Rossi 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
2026-08-26 17:35:49 +02:00
Tommy D. Rossi 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
2026-08-26 17:31:00 +02:00
Tommy D. Rossi 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
2026-08-26 17:30:38 +02:00
Tommy D. Rossi 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
2026-08-26 17:08:19 +02:00
Tommy D. Rossi 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
2026-08-26 17:08:19 +02:00
Tommy D. Rossi 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
2026-08-26 16:59:05 +02:00
Tommy D. Rossi 3d759b21c3 Drop the VirtualList wrapper, and fix the starter after review
Oracle review of 7d45d36 plus a bug the review turned up.

**Remove `VirtualList`**

The windowed wrapper is gone from `@gpuix/react`. Windowing on the React side
is application state: pass `itemCount`, `estimatedItemHeight` and `windowStart`
to the host `<virtual-list>` and render only that slice.

A generic wrapper cannot know when to widen its own window. It moved `range`
only when the native list reported a new visible range, so growing `itemCount`
without a scroll left the extra rows unmounted forever. A filter does exactly
that. The todo example hit it immediately: switching from a 2-row view to a
6-row view painted 2 rows.

    view-done    ok
    view-starred ok
    view-inbox   FAILED: "Ship it" never mounted
    view-today   ok

`VirtualList` was never published, so the pending
`.changeset/windowed-virtual-list.md` now describes the native `itemCount` plus
`windowStart` path instead. README and AGENTS.md carry a worked example and the
rule that no wrapper should come back.

**Review fixes**

`web:build` ran `bun run --cwd ../packages/native build:web`, a path that does
not exist once the folder is copied, while the README claimed nothing depended
on the repository. The script is gone. The browser renderer ships inside
`@gpuix/native`; `web.ts` explains the repository-only build in its failure
message.

`example-app/README.md` claimed `<virtual-list>` builds only visible rows. GPUI
builds only visible native rows, but React still mounts every child. Corrected.

`noImplicitAny: false` is dropped from the Quickstart tsconfig and from
`example-app/tsconfig.json`. The example passes under full `strict`, so the
starter should not hand out weaker checking.

The lockfile finding resolved itself: `examples/package.json` on main now
declares `cbor-x` and `msgpackr`, and `bun install` in a clean checkout of HEAD
produces no diff.

**Verified**

`packages/react` 302 tests pass. `example-app` typechecks under full strict,
lints clean, and all four filter views paint through the automation client.

Session: ses_fc1c928b0ffeeHIfdRpxtE0QaV
2026-08-26 16:46:11 +02:00
Tommy D. Rossi 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
2026-08-26 16:40:21 +02:00
Tommy D. Rossi 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
2026-08-26 16:39:54 +02:00
Tommy D. Rossi 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
2026-08-26 16:39:29 +02:00
Tommy D. Rossi 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
2026-08-26 12:33:38 +02:00
Tommy D. Rossi 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
2026-08-25 10:58:57 +02:00
Tommy D. Rossi 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
2026-08-25 10:20:35 +02:00
Tommy D. Rossi 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
2026-08-24 23:22:43 +02:00
Tommy D. Rossi 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
2026-08-24 22:18:45 +02:00
Tommy D. Rossi 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
2026-08-24 20:42:16 +02:00
Tommy D. Rossi 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
2026-08-24 19:24:35 +02:00
Tommy D. Rossi 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
2026-08-24 19:24:26 +02:00
Tommy D. Rossi 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
2026-08-24 11:55:30 +02:00
Tommy D. Rossi 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
2026-08-24 11:52:31 +02:00
Tommy D. Rossi 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
2026-08-24 11:52:25 +02:00