retend-gpui
v0.0.37
Published
Native desktop rendering for the Retend UI framework through GPUI.
Maintainers
Readme
retend-gpui
retend-gpui renders Retend components with GPUI through Retend's own native bridge. It is for native desktop interfaces built with Retend's JSX and reactive cells.
Status
This package is experimental. It is usable for real applications, but the native protocol and renderer API may still change between 0.0.x releases. Pin an exact version, and expect breaking changes until 0.1.
Bug reports and rough edges are welcome at github.com/resuite/retend/issues.
Install
Scaffold a new desktop project:
npx retend-start@latest my-app --target=gpui
cd my-app
npm run devOr add it to an existing Retend project:
npm install retend retend-gpui viteThe prebuilt native addon for your platform is installed automatically as an optional dependency. See Platform notes for supported platforms.
Cursor and pointer targeting
Use style={{ cursor: 'pointer' }} to select a native cursor. Supported values include
auto, default, pointer, text, crosshair, grab, grabbing, directional
resize cursors, col-resize, row-resize, vertical-text, not-allowed, alias,
copy, and context-menu. auto selects the native arrow. zoom-in and
zoom-out are unsupported because the installed GPUI has no corresponding
native cursor variants.
style={{ pointerEvents: 'none' }} makes that element's subtree
pointer-transparent: pointer input passes through to what is behind it, while
layout, paint, and keyboard focus are unaffected. auto (the default)
restores targeting. A descendant auto does not opt back in while an
ancestor is none.
Not supported yet
retend-gpui covers the renderer: elements, layout, styling, events, windows, and packaging. Several things desktop apps commonly need are not available yet:
- Full-color inline SVG.
<svg>icons are single-color (they take the textcolor), and SVG markup cannot be written as JSX children. Full-color SVG files render through<img>. - CSS filters. There is no
filterstyle (blur,saturate,invert,contrast, and so on). - System integration. There are no APIs for the system tray or menu bar, global keyboard shortcuts, the clipboard, native file dialogs, or OS notifications.
- OS permission prompts. Apps that need Microphone, Accessibility, or similar permissions must manage them outside
retend-gpui. - Browser elements and APIs. Only the elements listed under Elements exist. There is no DOM, so tags like
span,a, andsectionare not available. - IME composition events. Composition stays inside the native editor; no JavaScript composition lifecycle events are fired.
If one of these blocks you, please open an issue describing the use case. That is how these get prioritized.
Quick start
Create a component with Retend's JSX, then pass it to renderToGpui:
import { Cell } from 'retend';
import { renderToGpui } from 'retend-gpui';
function App() {
const count = Cell.source(0);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 24,
width: '100%',
height: '100%',
}}
>
<div>Count: {count}</div>
</div>
);
}
await renderToGpui(App, {
title: 'Counter',
width: 640,
height: 420,
});The renderer opens a GPUI window through the Retend-owned native binding and submits the Retend tree through the binary command protocol. renderToGpui resolves with the RetendGpuiRenderer if the application needs to inspect or dispose of the renderer later.
Vite development
Vite-managed applications separate process-wide setup from per-window rendering. The application module default-exports a class with a stable context object:
import type { GpuiApplication } from 'retend-gpui';
export default class Application implements GpuiApplication<{
database: Database | null;
}> {
readonly context = { database: null };
async init() {
this.context.database = await openDatabase();
}
async cleanup() {
await this.context.database?.close();
}
}Configure the application class, the per-window root component, application metadata, and the initial window in vite.config.ts:
import { retendGpui } from 'retend-gpui/plugins/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
retendGpui({
app: {
name: 'Notes',
identifier: 'com.example.notes',
version: '1.0.0',
icon: './assets/icon.png',
},
application: './source/application.ts',
entry: './source/main.tsx',
system: { transparentTitlebar: true },
window: { width: 900, height: 640 },
}),
],
});Run retend-gpui dev. It starts Vite without an HTTP listener and forks one Node.js application process with the configured initial window. Applications can open additional independent windows through useWindow().open(options). system.transparentTitlebar makes the native titlebar transparent on macOS and Windows and applies to every window in the application. Vite full reloads recreate application/module state and remount every live window without replacing its native window, and a remounted router starts again from the window's initialPath; Vite/config restarts replace the whole application process.
Components read process-wide resources with useAppContext(). Projects created by retend-start include retend-gpui-env.d.ts immediately, typing the context from the configured application module. Each dev/build startup verifies that declaration and rewrites it only when it is missing or stale, so changing the configured application path repairs the types automatically without touching the file on every run. Add it to your tsconfig include if you maintain the project manually; like next-env.d.ts, it can be committed.
import { useAppContext, useSystem, useWindow } from 'retend-gpui';
export default function App() {
const { database } = useAppContext();
const system = useSystem();
const window = useWindow();
return (
<div>
{database ? window.title : 'Database unavailable'} — {system.theme}
</div>
);
}useWindow() returns the window associated with the current Retend root. Its readonly width, height, and orientation Cells track native resize events, its title Cell writes through to the OS window, open(options) creates another independent native window, and close() requests closure of the current window.
useSystem() returns reactive operating-system preferences shared by every GPUI renderer in the process, including components rendered with renderToGpui(). theme tracks light/dark appearance changes, and accentColor tracks the native accent as a CSS hex color on macOS and Windows (using the Windows UI accent preference, not the DWM titlebar color). Both Cells are null until a native window reports the initial values; accentColor remains null on platforms without a supported system accent API.
TypeScript and JSX
Configure TypeScript to use Retend's JSX runtime and the retend-gpui JSX types:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "retend",
"types": ["retend-gpui/jsx-runtime"]
}
}The component passed to renderToGpui must return Retend JSX. retend-gpui does not use the browser DOM, so browser-only elements and APIs are not available.
Elements
The currently implemented Retend GPUI intrinsic surface is deliberately small:
divanchoredimgsvginputtextareabutton
Text is ordinary JSX content rather than a <text> intrinsic. Adjacent text nodes, including JSX interpolations such as Count: {count}, render as one inline run that wraps with its container. input uses the native single-line editor, while textarea uses the native multi-line editor with wrapping and optional minRows/maxRows auto-sizing. Both accept a placeholder, shown while the control is empty.
anchored is the native floating-layer primitive used by menus, tooltips, and similar floating controls. It delegates placement to GPUI rather than measuring in JavaScript. side (top/right/bottom/left), align (start/center/end), gap, and offset place content relative to its parent slot; position={{ x, y }} instead uses window coordinates. fit="snap" (the default) keeps the layer inside the window with snapMargin, while fit="switch" uses GPUI's anchor-flipping behavior. Layers are deferred and occluding by default; deferred, priority, and occlude expose those native controls directly. Margins are intentionally unsupported on <anchored> because GPUI requires an anchored child to be margin-free; use gap/offset, or put the anchored element inside a wrapper when ordinary layout margin is needed.
svg draws a single-color vector icon in the current text color, like CSS currentColor: an icon follows the color of its container, including hover and active styles, unless it sets its own color. Pass SVG markup as content (for example from a ?raw import) or a local file as src; content wins when both are set. Icons are 16 × 16 by default; set width and height to resize them. alt provides the accessible label. Use <img> for full-color SVG artwork.
import check from './icons/check.svg?raw';
<button style={{ color: '#555', hover: { color: '#000' } }}>
<svg content={check} alt="" />
Save
</button>;button is a native control with default styling: it centers its children in a padded, rounded, neutral surface with a hairline border and activates on click or Enter/Space when focused. It participates in Tab traversal by default, like native text controls. disabled skips it in Tab traversal, ignores pointer and keyboard activation, and renders it dimmed.
const count = Cell.source(0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>Clicked {count} times</div>
<button onClick={() => count.set(count.get() + 1)}>Increment</button>
</div>
);input and textarea are native controls with default styling: comfortable padding, a 6px corner radius, a white surface, and a hairline border. Author style declarations override those defaults per field, so a form field needs no surface styling of its own.
Events
Retend GPUI exposes JSX handlers for native pointer/button events and keydown/keyup, plus the mousedownoutside extension. Retend owns capture/target/bubble propagation over the logical node tree, and GPUI nodes also expose addEventListener(), removeEventListener(), and dispatchEvent() for imperative/custom events. mouseenter and mouseleave are non-bubbling; mousedownoutside is target-only for each qualifying subscriber. Focus/blur, input/change, and element-scroll events are delivered from the native focus, editor, and scrolling systems. IME/composition remains native editor state; separate JavaScript composition lifecycle events are outside v1.
Reactive values
Retend cells can drive text, properties, and styles:
const label = Cell.source('Waiting');
const color = Cell.derived(() =>
label.get() === 'Ready' ? '#00aa66' : '#777777'
);
return <div style={{ color }}>{label}</div>;The renderer also handles asynchronous Retend values used for text, control flow, intrinsic properties, and top-level style properties.
The Retend-owned bridge parses its typed authoring vocabulary into Retend-native Rust values. The renderer publishes each resolved author style as one complete sparse snapshot; declarations disappear when omitted from the next replacement snapshot. The surface includes block/flex layout, alignment and gaps, dimensions, spacing, positioning, colors/opacity, borders, inherited text styling, overflow/scrolling, hover/focused/active pseudo-states, and native transitions for the supported animatable properties. Numbers are logical pixels; dimensions also accept auto, pixel strings, and percentages.
The application root defaults to a white background with black text. Explicit root backgroundColor and color styles override these defaults.
Window options
renderToGpui accepts the window options currently implemented by the Retend-owned bridge:
await renderToGpui(App, {
title: 'My app',
width: 900,
height: 600,
});Initial width, height, title, resizable state, fullscreen/maximized state, and minimum/maximum size constraints are applied by the native window bridge. Vite-managed applications can open additional independent windows through useWindow().open(options). useWindow().width and height are readonly Cells kept current by native resize events, and useWindow().orientation is a readonly reactive Cell containing portrait or landscape (portrait includes square windows). useSystem() exposes process-wide theme and accentColor Cells updated from native system preferences. The bound window object emits per-window focus and blur lifecycle events.
Platform notes
macOS support requires Apple Silicon (arm64). Intel Macs are not supported: there is no Intel CI hardware left to validate on, and Apple has frozen Intel macOS releases.
The macOS GPUI backend is a patched gpui-pre-macos 0.3.4 snapshot. The patched crate lives in the sibling gpui-pre repo (crates/gpui-pre-macos), not in this tree. The patch adds an embedded NSApplication / CFRunLoop pump so GPUI can run inside the Node/napi process.
The Retend-owned runtime owns process keep-alive while native windows exist. On macOS a CVDisplayLink hops onto the process main thread to pump AppKit/GPUI at display refresh; applications do not run a JavaScript frame timer. retend-gpui is a native renderer and does not run in a browser.
Lower-level API
Most applications should use renderToGpui. For custom startup or testing, create the renderer yourself:
import { RetendGpuiRenderer } from 'retend-gpui';
const renderer = new RetendGpuiRenderer();
renderer.init({ title: 'My app', width: 900, height: 600 });
renderer.render(App);Call renderer.flush() after making changes when you manage rendering manually. Call renderer.dispose() when the application is finished with the renderer. renderer.host exposes the window-local navigation facade and Retend-owned native command host.
Troubleshooting
Unsupported intrinsic element
If the renderer throws an error for an element, check that the tag is in the supported list above. HTML elements such as section, span, and a are not automatically available.
Development application identity on macOS
The dev child runs the local Node executable through a minimal temporary .app bundle carrying app.name and app.identifier, installs the native application menu, and sets the Dock icon from app.icon (or the app.macos.icon override) before the first window opens. This gives LaunchServices, the Dock, and Mission Control the configured application identity without changing the Node runtime or renderer architecture. The native menu is also initialized in packaged macOS apps so AppKit can provide standard fullscreen menu-bar/titlebar reveal behavior. On Windows, Retend sets the explicit AppUserModelID from app.identifier and applies app.icon (or app.windows.icon) to every window and taskbar button. Windows icons may be .ico, .png, or .svg. A Windows build creates dist/win32-x64/<AppName>.exe (or win32-arm64) with the icon embedded in the executable. Keep the adjacent native/, assets/, and AppIcon.* files with the executable when testing or distributing it. Native bundle identity remains the packaged .app; Linux applies identity as part of its packaging work.
JSX types are missing
Make sure the application has both jsxImportSource: "retend" and types: ["retend-gpui/jsx-runtime"] in its TypeScript configuration. Also make sure the JSX file uses a .tsx extension.
