@felipe-software/react-native-gpui 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
  2. package/LICENSE +21 -0
  3. package/README.md +657 -2
  4. package/cli/bin.ts +379 -0
  5. package/cli/bundle.ts +32 -0
  6. package/cli/commands/do.ts +213 -0
  7. package/cli/commands/flow.ts +283 -0
  8. package/cli/commands/get.ts +584 -0
  9. package/cli/commands/shot.ts +312 -0
  10. package/cli/commands/trace.ts +293 -0
  11. package/cli/host.ts +714 -0
  12. package/cli/hotReload.ts +210 -0
  13. package/cli/run.ts +66 -0
  14. package/cli/selectors.ts +205 -0
  15. package/dist/Animated.d.ts +120 -0
  16. package/dist/ChatList.d.ts +28 -0
  17. package/dist/Dimensions.d.ts +31 -0
  18. package/dist/StyleSheet.d.ts +18 -0
  19. package/dist/Window.d.ts +33 -0
  20. package/dist/apis.d.ts +165 -0
  21. package/dist/app-state.d.ts +27 -0
  22. package/dist/chat-list-types.d.ts +136 -0
  23. package/dist/colors.d.ts +32 -0
  24. package/dist/commands.d.ts +230 -0
  25. package/dist/components.d.ts +419 -0
  26. package/dist/hotModules.d.ts +1 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.development.js +14876 -0
  29. package/dist/index.js +12 -0
  30. package/dist/keyboard.d.ts +140 -0
  31. package/dist/native-menu.d.ts +7 -0
  32. package/dist/platform-driver.d.ts +32 -0
  33. package/dist/platform-services.d.ts +35 -0
  34. package/dist/portal.d.ts +43 -0
  35. package/dist/raf.d.ts +4 -0
  36. package/dist/reanimated/colors-processor-shim.d.ts +10 -0
  37. package/dist/reanimated/reanimated-host.d.ts +10 -0
  38. package/dist/reanimated/seam.d.ts +34 -0
  39. package/dist/reanimated/ui-entry.d.ts +2 -0
  40. package/dist/reanimated/worklet-channel.d.ts +10 -0
  41. package/dist/reanimated/worklet-runtime.d.ts +466 -0
  42. package/dist/reanimated/worklets.d.ts +199 -0
  43. package/dist/reconciler.d.ts +102 -0
  44. package/dist/refresh.d.ts +1 -0
  45. package/dist/render.d.ts +37 -0
  46. package/dist/runtime.d.ts +235 -0
  47. package/dist/surfaces.d.ts +51 -0
  48. package/dist/types.d.ts +276 -0
  49. package/dist/ui-runtime.js +42276 -0
  50. package/dist/window-context.d.ts +7 -0
  51. package/dist/window-host.d.ts +41 -0
  52. package/dist/wire-delta.d.ts +20 -0
  53. package/package.json +176 -5
  54. package/scripts/bundle-app.mjs +88 -0
  55. package/scripts/conformance-utils.mjs +293 -0
  56. package/scripts/hot-update-alias-plugin.mjs +88 -0
  57. package/scripts/native-package-exports-plugin.mjs +235 -0
  58. package/scripts/pixel.mjs +152 -0
  59. package/scripts/png.mjs +198 -0
  60. package/scripts/react-refresh-swc.mjs +35 -0
  61. package/scripts/reanimated-bun-plugin.mjs +188 -0
  62. package/scripts/service-binary.d.mts +27 -0
  63. package/scripts/service-binary.mjs +100 -0
  64. package/src/reanimated/colors-processor-shim.ts +119 -0
  65. package/src/reanimated/reanimated-host.ts +69 -0
  66. package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
  67. package/src/reanimated/seam.ts +566 -0
  68. package/src/reanimated/ui-entry.ts +80 -0
  69. package/src/reanimated/worklet-channel.ts +64 -0
  70. package/src/reanimated/worklet-runtime.ts +2569 -0
  71. package/src/reanimated/worklets.ts +1334 -0
  72. package/src/refresh.ts +33 -0
