Skip to content

Commit e58a9fb

Browse files
committed
feat: add @gpuix/svelte, a Svelte custom renderer for GPUI
Svelte's custom renderer API (sveltejs/svelte#18042) lets Svelte drive non-DOM hosts. This is the Svelte counterpart to @gpuix/react: a 19-method renderer object plus a window/frame-loop entry point. No Rust changes — it drives the existing @gpuix/native mutation API unmodified. The hard part is that GPUI has no comment nodes and no fragments, while Svelte's tree is full of anchors marking every {#if}, {#each} and component boundary — and most of those anchors are empty *text* nodes, not comments. So the renderer keeps a JS shadow tree and projects it onto GPUI: - elements and non-blank text get a nativeId; comments, blank text and fragments never do, and are ordering-only - native ids are allocated lazily, on first reachability from the root, so Svelte's constant offscreen rendering (the shared each-block fragment, deferred branches, boundary pending content) emits nothing - virtual nodes are always leaves, so resolving "the next native node" is a flat scan of following siblings - remove() never destroys; Svelte removes and re-inserts the same node in consecutive statements, so destroyElement is deferred to commit Styling goes through CSS text, since that is how Svelte hands over the style attribute, and is translated to GPUI's camelCase StyleDesc. GPUI's nested hover/active styles have no CSS-text spelling and get their own attributes. Reloading is handled by render_hot rather than `bun --hot`: .svelte files are plugin-loaded and never enter Bun's watch graph, and a --hot reload re-evaluates Svelte's runtime, orphaning the previous component. Tests run against TestGpuixRenderer (real Metal, no window): test/reorder.js 15 keyed {#each} projection cases test/smoke.js mount, click, {#if}, keyed add/remove, screenshot test/coverage.js Svelte's own 47-sample custom-renderer suite Coverage: 32 of the 47 samples work; 14 are refusals Svelte itself enforces under customRenderer; 1 (boundary-pending) hits a compiler bug on the Svelte branch, before the renderer is ever reached. Note: the `svelte` dependency is a file: path to a local checkout of the custom-condition branch, so it is machine-specific. scripts/link-svelte.sh re-links it after bun install (SVELTE_REPO=... to point elsewhere).
1 parent b8bbff4 commit e58a9fb

18 files changed

Lines changed: 1672 additions & 16 deletions

bun.lock

Lines changed: 199 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/Counter.svelte

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<script>
2+
// A port of counter.tsx, plus the control flow that a React reconciler never
3+
// has to deal with: {#if} and keyed {#each} both leave anchor nodes in
4+
// Svelte's tree that GPUI cannot represent, so they are the interesting part.
5+
let count = $state(0);
6+
let hovered = $state(false);
7+
8+
let next_id = 3;
9+
let items = $state([
10+
{ id: 0, label: 'alpha' },
11+
{ id: 1, label: 'beta' },
12+
{ id: 2, label: 'gamma' }
13+
]);
14+
15+
const NAMES = ['delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota'];
16+
17+
function add() {
18+
const label = NAMES[next_id % NAMES.length];
19+
items.push({ id: next_id++, label });
20+
}
21+
22+
function shuffle() {
23+
for (let i = items.length - 1; i > 0; i--) {
24+
const j = Math.floor(Math.random() * (i + 1));
25+
[items[i], items[j]] = [items[j], items[i]];
26+
}
27+
}
28+
</script>
29+
30+
<div
31+
style="display: flex; flex-direction: row; align-items: center; justify-content: center;
32+
gap: 24px; width: 100%; height: 100%; background-color: #11111b; padding: 24px"
33+
>
34+
<!-- counter card -->
35+
<div
36+
style="display: flex; flex-direction: column; align-items: center; justify-content: center;
37+
gap: 16px; padding: 32px; width: 380px; background-color: #1e1e2e; border-radius: 12px"
38+
>
39+
<div
40+
style="font-size: 48px; font-weight: bold; color: #cdd6f4; cursor: pointer"
41+
onclick={() => count++}
42+
>
43+
{count}
44+
</div>
45+
46+
<div style="color: #a6adc8; font-size: 14px">123 Click the number or + to increment</div>
47+
48+
<div style="display: flex; flex-direction: row; gap: 12px">
49+
<div
50+
style="padding: 12px; padding-left: 24px; padding-right: 24px; border-radius: 8px"
51+
style:background-color={count > 0 ? '#f38ba8' : '#6c7086'}
52+
style:cursor={count > 0 ? 'pointer' : 'default'}
53+
style:opacity={count > 0 ? 1 : 0.5}
54+
onclick={() => count > 0 && count--}
55+
>
56+
<div style="color: #1e1e2e; font-weight: bold">-</div>
57+
</div>
58+
59+
<div
60+
style="padding: 12px; padding-left: 24px; padding-right: 24px;
61+
border-radius: 8px; cursor: pointer;
62+
background-color: {hovered ? '#94e2d5' : '#a6e3a1'}"
63+
onclick={() => count++}
64+
onmouseenter={() => (hovered = true)}
65+
onmouseleave={() => (hovered = false)}
66+
>
67+
<div style="color: #1e1e2e; font-weight: bold">+</div>
68+
</div>
69+
</div>
70+
71+
{#if count > 0}
72+
<div style="color: #f9e2af; font-size: 13px">
73+
{count} click{count === 1 ? '' : 's'} — the {'{#if}'} branch is live
74+
</div>
75+
{/if}
76+
77+
<div
78+
style="margin-top: 16px; padding: 16px; background-color: #313244;
79+
border-radius: 8px; cursor: pointer"
80+
hover="background-color: #45475a"
81+
onclick={() => (count = 0)}
82+
>
83+
<div style="color: #bac2de; font-size: 14px">Reset</div>
84+
</div>
85+
</div>
86+
87+
<!-- keyed each-block card -->
88+
<div
89+
style="display: flex; flex-direction: column; gap: 12px; padding: 24px; width: 320px;
90+
background-color: #1e1e2e; border-radius: 12px"
91+
>
92+
<div style="color: #cdd6f4; font-size: 16px; font-weight: bold">Keyed list</div>
93+
94+
{#each items as item (item.id)}
95+
<div
96+
style="display: flex; flex-direction: row; justify-content: space-between;
97+
padding: 10px; background-color: #313244; border-radius: 6px;
98+
color: #cdd6f4; font-size: 14px; cursor: pointer"
99+
hover="background-color: #45475a"
100+
onclick={() => (items = items.filter((i) => i.id !== item.id))}
101+
>
102+
<div>{item.label}</div>
103+
<div style="color: #6c7086">#{item.id}</div>
104+
</div>
105+
{/each}
106+
107+
{#if items.length === 0}
108+
<div style="color: #6c7086; font-size: 13px">empty — add something</div>
109+
{/if}
110+
111+
<div style="display: flex; flex-direction: row; gap: 8px; margin-top: 8px">
112+
<div
113+
style="padding: 8px; padding-left: 14px; padding-right: 14px; border-radius: 6px;
114+
background-color: #89b4fa; color: #1e1e2e; font-size: 13px;
115+
font-weight: bold; cursor: pointer"
116+
hover="background-color: #b4befe"
117+
onclick={add}
118+
>
119+
add
120+
</div>
121+
<div
122+
style="padding: 8px; padding-left: 14px; padding-right: 14px; border-radius: 6px;
123+
background-color: #cba6f7; color: #1e1e2e; font-size: 13px;
124+
font-weight: bold; cursor: pointer"
125+
hover="background-color: #ddb6f2"
126+
onclick={shuffle}
127+
>
128+
shuffle
129+
</div>
130+
</div>
131+
132+
<div style="color: #6c7086; font-size: 12px">click a row to remove it</div>
133+
</div>
134+
</div>

examples/bunfig.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Compiles .svelte files on import (and on save, under `bun --hot`).
2+
preload = ["../packages/svelte/src/plugin.js"]

examples/counter-svelte.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* GPUIX + Svelte counter.
3+
*
4+
* The same app as counter.tsx, driven by Svelte's custom renderer instead of a
5+
* React reconciler. Run it with:
6+
*
7+
* bun run counter-svelte
8+
*
9+
* `render_hot` reloads the component on save. The `--conditions custom-renderer`
10+
* flag in that script is not optional — it is how `svelte` resolves to its
11+
* client build outside a browser.
12+
*/
13+
14+
import { render_hot } from '@gpuix/svelte';
15+
16+
render_hot(new URL('./Counter.svelte', import.meta.url), {
17+
title: 'GPUIX + Svelte',
18+
width: 820,
19+
height: 560
20+
});

examples/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
"test": "vitest run",
99
"native-text": "bun --hot native-text.tsx",
1010
"chat": "bun --hot chat.tsx",
11-
"compile": "bun compile-chat.ts"
11+
"compile": "bun compile-chat.ts",
12+
"counter-svelte": "bun --conditions custom-renderer --conditions development counter-svelte.js"
1213
},
1314
"dependencies": {
1415
"@gpuix/react": "workspace:*",
1516
"@kmamal/sdl": "^0.11.12",
1617
"diff": "^7.0.0",
1718
"react": "^19.2.4",
1819
"safe-mdx": "1.14.0",
19-
"shiki": "^3.0.0"
20+
"shiki": "^3.0.0",
21+
"@gpuix/svelte": "workspace:*"
2022
},
2123
"devDependencies": {
2224
"@types/mdast": "^4.0.4",

packages/svelte/README.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# @gpuix/svelte
2+
3+
Svelte's [custom renderer API](https://github.com/sveltejs/svelte/pull/18042) targeting Zed's GPUI —
4+
the Svelte counterpart to `@gpuix/react`.
5+
6+
```svelte
7+
<!-- Counter.svelte -->
8+
<script>
9+
let count = $state(0);
10+
</script>
11+
12+
<div style="padding: 32px; background-color: #1e1e2e; border-radius: 12px">
13+
<div style="font-size: 48px; color: #cdd6f4; cursor: pointer" onclick={() => count++}>
14+
{count}
15+
</div>
16+
</div>
17+
```
18+
19+
```js
20+
import { render_hot } from '@gpuix/svelte';
21+
22+
render_hot(new URL('./Counter.svelte', import.meta.url), {
23+
title: 'GPUIX + Svelte',
24+
width: 820,
25+
height: 560
26+
});
27+
```
28+
29+
```
30+
cd examples && bun run counter-svelte
31+
```
32+
33+
## Setup
34+
35+
This package depends on an unreleased Svelte branch (`custom-condition`), checked out as a sibling of
36+
this repo. `bun install` *copies* `file:` dependencies rather than symlinking them, so after every
37+
install run:
38+
39+
```
40+
./scripts/link-svelte.sh # SVELTE_REPO=... to point elsewhere
41+
```
42+
43+
The `--conditions custom-renderer` flag in the run script is **not optional**. Svelte's `exports` map
44+
resolves to its *server* build by default outside a browser, and `mount()` doesn't exist there.
45+
Without the condition you get a descriptive throw from `svelte/internal/flags/custom-renderer`.
46+
47+
## Hot reload
48+
49+
`render_hot` watches for `.svelte` changes and remounts. Use `render(Component, options)` instead if
50+
you don't want a watcher.
51+
52+
Note that `bun --hot` cannot do this job here, for two independent reasons:
53+
54+
- `.svelte` files are plugin-loaded, so they never enter Bun's watch graph and editing one triggers
55+
nothing. (Bun also ignores mtime-only changes — a `touch` is not enough, the content must differ.)
56+
- A `--hot` reload re-evaluates *Svelte's runtime* too, so the previous component belongs to a module
57+
instance the new one can't see and `unmount` reports it as never mounted.
58+
59+
Watching in-process avoids both: one Svelte runtime lives for the life of the process, the old tree
60+
unmounts properly, and only the component module is re-instantiated. The loader propagates its
61+
cache-busting `?v=` query to child components so a reload doesn't re-instantiate the root against
62+
stale children.
63+
64+
Set `GPUIX_SCREENSHOT=/path/to.png` to have each mount write a PNG — a window can't be inspected
65+
from a terminal, and Preview.app reloads on write.
66+
67+
## How it works
68+
69+
GPUI's tree is flat and id-based, and knows only `div`, `text` and its custom element types. Svelte's
70+
tree is DOM-shaped and full of **anchor nodes** — comments and empty text nodes marking every
71+
`{#if}`, `{#each}` and component boundary — plus fragments. None of those exist in GPUI.
72+
73+
So `src/renderer.js` keeps a **JS shadow tree** and projects it onto GPUI:
74+
75+
| Svelte node | GPUI |
76+
|---|---|
77+
| element | `createElement(id, tag)` |
78+
| text with content | `createElement(id, 'text')` + `setText` |
79+
| text that is empty or whitespace-only | *nothing* — materialized if it later gains content |
80+
| comment | *nothing, ever* |
81+
| fragment | splatted into the parent on insert |
82+
83+
Three properties make this tractable:
84+
85+
- **Virtual nodes are always leaves**, so "the next native node" is a flat scan of following
86+
siblings. A native node can never hide beneath a virtual one.
87+
- **Native ids are allocated lazily**, when a node first becomes reachable from the root. Svelte
88+
renders offscreen constantly (the shared each-block fragment, deferred `{#if}` branches,
89+
`<svelte:boundary>` pending content); eager creation would leak a Rust node per abandoned render.
90+
- **`remove()` never destroys.** Svelte removes and re-inserts the same node in consecutive
91+
statements, so removal only detaches and enqueues; `destroyElement` runs at commit time for nodes
92+
that are still detached.
93+
94+
Mutations queue up and ship as a single `applyBatch` call, committed before each `tick()` and
95+
immediately after each event.
96+
97+
## Styling
98+
99+
Svelte hands the renderer the `style` attribute as CSS **text**, never as an object, so `src/style.js`
100+
translates it into GPUI's camelCase `StyleDesc`: `background-color``backgroundColor`, `16px``16`,
101+
while `50%`, `auto` and `#1e1e2e` stay strings. `style:` directives and dynamic values work normally.
102+
103+
GPUI's nested pseudo-styles have no CSS-text spelling, so they get their own attributes:
104+
105+
```svelte
106+
<div style="background-color: #313244" hover="background-color: #45475a" active="opacity: 0.8">
107+
```
108+
109+
`class` is inert — GPUI has no CSS engine.
110+
111+
## Limits
112+
113+
Compile errors under `customRenderer` (enforced by the Svelte compiler): `{@html}`, `bind:` on
114+
elements, `transition:` / `in:` / `out:`, `animate:`, legacy `on:`, `<svelte:head|window|document|body>`,
115+
and `css: 'injected'`. Component `bind:` and `bind:this` are fine.
116+
117+
Beyond that:
118+
119+
- **No event bubbling.** GPUI dispatches straight to the element, so a parent's `onclick` does not
120+
fire for a child's click. (Same limitation as `@gpuix/react`.)
121+
- **Whitespace-only text is dropped.** GPUI lays out with flex, not inline text flow, so an
122+
inter-element space would render as a stray row. The cost is that a deliberate space between two
123+
expressions (`{a} {b}`) is lost — put it inside one expression instead.
124+
- **Unknown tags degrade to `div`** with a warning. GPUI has no `<span>`, `<p>` or `<section>`.
125+
- Only the events in `src/events.js` exist; anything else is silently not registered.
126+
127+
## Tests
128+
129+
```
130+
bun --conditions custom-renderer --conditions development test/smoke.js # mount + interact + screenshot
131+
bun --conditions custom-renderer --conditions development test/reorder.js # keyed {#each} projection
132+
```
133+
134+
Both run against `TestGpuixRenderer`, which drives the real Metal pipeline without opening a window.
135+
`reorder.js` is the important one: keyed reordering re-inserts nodes before anchors that have no GPUI
136+
presence, and it is the one place the projection can silently produce the wrong native order.

packages/svelte/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "@gpuix/svelte",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "src/index.js",
7+
"exports": {
8+
".": "./src/index.js",
9+
"./renderer": "./src/renderer.js",
10+
"./plugin": "./src/plugin.js"
11+
},
12+
"dependencies": {
13+
"@gpuix/native": "workspace:^",
14+
"svelte": "file:../../../svelte/packages/svelte"
15+
}
16+
}

packages/svelte/src/events.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Event-name translation.
3+
*
4+
* Svelte lowercases event names when it compiles `onmouseenter={...}` down to
5+
* `$.event('mouseenter', ...)`. GPUI's retained tree keys listeners by the
6+
* camelCase names in `@gpuix/react`'s EVENT_PROPS (`mouseEnter`). Deriving the
7+
* map by lowercasing GPUI's own list keeps the two in sync automatically,
8+
* including the custom-element events.
9+
*/
10+
11+
/** The event types GPUI knows about, spelled the way GPUI spells them. */
12+
export const GPUI_EVENTS = [
13+
// custom elements (<diff>, <markdown>, <input>, <textarea>)
14+
'toggleFile',
15+
'showMore',
16+
'lineClick',
17+
'linkClick',
18+
'change',
19+
'submit',
20+
// mouse
21+
'click',
22+
'mouseDown',
23+
'mouseUp',
24+
'mouseEnter',
25+
'mouseLeave',
26+
'mouseMove',
27+
'mouseDownOutside',
28+
// keyboard — require focus (tabIndex or autofocus)
29+
'keyDown',
30+
'keyUp',
31+
// focus
32+
'focus',
33+
'blur',
34+
// scroll
35+
'scroll'
36+
];
37+
38+
const BY_LOWERCASE = new Map(GPUI_EVENTS.map((name) => [name.toLowerCase(), name]));
39+
40+
/**
41+
* @param {string} type an event name as Svelte spells it, e.g. `mouseenter`
42+
* @returns {string | null} the GPUI spelling, or null if GPUI has no such event
43+
*/
44+
export function to_gpui_event(type) {
45+
return BY_LOWERCASE.get(type.toLowerCase()) ?? null;
46+
}

packages/svelte/src/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export { render, render_hot } from './render.js';
2+
export {
3+
default as renderer,
4+
set_native,
5+
create_root,
6+
commit,
7+
is_dirty,
8+
dispatch
9+
} from './renderer.js';
10+
export { parse_css_text, build_style } from './style.js';
11+
export { to_gpui_event, GPUI_EVENTS } from './events.js';

0 commit comments

Comments
 (0)