package/README.md CHANGED
@@ -1,3 +1,658 @@
1
- # Temporary Holding Version
1
+ # react-native-gpui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Write **React Native** components — render them **natively** with [GPUI](https://www.gpui.rs/) (Zed's GPU-accelerated Rust UI framework). No browser, no Electron, no mobile host. Your React tree is reconciled into a GPUI window on Metal (macOS) or Vulkan (Linux), with a real `react-reconciler` (so hooks, state, and effects all work) and a typed component surface that mirrors React Native.
4
+
5
+ It also supports the **"native shell, web content"** hybrid: embed a native `WebView` (WKWebView via `wry`) anywhere in the tree for web-grade content with native scroll + selection, while the rest of the UI stays native GPUI.
6
+
7
+ ```tsx
8
+ import { AppRegistry, View, Text, Pressable, StyleSheet } from "@felipe-software/react-native-gpui";
9
+ import { useState } from "react";
10
+
11
+ function App() {
12
+ const [n, setN] = useState(0);
13
+ return (
14
+ <View style={s.root}>
15
+ <Text style={s.h1}>Hello from GPUI 👋</Text>
16
+ <Pressable style={s.btn} onPress={() => setN((c) => c + 1)}>
17
+ <Text style={s.btnText}>tapped {n}×</Text>
18
+ </Pressable>
19
+ </View>
20
+ );
21
+ }
22
+ const s = StyleSheet.create({
23
+ root: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#1e1e2e", gap: 16 },
24
+ h1: { color: "#e6e6ef", fontSize: 24, fontWeight: "700" },
25
+ btn: { backgroundColor: "#8a5cf6", paddingVertical: 10, paddingHorizontal: 18, borderRadius: 10 },
26
+ btnText: { color: "#fff", fontSize: 15, fontWeight: "600" },
27
+ });
28
+
29
+ AppRegistry.registerComponent("App", () => App);
30
+ AppRegistry.runApplication("App");
31
+ ```
32
+
33
+ ```sh
34
+ bun add @felipe-software/react-native-gpui react
35
+ bun run rngpui run app.tsx
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ bun add @felipe-software/react-native-gpui react
42
+ ```
43
+
44
+ The package brings its own renderer: npm, Bun, pnpm and Yarn install the one platform
45
+ package that matches your machine from its `optionalDependencies`, so do not install with
46
+ `--omit=optional` / `--no-optional`. Your app's own `react` (19 or newer) is the only React in
47
+ the bundle; the library declares it as a peer dependency.
48
+
49
+ | Platform | Package | Built on |
50
+ | --- | --- | --- |
51
+ | Linux x86_64 | `@felipe-software/react-native-gpui-linux-x64` | Ubuntu 24.04 (glibc 2.39 or newer) |
52
+ | Linux arm64 | `@felipe-software/react-native-gpui-linux-arm64` | Ubuntu 24.04 (glibc 2.39 or newer) |
53
+ | macOS Apple silicon | `@felipe-software/react-native-gpui-darwin-arm64` | macOS 14 |
54
+
55
+ Each platform package holds `rngpui-service` and `ui-runtime.js`; there is no
56
+ Intel macOS build. The CLI and the bundler run on [Bun](https://bun.sh) 1.3 or newer.
57
+
58
+ **Linux runtime.** The service loads WebKitGTK's JavaScriptCore at startup
59
+ (`libjavascriptcoregtk-4.1`, `-6.0` or `-4.0`, whichever is installed) and needs a Vulkan
60
+ driver, `libwayland-client` (Wayland), `libxkbcommon-x11` (X11) and ALSA. GNOME desktops
61
+ already ship them; elsewhere:
62
+
63
+ ```sh
64
+ # Debian 13 / Ubuntu 24.04
65
+ sudo apt install libjavascriptcoregtk-4.1-0 libvulkan1 mesa-vulkan-drivers libxkbcommon-x11-0 libasound2t64
66
+ # Fedora
67
+ sudo dnf install javascriptcoregtk4.1 vulkan-loader mesa-vulkan-drivers libxkbcommon-x11 alsa-lib
68
+ ```
69
+
70
+ ## Bundle and run
71
+
72
+ An app is one entry file. Bun bundles it into a single script and `rngpui-service` runs that
73
+ script in JavaScriptCore:
74
+
75
+ ```sh
76
+ rngpui run app.tsx # bundle (production) and open the window
77
+ rngpui run app.tsx --dev --size 1280x800 # development React, window size
78
+ rngpui bundle app.tsx dist/app.js # just write the bundle (add --dev for development)
79
+ rngpui run --bundle dist/app.js # run a bundle you built earlier
80
+ rngpui dev --launch app.tsx --root src # Fast Refresh while you edit
81
+ ```
82
+
83
+ Run them with `bun run rngpui …` or from a `package.json` script, which only use the copy in
84
+ your `node_modules`. The bundler
85
+ resolves packages from the entry's directory upward, so it uses your app's `node_modules`, and
86
+ both `@felipe-software/react-native-gpui` and `react-native` resolve to the same renderer.
87
+ Imports of the old unscoped name `react-native-gpui` bundle too; for TypeScript, map it with
88
+ `"paths": { "react-native-gpui": ["./node_modules/@felipe-software/react-native-gpui"] }` or
89
+ install the package under that name (`bun add react-native-gpui@npm:@felipe-software/react-native-gpui`).
90
+
91
+ `rngpui run` finds the renderer the same way every launcher here does: `RNGPUI_SERVICE` if set,
92
+ otherwise the installed platform package. A custom launcher can ask for the same path:
93
+
94
+ ```js
95
+ import { resolveServiceBinary } from "@felipe-software/react-native-gpui/scripts/service-binary.mjs";
96
+
97
+ const { path } = resolveServiceBinary(); // spawn it with RNGPUI_BUNDLE=<your bundle>
98
+ ```
99
+
100
+ `react-native-reanimated` runs from a copy prebuilt into the package, so do not install
101
+ reanimated yourself. Worklets in your own code (`useAnimatedStyle`, `withSpring`, …) need the
102
+ worklets Babel transform at bundle time:
103
+ `bun add -d @babel/core @babel/plugin-syntax-jsx @babel/preset-typescript react-native-worklets@0.8.1`.
104
+
105
+ ## How it works
106
+
107
+ ```
108
+ React tree ──► react-reconciler ──► serialized node tree ──► __rngpui_applyTree()
109
+ │
110
+ rngpui-service
111
+ Rust + GPUI + JavaScriptCore
112
+ │
113
+ onPress / onChangeText / onLayout / resize ◄──── __rngpui_onHostEvent()
114
+ ```
115
+
116
+ The TypeScript side is bundled to a self-contained JS file and evaluated inside the native
117
+ service via system JavaScriptCore. React reconciles your components into a flat node tree; host globals pass
118
+ commits and UI events in memory between JavaScriptCore and GPUI. Stable element ids keep
119
+ native state (text-input contents, scroll offsets, web views) alive across
120
+ re-renders.
121
+
122
+ ## Components
123
+
124
+ | Layout / display | Input / touch | Lists | Native content |
125
+ | --- | --- | --- | --- |
126
+ | `View` `Text` `ScrollView` `SafeAreaView` `KeyboardAvoidingView` | `TextInput` `Pressable` `TouchableOpacity` `TouchableHighlight` `TouchableWithoutFeedback` `Button` `Switch` | `FlatList` `SectionList` `ChatList` | `Image` `Svg` `WebView` |
127
+
128
+ APIs: `StyleSheet`, `Dimensions`, `useWindowDimensions`, `Platform`, `PixelRatio`, `useColorScheme`, `AppRegistry`, `createRoot` / `render`, [`Window`](#windows), `AppState` / `useAppState` / `useWindowFocused`, plus the [platform services](#platform-services) (`localStorage`, `Notifications`, `Sound`, `Processes`, `NativeClipboard`, `fetch`, `crypto`, `ImageCache`).
129
+
130
+ - **Real text input** (`TextInput`) is backed by [gpui-component](https://github.com/longbridge/gpui-component)'s editor: selection, IME, copy/paste, word motion, multiline. Resolved `style.color` and `placeholderTextColor` paint through the native editor. `onChange.nativeEvent` includes `isComposing` and `eventCount`, so controlled inputs can preserve native marked text while React commits catch up.
131
+ - **Text truncation** follows React Native's `numberOfLines`: one-line labels truncate with ellipsis, and multi-line labels are line-clamped.
132
+ - **Native portals** (`PortalProvider`, `Portal`, `PortalHost`) render overlay content at the matching host during GPUI serialization, so Tamagui Dialog/Popover/Sheet can avoid extra state-driven portal fallbacks.
133
+ - **RN-style measurement** (`measure`, `measureInWindow`, `measureLayout`) is exposed on host refs. Floating overlay libraries such as Tamagui's Popover/Select can measure trigger and content nodes without app-specific adapters.
134
+ - **RN Animated** runs JS-frame animations through React commits; animated styles resolve to plain GPUI styles each frame.
135
+ - **Native layout overrides** (`nativeLayoutKey`, `nativeResize`, `NativeLayout`) let narrow chrome interactions such as pane resizing mutate GPUI layout in the native service without a React commit per pointer frame.
136
+ - **Styling** matches RN: flexbox (Yoga semantics — `flex:1`, `%`, `auto`), `backgroundColor`, gradients via `backgroundImage`, `boxShadow` / iOS `shadow*` / `elevation`, `borderRadius`, `overflow: scroll`, `opacity`.
137
+ - **`Svg`** renders a monochrome icon tinted by `style.color`.
138
+ - **Keyboard**: the first `onKeyPress` listener of a window (outside text inputs) receives
139
+ every key typed in that window, and Ctrl, Shift, Alt or Meta pressed on its own arrives
140
+ as `Control`, `Shift`, `Alt` or `Meta`. `onKeyUp` reports releases the same way.
141
+ - **Middle click**: `onAuxClick` fires when the middle button is pressed and released on
142
+ the same view (`nativeEvent.button` is 1).
143
+ - **Press-drag scrubbing**: a press on a view with `onMouseUp`/`onClick`/`onPress` that
144
+ drags across another such view presses it (menus, list rows), scoped by
145
+ `nativeListGroup`. `nativePressDrag={false}` takes a view and its descendants out of it,
146
+ for drag handles whose child buttons must not fire when the drag crosses them.
147
+ - **Colors**: every CSS named color and `transparent` (for `color`, `backgroundColor` and
148
+ `borderColor`).
149
+
150
+ ## Windows
151
+
152
+ `<Window>` opens a real OS window that this app's React tree renders into. The window is
153
+ another GPUI window inside the same service, drawn from the same JavaScript runtime and
154
+ reconciler, so its children share every context, store and piece of state with the rest
155
+ of the app, and live data (a chat feed, a socket) updates both windows at once.
156
+ Mounting opens the window; unmounting closes it.
157
+
158
+ ```tsx
159
+ import { useState } from "react";
160
+ import { Window, Text, TextInput, View } from "react-native-gpui";
161
+
162
+ function UserCardHost({ user }: { user: User }) {
163
+ const [pinned, setPinned] = useState(false);
164
+ const [open, setOpen] = useState(true);
165
+ if (!open) return null;
166
+ return (
167
+ <Window
168
+ kind="popup"
169
+ title={`${user.name}'s Usercard`}
170
+ width={420}
171
+ height={470}
172
+ position={{ x: -140, y: -94, relativeTo: "cursor" }}
173
+ closeOnBlur={!pinned}
174
+ onClose={({ reason }) => setOpen(false)}
175
+ >
176
+ <UserCard user={user} pinned={pinned} onPin={setPinned} />
177
+ </Window>
178
+ );
179
+ }
180
+ ```
181
+
182
+ | Prop | Meaning |
183
+ | --- | --- |
184
+ | `title`, `width`, `height` | Window title and content size (the platform frame, or the Linux client-side frame, comes on top). |
185
+ | `minWidth`, `minHeight` | Minimum content size. |
186
+ | `kind` | `"normal"` (default), `"dialog"` (transient for the main window, closes on Escape) or `"popup"` (frameless, closes on Escape and when it loses focus). |
187
+ | `position` | `"center"` on the main window (default for normal and dialog windows), `"cursor"` (default for popups), or `{ x, y, relativeTo }` for the content's top-left corner relative to the main window's content (`"parent"`, default), the screen, or the cursor. Windows are kept on the display; a cursor-placed window that would run off the bottom-right corner opens above the cursor. |
188
+ | `resizable`, `alwaysOnTop` | Default resizable except popups. `alwaysOnTop` keeps the window above others on macOS and X11. |
189
+ | `closeOnBlur`, `closeOnEscape` | Override the kind's defaults. |
190
+ | `onClose({ reason })` | The window asked to close: `"system"` (the OS or title-bar close button), `"escape"`, `"blur"` or `"error"` (it could not be created). Unmount the `<Window>` to close it; without `onClose` it closes itself. |
191
+ | `onFocus`, `onBlur`, `onResize({ width, height })` | Activation and size changes. |
192
+ | `testID` | Names the window for `rngpui --window`. |
193
+
194
+ Inside a window, `useWindowDimensions()` returns that window's size and
195
+ `useWindowFocused()` whether it has focus. A ref (`WindowHandle`) has `windowId` and
196
+ `focus()`.
197
+
198
+ - Each window has its own hit testing, focus, keyboard shortcuts (`AppCommands` bindings
199
+ fire in whichever window has focus), text inputs, `ChatList`s, theme, layout and frame
200
+ pacing: a window repaints only when its own tree, input or animation changes, and the
201
+ main window keeps its retained-layout fast path while secondary windows lay out in full.
202
+ - Wayland does not let a client place its toplevel windows, so `position` is ignored
203
+ there and the compositor places the window. X11 and macOS honor it. `alwaysOnTop` has
204
+ no Wayland protocol either.
205
+ - On GNOME (no server-side decorations) normal windows and dialogs get the client-side
206
+ title bar, border and resize edges, and its close button reports `reason: "system"`.
207
+ KWin draws server-side decorations. Popups are frameless everywhere.
208
+ - Closing the main window quits the app and closes every other window.
209
+ - `NativeWindow.open()` still starts a separate process with its own JavaScript state; use
210
+ `<Window>` for windows that belong to the running app.
211
+
212
+ `AppState.currentState` is `"active"` while one of the app's windows has focus and
213
+ `"inactive"` otherwise; `AppState.addEventListener("change" | "focus" | "blur", …)` and
214
+ `useAppState()` follow it. `AppState.addEventListener("windowFocus", ({ windowId, focused }))`
215
+ reports each window, the main window as id 0.
216
+
217
+ `bun run conformance:multi-window` opens a dialog, a window with a `ChatList` and a popup
218
+ in one service and checks per-window trees, pixels, real clicks and keys, shared state,
219
+ Escape / title-bar / focus-loss / unmount closing, and that closing leaks no nodes or
220
+ native state.
221
+
222
+ ## Native surfaces
223
+
224
+ For app chrome that needs native glass or GPUI shader-backed effects, use the
225
+ surface components instead of baking app-specific behavior into the renderer:
226
+
227
+ ```tsx
228
+ import {
229
+ LiquidGlassBackground,
230
+ SmokeEffectSurface,
231
+ View,
232
+ } from "react-native-gpui";
233
+
234
+ export function Shell({ children }) {
235
+ return (
236
+ <View style={{ flex: 1, borderRadius: 32, overflow: "hidden" }}>
237
+ <LiquidGlassBackground
238
+ radius={32}
239
+ material="underWindowBackground"
240
+ glassVariant="controlCenter"
241
+ />
242
+ <SmokeEffectSurface
243
+ pointerEvents="none"
244
+ radius={32}
245
+ alpha={0.9}
246
+ reach={0.28}
247
+ topClear={0.06}
248
+ />
249
+ {children}
250
+ </View>
251
+ );
252
+ }
253
+ ```
254
+
255
+ `LiquidGlassBackground` / `LiquidGlassView` wrap native `SystemView` glass.
256
+ `EffectSurface` / `SmokeEffectSurface` are ordinary React components that set
257
+ `backgroundImage` to renderer effects such as `smoke(...)`. Apps can tune these
258
+ from React props while the renderer owns only the generic native primitive.
259
+
260
+ ## Dev loop
261
+
262
+ `rngpui dev --launch` launches one development renderer, watches every local
263
+ module in its dependency graph, and applies edits through React Fast Refresh.
264
+ Compatible component state, the native window, focus, scroll position, and
265
+ renderer-owned state stay in place. A transform or evaluation error is printed
266
+ without killing the running app, so the next valid edit can recover it.
267
+
268
+ ```sh
269
+ rngpui dev --launch app/index.tsx --root app --root packages/ui
270
+ ```
271
+
272
+ An owned external instance can use the same Fast Refresh path by providing its
273
+ control socket, build command, output bundle, and watched roots:
274
+
275
+ ```sh
276
+ rngpui dev \
277
+ --socket /tmp/my-app.control.sock \
278
+ --bundle native-shell/.gpui/hot-update.js \
279
+ --build "NODE_ENV=development bun native-shell/scripts/bundle-app-gpui.mjs native-shell/app.tsx native-shell/.gpui/hot-update.js" \
280
+ --root app --root interface --root native-shell
281
+ ```
282
+
283
+ There is one development reload contract. RNGPUI does not fall back to process
284
+ signals or remount the application when a Fast Refresh update fails.
285
+
286
+ `rngpui get windows` lists the windows a service shows (id, kind, title, `testID`,
287
+ bounds, nodes, frames painted). `--window <id | main | testID | kind | title>` aims
288
+ `get`, `do`, `trace` and `reshot` at one of them:
289
+
290
+ ```sh
291
+ rngpui get windows --session /tmp/rngpui-cli-abc123
292
+ rngpui do tap dialog-increment --window fixture-dialog --session /tmp/rngpui-cli-abc123
293
+ rngpui reshot --window popup --session /tmp/rngpui-cli-abc123 --out /tmp/popup.png
294
+ ```
295
+
296
+ ## Native diff
297
+
298
+ `Diff` parses and virtualizes a unified patch in the renderer. Large patches do
299
+ not create one React node per line.
300
+
301
+ ```tsx
302
+ <Diff
303
+ patch={unifiedPatch}
304
+ wordDiff
305
+ scroll
306
+ maxLines={20_000}
307
+ collapsedPaths={["generated/output.ts"]}
308
+ onToggleFile={({ nativeEvent }) => toggle(nativeEvent.value)}
309
+ onShowMore={() => setExpanded(true)}
310
+ onLineClick={({ nativeEvent }) => {
311
+ openLine(nativeEvent.value, nativeEvent.oldLine, nativeEvent.newLine)
312
+ }}
313
+ style={{ height: 520, fontFamily: "Menlo", fontSize: 12 }}
314
+ />
315
+ ```
316
+
317
+ ## Native chat list
318
+
319
+ `ChatList` is a virtualized chat log drawn by the renderer, built for Twitch-style
320
+ clients. Messages never travel as props: the ref's methods stream them into the native
321
+ element, so an append costs only the new messages. The element keeps up to
322
+ `maxMessages` (default 1000, oldest dropped), lays out and paints only visible rows,
323
+ and caches each message's layout until its width, font, scale or content changes.
324
+
325
+ ```tsx
326
+ import { useEffect, useRef } from "react";
327
+ import { ChatList, type ChatListHandle, type ChatListMessage } from "react-native-gpui";
328
+
329
+ const message: ChatListMessage = {
330
+ id: "msg-1",
331
+ timestamp: "12:00",
332
+ marker: "#ff2d2d",
333
+ elements: [
334
+ { kind: "badge", name: "moderator", url: badgeUrl, width: 18, height: 18, tooltip: "Moderator" },
335
+ { kind: "user", text: "alice:", login: "alice", color: "#ff4040" },
336
+ { kind: "text", text: "hello world" },
337
+ { kind: "emote", name: "Kappa", url: kappaUrl, width: 28, height: 28 },
338
+ ],
339
+ };
340
+
341
+ function Channel({ chat }: { chat: ChatSource }) {
342
+ const list = useRef<ChatListHandle>(null);
343
+ useEffect(() => {
344
+ list.current?.prepend(chat.history());
345
+ return chat.onMessages((messages) => list.current?.append(messages));
346
+ }, [chat]);
347
+ return (
348
+ <ChatList
349
+ ref={list}
350
+ style={{ flex: 1, backgroundColor: "#191919" }}
351
+ fontSize={13}
352
+ showTimestamps
353
+ textColor="#ffffff"
354
+ mutedColor="#8c7f7f"
355
+ linkColor="#4286f4"
356
+ pauseOnHover
357
+ onPress={({ nativeEvent }) => {
358
+ if (nativeEvent.target === "user") openUserCard(nativeEvent.login);
359
+ if (nativeEvent.target === "link") openUrl(nativeEvent.url);
360
+ if (nativeEvent.button === "right") showMessageMenu(nativeEvent.messageId, nativeEvent.x, nativeEvent.y);
361
+ }}
362
+ onHover={({ nativeEvent }) => setTooltip(nativeEvent)}
363
+ onScrollState={({ nativeEvent }) => setMoreBelow(nativeEvent.atBottom ? 0 : nativeEvent.unseen)}
364
+ />
365
+ );
366
+ }
367
+ ```
368
+
369
+ When a moderator deletes the message, patch it in place (`hidden: true` removes it from
370
+ the layout instead, like Chatterino's "hide moderated messages"):
371
+
372
+ ```tsx
373
+ list.current?.update([{ id: "msg-1", disabled: true }]);
374
+ ```
375
+
376
+ - Message flags: `disabled` and `historical` fade a message, `hidden` takes it out of the
377
+ layout until a patch sets it back, `background` fills the row, `marker` colors its
378
+ scrollbar tick. A badge's `background` paints behind its image (FFZ's custom moderator
379
+ badge).
380
+
381
+ - Handle: `append`, `prepend` (history, oldest first), `update` (patches by `id`; pass
382
+ `""` to clear a string or color field), `clear`, `scrollToBottom`, `scrollToTop`,
383
+ `scrollToMessage(id, { align: "top" | "center" | "bottom" })` (default center),
384
+ `pageUp`, `pageDown` (one viewport, for the app's Page Up / Page Down keys).
385
+ - Elements are separated by one space; whitespace inside a text element separates words.
386
+ Set `trailingSpace: false` on an element to glue the next one to it (`@alice` + `,`).
387
+ - Layout follows Chatterino: 8/4 px margins, elements bottom-aligned per line, emotes
388
+ count as 4 px shorter than they are (they overhang 2 px above and below), badges are not
389
+ scaled by `emoteScale`, zero-width emotes are centered over the previous emote, and a
390
+ `reply` renders as one truncated line at 0.8× size with a curve.
391
+ - Images load through GPUI's image cache (http(s) URLs or file paths). Animated GIF and
392
+ WebP play while visible; the list schedules one repaint for the earliest frame change,
393
+ and an idle list without visible animation does not repaint.
394
+ - Scrolling: wheel/trackpad, a thin scrollbar that widens on hover and shows `marker`
395
+ colors. At the bottom the list follows new messages; scrolled up, appends, prepends
396
+ and trimming keep the rows you are reading in place, and `onScrollState` reports
397
+ `{ atBottom, paused, unseen }`. `pauseOnHover` freezes the view while the pointer is
398
+ over the list.
399
+ - Interaction: `onHover` fires with the user, link, emote or badge under the pointer, its
400
+ window-space bounds and the pointer position (`pointerX`, `pointerY`), or `null` when
401
+ it leaves, so tooltips can be React overlays. `onPress` fires for user/link/emote/badge,
402
+ or `message` for anything else, with the button, pointer position and modifier keys
403
+ (`shiftKey`, `ctrlKey`, `altKey`, `metaKey`). Left clicks fire on release unless the pointer moved more
404
+ than 15 px (that was a selection); right and middle clicks fire on press.
405
+ - Selection: drag selects across messages, double-click selects a word, triple-click
406
+ selects the message body. Ctrl+C (Cmd+C on macOS) copies it, including while a
407
+ `TextInput` is focused, unless that input holds a newer selection of its own. Emotes
408
+ copy as their names, badges copy nothing.
409
+
410
+ `bun run conformance:chat-list` checks wrapping, emote placement, timestamps, events,
411
+ selection, copy and scroll anchoring against real pixels and bounds.
412
+ `bun run perf:chat-list` measures 6 lists × 1000 messages at 20 messages/s.
413
+
414
+ Use `PerformanceHUD.setEnabled(true)` or `rngpui do hud on` for the native
415
+ frame/layout/paint overlay. `Renderer.getProvenance()` and
416
+ `rngpui get provenance` report the exact renderer, service, GPUI, JavaScriptCore, and
417
+ bundle identity.
418
+
419
+ ## Native inspector
420
+
421
+ Enable the GPUI node inspector for any root:
422
+
423
+ ```tsx
424
+ render(<App />, { devtools: { inspector: true } })
425
+ ```
426
+
427
+ or set `RNGPUI_INSPECTOR=1` before launching. Hold Option while the window is
428
+ focused to highlight the native node under the pointer. Option-click copies a
429
+ compact snapshot with node id/type, bounds, event names, accessibility metadata,
430
+ text/value snippets, style facts, and the ancestor chain.
431
+
432
+ ## The WebView hybrid
433
+
434
+ ```tsx
435
+ <View style={{ flex: 1 }}>
436
+ {/* native shell: sidebar, header, composer … */}
437
+ <WebView style={{ flex: 1 }} source={{ html: myMarkdownHtml }} />
438
+ {/* …or source={{ uri: "https://…" }} */}
439
+ </View>
440
+ ```
441
+
442
+ The `WebView` is a real native web view composited inside the GPUI window — you get web-grade rendering and **native scroll momentum + selection** for the content area while the shell stays native. Re-render it from React state like any other component.
443
+
444
+ ### Messaging (two-way)
445
+
446
+ `source` re-renders the whole document, so for live content drive it with messages instead:
447
+
448
+ ```tsx
449
+ const ref = useRef<WebViewHandle>(null)
450
+
451
+ <WebView
452
+ ref={ref}
453
+ source={{ html }}
454
+ onLoad={() => ref.current?.injectJavaScript("startStreaming()")}
455
+ onMessage={(e) => handle(e.nativeEvent.data)} // page → host
456
+ />
457
+
458
+ // host → frame:
459
+ ref.current?.injectJavaScript("appendToken('…')") // run JS in the page (no reload)
460
+ ref.current?.postMessage("payload") // → a 'message' event in the page
461
+ ref.current?.reload()
462
+ ```
463
+
464
+ Inside the page, talk back to the host with the React-Native bridge global (injected automatically):
465
+
466
+ ```js
467
+ window.ReactNativeWebView.postMessage("hello from the page") // → onMessage
468
+ window.addEventListener("message", (e) => { /* host postMessage */ })
469
+ ```
470
+
471
+ ## Platform services
472
+
473
+ Desktop services an app needs beyond rendering. Each one runs off the JS thread in the
474
+ native service and resolves a promise when it is done.
475
+
476
+ ```tsx
477
+ import { ImageCache, LocalStorage, Notifications, Processes, Sound, randomUUID } from "react-native-gpui";
478
+
479
+ localStorage.setItem("accounts", JSON.stringify(accounts)); // survives restarts
480
+ await LocalStorage.flush(); // optional: write now
481
+
482
+ const shown = await Notifications.show({ title: "forsen mentioned you", body: message });
483
+ await Sound.play(); // built-in "ping"
484
+ await Sound.play("/home/me/sounds/mention.wav", { volume: 0.6 });
485
+ await Sound.play("https://example.com/alert.wav");
486
+
487
+ const streaming = (await Processes.findRunning(["obs", "obs64.exe"])).length > 0;
488
+ const nonce = randomUUID();
489
+
490
+ const cached = await fetch(setUrl, { cache: "only-if-cached" }); // 504 when nothing is stored
491
+ const fresh = await fetch(setUrl, { cache: "reload" }); // network, then stored on disk
492
+
493
+ ImageCache.preconnect(["https://cdn.7tv.app"]); // open the HTTP/2 connection early
494
+ await ImageCache.prefetch(emoteUrls); // download + decode before the first paint
495
+ ```
496
+
497
+ | API | Behavior |
498
+ | --- | --- |
499
+ | `localStorage` | The synchronous Web Storage API. Loaded once at startup and written back atomically (temp file + rename, 0600) 250ms after the last change, at most 2s late, and again on exit, `SIGTERM`/`SIGINT`/`SIGHUP`, reload, and before `NativeWindow.open()`. Changes from two processes of the same app merge per key. An unreadable file is moved aside as `local-storage.json.corrupt-<ms>` and the store starts empty. |
500
+ | `LocalStorage.flush(): Promise<void>` | Writes pending changes now. `LocalStorage.getPath()` returns the backing file (or `null`), `LocalStorage.isPersistent()` whether there is one. |
501
+ | `Notifications.show({ title, body? }): Promise<boolean>` | Linux: `org.freedesktop.Notifications` over D-Bus (body markup is escaped when the server renders markup). macOS: `osascript display notification`. Resolves `false` when nothing could show it; rejects without a title. |
502
+ | `Sound.play(source = "ping", { volume? }): Promise<void>` | `"ping"` is synthesized; otherwise a file path, `file://` URL, or `http(s)` URL (URL sounds are cached in memory). Sources are capped at 32 MiB and 8 sounds play at once. Decodes WAV (8/16/24/32-bit PCM, 32/64-bit float); other formats reject with the reason. Resolves when playback ends. |
503
+ | `Processes.findRunning(names): Promise<string[]>` | The given names that match a running process, compared case-insensitively against the process name and its executable's file name (Linux `/proc/*/comm` and `cmdline`, macOS `ps`). |
504
+ | `fetch` | `Response` has `status`, `statusText`, `ok` (2xx), `url` (after redirects), `redirected`, `headers` (`get`/`has`/`forEach`/`entries`/`keys`/`values`, iterable), `text()`, `json()`, `arrayBuffer()`, `clone()`. Bodies may be strings, `URLSearchParams`, `ArrayBuffer`, or any typed array/`DataView`; network errors reject with `TypeError`. |
505
+ | `fetch(url, { cache })` | GET requests use the disk cache below only when asked. `"default"` and `"no-store"` go to the network and store nothing. `"reload"` goes to the network and stores a 200. `"force-cache"` returns the stored copy at any age, else fetches and stores. `"only-if-cached"` returns the stored copy or a `504` response without touching the network. `"no-cache"` revalidates a stored copy with `If-None-Match`/`If-Modified-Since` and returns it on `304`. For stale-while-revalidate, start `only-if-cached` and `reload` together and apply each as it arrives. |
506
+ | `ImageCache.prefetch(urls): Promise<boolean>` | Downloads and decodes images into the same cache `<Image>` and `ChatList` read, so their first paint already has them. Resolves `true` when every URL loaded. |
507
+ | `ImageCache.preconnect(origins)` | Opens the HTTP/2 connection to each origin now (a `HEAD /`), so the first images from it skip the TCP and TLS handshakes. |
508
+ | `crypto.getRandomValues`, `crypto.randomUUID` | Backed by the OS random number generator; also exported as `getRandomValues()` / `randomUUID()`. |
509
+ | `NativeClipboard.read(): Promise<string>` | The clipboard's text (`""` when it holds none), read on the main thread. `NativeClipboard.setString(text)` writes it. |
510
+ | `<Image>` | Animated GIF and WebP play, redrawing only when a frame is due; still images never schedule redraws. Remote images load over HTTP/2 (one connection per host) and are kept in the disk cache, so a relaunch shows them without the network; a stored image past its `Cache-Control`/`Expires` lifetime is shown at once and revalidated in the background for the next launch. |
511
+
512
+ The data file lives at `$XDG_DATA_HOME/<app id>/local-storage.json` (default
513
+ `~/.local/share`) on Linux and `~/Library/Application Support/<app id>/local-storage.json` on
514
+ macOS. The app id is `RNGPUI_APP_ID`, else `RNGPUI_APP_NAME`, else `react-native-gpui`.
515
+
516
+ The HTTP disk cache lives in `$XDG_CACHE_HOME/<app id>/http/` (default `~/.cache`) on Linux and
517
+ `~/Library/Caches/<app id>/http/` on macOS, one file per URL named by its SHA-256. It holds 200
518
+ responses to GET requests without `Authorization`, `Cookie` or `Range`, skips `no-store`, writes
519
+ atomically (temp file + rename) so two processes of one app can share it, and evicts the least
520
+ recently used files once it passes 256 MB.
521
+
522
+ | Variable | Effect |
523
+ | --- | --- |
524
+ | `RNGPUI_STORAGE_DIR` | directory holding `local-storage.json`, overriding the data dir |
525
+ | `RNGPUI_CACHE_DIR` | directory replacing `<cache dir>/<app id>`; the HTTP cache goes in its `http/` folder |
526
+ | `RNGPUI_HTTP_CACHE=0` | turns the HTTP disk cache off |
527
+ | `RNGPUI_HTTP_CACHE_MAX_MB` | size cap of the HTTP disk cache (default 256) |
528
+ | `RNGPUI_HTTP_TRACE=1` | prints one `[rngpui-http]` JSON line per image request (`network`, `cache` or `revalidate`, timings, bytes) |
529
+ | `RNGPUI_TEST_MODE` | without `RNGPUI_STORAGE_DIR`, keeps `localStorage` in memory; without `RNGPUI_CACHE_DIR`, disables the HTTP disk cache; also suppresses real notifications and sound output |
530
+ | `RNGPUI_FAKE_NOTIFICATIONS=<file>` | append each notification as a JSON line instead of showing it |
531
+ | `RNGPUI_FAKE_SOUND_DIR=<dir>` | write each decoded sound as `sound-NNN.wav` instead of playing it |
532
+ | `RNGPUI_FAKE_CLIPBOARD=<file>` | `NativeClipboard` reads and writes this file instead of the clipboard (a headless Wayland window never receives the selection) |
533
+
534
+ ## Building from source
535
+
536
+ Releases ship `rngpui-service` in the platform packages above. In a checkout, `npm run build`
537
+ writes it to `native/` instead, and every launcher uses the newer of `native/rngpui-service`
538
+ and `../rust/target/release/rngpui-service`, so a plain `cargo build` is picked up too. To
539
+ build it yourself you need a Rust toolchain. macOS supplies the JavaScriptCore framework; on Linux,
540
+ install a C/C++ toolchain and WebKitGTK's JavaScriptCore headers:
541
+
542
+ ```sh
543
+ # Debian/Ubuntu (Fedora: gcc-c++ pkgconf javascriptcoregtk4.1-devel libxkbcommon-x11-devel alsa-lib-devel)
544
+ sudo apt install build-essential pkg-config libjavascriptcoregtk-4.1-dev \
545
+ libxkbcommon-x11-dev libasound2-dev libfontconfig-dev libfreetype-dev
546
+ ```
547
+
548
+ ```sh
549
+ npm run build # builds the Rust service, copies it into native/, then tsc → dist/
550
+ npm run build:rust # just the native binary
551
+ npm run typecheck
552
+ ```
553
+
554
+ Point the runtime at a specific binary with `RNGPUI_SERVICE=/path/to/rngpui-service`.
555
+ Set `RNGPUI_INSPECTOR=1` to force-enable the native inspector for any app.
556
+
557
+ `npm run npm:pack -- --out /tmp/packs` builds the two tarballs a release would publish for
558
+ this machine (the main package and its platform package) so you can install them into a test
559
+ project: `bun add react /tmp/packs/felipe-software-react-native-gpui-<version>.tgz
560
+ /tmp/packs/felipe-software-react-native-gpui-<platform>-<version>.tgz`. Until that version is
561
+ on npm, the install warns that the other platform packages are missing (404); that is
562
+ expected.
563
+
564
+ ## Releasing
565
+
566
+ Releases are automated, the same way as
567
+ [react-native-back-gesture](https://github.com/felipe-software/react-native-back-gesture):
568
+
569
+ 1. Merge a pull request with [conventional commits](https://www.conventionalcommits.org/)
570
+ into `main`. `release-main.yaml` runs semantic-release, which turns `feat`, `fix`, `perf`,
571
+ `refactor` and `revert` commits into the next patch version and pushes a `v<version>` tag
572
+ and a GitHub release.
573
+ 2. The tag starts `publish-npm.yml`. It builds `rngpui-service` on Linux x64, Linux arm64 and
574
+ macOS arm64 (ad-hoc signed with the JIT entitlement), strips its debug info, packs the
575
+ three platform packages and the main package at the tag's version, then publishes the
576
+ platform packages first and the main package last, with npm provenance. Versions already
577
+ on npm are skipped, so a failed run can simply be re-run, or started by hand with
578
+ **Run workflow** and the tag.
579
+ 3. `test.yaml` checks pull requests: typecheck, the unit tests, and `cargo test` on Linux.
580
+
581
+ The version in `package.json` is not the release version: CI sets it, and the
582
+ `optionalDependencies` on the platform packages, from the tag.
583
+
584
+ **One-time setup** (maintainers):
585
+
586
+ 1. **npm scope** `@felipe-software`, owned by the `felipe-software` npm account.
587
+ 2. **GitHub environment** `npm` in this repository (Settings → Environments), holding:
588
+ - `SEMANTIC_RELEASE_GH_TOKEN`: a fine-grained personal access token for this repository with
589
+ *Contents: read and write*. It pushes the release tag and creates the GitHub release; a
590
+ tag pushed with the default `GITHUB_TOKEN` would not start `publish-npm.yml`.
591
+ - `NPM_TOKEN`, for the first release only: an npm granular access token with read and write
592
+ access to the `@felipe-software` scope. npm can only attach a trusted publisher to a
593
+ package that already exists, so the first publish of each package uses this token.
594
+ 3. **Trusted publishers.** After the first release, open each of the four packages on
595
+ npmjs.com (`@felipe-software/react-native-gpui` and its `-linux-x64`, `-linux-arm64` and
596
+ `-darwin-arm64` packages) → Settings → Trusted publishing → GitHub Actions, with owner
597
+ `felipe-software`, repository `react-native-gpui`, workflow `publish-npm.yml` and
598
+ environment `npm`. Then delete the `NPM_TOKEN` secret. npm drops trusted publisher
599
+ settings that are never used, so set them up shortly before the next release.
600
+
601
+ ## Examples
602
+
603
+ [chatterino-gpui](https://github.com/felipe-software/chatterino-gpui) is a full app built on
604
+ the npm package: a Chatterino-style Twitch chat client. The fixtures below run from a checkout:
605
+
606
+ ```sh
607
+ bun run kitchen # the full component surface + self-validating layout
608
+ bun run conformance:animation # Animated frame progression fixture
609
+ bun run conformance:animation:diff # deterministic Animated PNG frame diff
610
+ bun run conformance:input # TextInput typing, Return submit, Shift+Return newline
611
+ bun run conformance:input-runtime # nonactivating focus, tab, IME, controlled state, latency
612
+ bun run conformance:portal # Portal overlay and TextInput-in-portal behavior
613
+ bun run conformance:native-layout # native layout override without React width state
614
+ bun run conformance:text-lines # visual fixture for Text numberOfLines
615
+ bun run conformance:chat-list # ChatList layout, events, selection, scroll anchoring
616
+ bun run perf:chat-list # ChatList CPU and frame numbers under load
617
+ bun run conformance:local-storage # localStorage across restarts, SIGTERM, corruption
618
+ bun run conformance:platform-services # fetch, crypto, notifications, sound, processes
619
+ bun run conformance:http-cache # fetch cache modes, image disk cache across launches
620
+ bun run conformance:animated-image # GIF/WebP frames advance, still images stay idle
621
+ bun run superconductor # native shell + WebView content hybrid
622
+ ```
623
+
624
+ Visual regressions can use the built-in PNG comparator:
625
+
626
+ ```sh
627
+ bun run pixel-diff before.png after.png --crop 0,0,540,300 --diff-out /tmp/rngpui-diff.png
628
+ ```
629
+
630
+ ## Status & platform
631
+
632
+ macOS (Metal) is the reference target. GPUI builds here with the `runtime_shaders` feature.
633
+ Prebuilt renderers exist for Linux x86_64, Linux arm64 and macOS on Apple silicon (see
634
+ [Install](#install)).
635
+
636
+ Linux runs on GPUI's Vulkan renderer under Wayland or X11, picked automatically: Wayland
637
+ when `WAYLAND_DISPLAY` answers, otherwise X11. It needs a Vulkan driver (Mesa or the
638
+ vendor driver) and WebKitGTK's JavaScriptCore at runtime (`libjavascriptcoregtk-4.1`,
639
+ `-6.0` or `-4.0`, whichever is installed). Compositors that leave decorations to the
640
+ app, like GNOME, get a client-side title bar with window controls and resize edges.
641
+
642
+ | Variable | Linux effect |
643
+ | --- | --- |
644
+ | `RNGPUI_LINUX_BACKEND=wayland\|x11` | force a display backend (default `auto`) |
645
+ | `RNGPUI_LINUX_DECORATIONS=client\|server` | force client- or server-side window decorations |
646
+ | `RNGPUI_WINDOW_TITLE`, `RNGPUI_APP_ID` | window title and Wayland app id (`.desktop` matching) |
647
+ | `RNGPUI_TRANSPARENT_WINDOW=1` | skip the opaque window backdrop |
648
+ | `RNGPUI_FRAME_HZ` | `requestAnimationFrame` rate (default 60) |
649
+ | `RNGPUI_JSC_LIBRARY` | load JavaScriptCore from a specific shared library |
650
+ | `RNGPUI_FRAME_CAPTURE=1` | let the dev CLI capture frames of an app it did not launch |
651
+
652
+ Not on Linux yet: `<WebView>` renders as an empty view, `<NativeButton>`/`<NativeTextInput>`
653
+ and system materials have no AppKit to wrap, in-app backdrop blur draws its tint without the
654
+ blur, and there is no accessibility tree or dock badge.
655
+
656
+ ## License
657
+
658
+ MIT