Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Game Engine Integration

Rinch supports two complementary integration patterns for game engines and custom renderers:

  1. Embed API — Your game owns the window and GPU. Rinch provides UI as a Vello scene you composite on top.
  2. RenderSurface — Rinch owns the window. Your game/renderer submits frames (CPU pixels or GPU textures) into a DOM component.

RenderSurface is a component that embeds external pixel content into rinch’s layout. Your renderer submits frames via a thread-safe SurfaceWriter (CPU pixels) or GpuTextureRegistrar (GPU textures), and rinch composites them during paint. Input events (mouse, keyboard) are routed back to your event handler.

This is the simpler pattern — rinch handles windowing, layout, and event dispatch. You just provide pixels and handle surface-local events.

Quick Start

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    let surface = create_render_surface();

    // Handle input events from the surface
    surface.set_event_handler(|event| {
        match event {
            SurfaceEvent::MouseDown { x, y, button } => { /* handle click */ },
            SurfaceEvent::MouseMove { x, y } => { /* handle hover */ },
            SurfaceEvent::MouseWheel { delta_y, .. } => { /* handle zoom */ },
            SurfaceEvent::KeyDown(key) => { /* handle keyboard */ },
            _ => {}
        }
    });

    // Submit frames from a worker thread
    let writer = surface.writer();
    std::thread::spawn(move || {
        loop {
            let pixels = render_frame(); // your renderer
            writer.submit_frame(&pixels, width, height);
            std::thread::sleep(std::time::Duration::from_millis(16));
        }
    });

    rsx! {
        div { style: "display: flex; height: 100%;",
            div { style: "width: 200px;",
                // Sidebar with rinch UI controls
                Button { onclick: || do_something(), "Tool" }
            }
            RenderSurface { surface: Some(surface), style: "flex: 1;" }
        }
    }
}

fn main() {
    App::new(app).title("My App").size(1280, 720).run();
}

CPU Pixel Submission

SurfaceWriter is Send + Sync + Clone — safe to use from any thread:

#![allow(unused)]
fn main() {
let surface = create_render_surface();
let writer = surface.writer();

// From any thread:
let pixels: Vec<u8> = render_rgba(width, height);
writer.submit_frame(&pixels, width, height);
}

Pixels are RGBA8, row-major. The surface redraws automatically after submit_frame().

GPU Texture Compositing

For zero-copy compositing, use GpuTextureRegistrar to provide a wgpu::TextureView directly:

#![allow(unused)]
fn main() {
let surface = create_render_surface();
let registrar = surface.gpu_registrar();

// Get the shared wgpu Device via gpu_handle(). It is None while the window
// presents with the software renderer (RINCH_RENDERER=cpu, or the fallback
// when the GPU would not start), so handle that case.
let Some(gpu) = gpu_handle() else {
    // No GPU device: submit CPU pixels through surface.writer() instead.
    return;
};
let device = &gpu.device;
let queue = &gpu.queue;

// Create your texture and render to it
let texture = device.create_texture(&wgpu::TextureDescriptor { /* ... */ });
// ... render into texture ...

// Register the texture for compositing — pass BOTH the texture and its view.
// rinch needs the texture for the GPU→CPU readback path (inline-painted
// RenderSurface) and the view for compositing it directly.
let view = texture.create_view(&Default::default());
registrar.set_texture_source(texture, view, width, height);
registrar.notify_frame_ready();
}

GpuTextureRegistrar is also Send + Sync + Clone. The texture must be created on the same wgpu::Device (available via gpu_handle()).

gpu_handle() returns None until the window’s GPU renderer has started, and for the whole session when the window presents with the software renderer: Renderer::Software was chosen, a user set RINCH_RENDERER=cpu, or the GPU would not start and Renderer::Auto fell back (see Rendering Backends). An app that configures the device with App::gpu_config or App::external_gpu (below) is exempt: it always presents on the GPU, or panics at startup if the GPU will not start, so once its window is up the handle is there.

Use rinch::wgpu. rinch pins a patched wgpu fork. Construct every wgpu type you hand back to rinch (TextureView, config features/limits, …) from rinch::wgpu::…, not a separately-pinned wgpu dependency, or the types won’t match.

Sharing a High-Capability GPU Device

Zero-copy compositing requires your texture to live on the same device rinch composites with. By default rinch requests that device with Features::default() / Limits::default(). If your renderer needs more (extra features, larger storage buffers, more bind groups, …), raise the device’s requirements at startup so the shared device — the one published via gpu_handle() — can host your pipelines too.

Let rinch own the device (recommended). rinch still creates the instance, picks a surface-compatible adapter, and creates the device, so presentation is always correct — you only add the capabilities you need:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::wgpu;

let gpu = RinchGpuConfig {
    required_features: wgpu::Features::FLOAT32_FILTERABLE,
    required_limits: wgpu::Limits {
        max_storage_buffers_per_shader_stage: 32, // wgpu default is 8
        ..Default::default()
    },
};
App::new(app).gpu_config(gpu).run();
}

After startup, gpu_handle() returns the shared device, queue, and adapter (use adapter.limits() to clamp, e.g. max_buffer_size = min(adapter, 2 GiB)). The requested features/limits are passed through verbatim — if the adapter can’t satisfy them, device creation fails loudly rather than silently dropping a capability.

Bring your own device. If you must keep your renderer’s exact DeviceDescriptor, build the whole GPU stack and hand it to rinch. rinch creates only the window surface (from your instance), validates that your adapter can present to it, and composites directly onto your device — no request_device, no CPU readback:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rinch::prelude::*;

let gpu = ExternalGpu {
    instance,                    // rinch creates the window surface from this
    adapter: Arc::new(adapter),  // must be able to present to the window
    device: Arc::new(device),
    queue: Arc::new(queue),
};
App::new(app).external_gpu(gpu).run();
}

The provided device is published through gpu_handle(). On a single-GPU machine a headless adapter (compatible_surface: None) usually presents fine; on multi-GPU systems create the adapter from a surface-compatible one, or prefer App::gpu_config (which guarantees compatibility). See the gpu-device-config example for both modes.

Layout Size

Query the surface’s current layout dimensions (set by CSS/Taffy) to match your render resolution:

#![allow(unused)]
fn main() {
let (w, h) = surface.layout_size();
// or from the registrar:
let (w, h) = registrar.layout_size();
}

Surface Events

Events are dispatched to the handler set via set_event_handler(). Coordinates are in logical pixels relative to the surface’s top-left corner.

EventFieldsDescription
MouseDownx, y, buttonMouse button pressed
MouseUpx, y, buttonMouse button released
MouseMovex, yMouse moved over surface
MouseWheelx, y, delta_x, delta_yScroll wheel
MouseEnterx, yCursor entered surface
MouseLeave—Cursor left surface
KeyDownSurfaceKeyDataKey pressed (when focused)
KeyUpSurfaceKeyDataKey released (when focused)
TextInputStringText input (when focused)
FocusGained—Surface received keyboard focus
FocusLost—Surface lost keyboard focus

A press keeps the pointer. From a press on the surface until the release of the button that started it, every move and release of that pointer go to the surface wherever it is (coordinates then fall outside the surface’s size), and it hears no MouseLeave until then; a MouseLeave follows a release off the surface. A press or release of another button meanwhile is a chord: the surface hears it and keeps the pointer. The browser backend does this with setPointerCapture. Desktop routes the surface’s events the same way, but not the document’s: DOM hover (:hover, onmouseenter / onmouseleave) and the cursor still follow the element under the pointer during the press, where a browser routes them to the capturing canvas.

A press whose release never arrives is ended without one, and the surface hears it ended — PointerCancel with pointer events, else a MouseUp where the cursor is: on desktop when the window loses focus, when the same pointer presses the left button again (another finger’s press proves nothing), on a platform PointerCancel (Android’s touch-scroll takeover), and silently when the surface unmounts; in the browser on pointercancel.

Pen, touch and pinch: pointer events (opt-in). A drawing surface wants to know which device pressed and how hard. surface.set_pointer_events(true) replaces MouseDown / MouseMove / MouseUp with these (every other surface hears exactly what it did before):

EventFieldsDescription
PointerDownx, y, button, pointerA button, pen tip or finger went down
PointerMovex, y, pointerIt moved (or its pressure changed)
PointerUpx, y, button, pointerThe press ended
PointerCancelpointerThe platform took the pointer away mid-press; no PointerUp follows
Pinchx, y, scaleTwo touches spreading or closing, a trackpad pinch, or Ctrl+wheel; scale multiplies the zoom (above 1 is in), relative to the last Pinch

SurfacePointer is { id, kind, pressure, primary }: kind is Mouse, Pen, Eraser, Touch or Unknown; pressure is 0.0..=1.0, and a device that cannot measure it reports 0.5 while down and 0 otherwise (the Pointer Events rule); id tells pointers that are down at once apart (two fingers) and stays the same from a press to its release. Compare ids for equality only: their values are the platform’s. On desktop the mouse is 1, a tablet tool 2 and each finger an id of its own above both; in the browser id is the event’s pointerId, whose value for a mouse differs between engines. What each host reports:

  • Browser: Pointer Events (pointerType, pressure, pointerId, isPrimary, the eraser button); two touches pinch; Ctrl+wheel (a trackpad pinch) pinches.
  • Desktop: winit’s pointer source (a tablet tool’s force, a touch’s force where measured); two touches pinch; a trackpad pinch (macOS, iOS, Wayland) and Ctrl+wheel pinch. One move per pointer per frame, as the browser does without getCoalescedEvents.
  • Android: every finger is folded into one mouse pointer (id 1, kind Mouse) by the touch translation, so there is no Touch kind and no two-finger Pinch.
  • Embed (RinchContext): the host feeds plain mouse events, so every pointer is the mouse (Mouse, id 1); Ctrl+wheel still pinches.

SurfaceKeyData contains key, code, ctrl, shift, alt, meta. key is spelled like the browser’s KeyboardEvent.key on both backends, so the space bar is key == " " and code == "Space".

A focused surface only swallows the keys it claims (issue #482). set_event_handler keeps receiving every KeyDown/KeyUp exactly as above — that delivery is unaffected. set_key_handler(handler: impl Fn(&SurfaceKeyData) -> bool) is a separate, additive registration asked after: should this key stop at the surface? Leave it unset — the default — and the surface claims nothing, so a host’s own window-level keybindings (and, on the web, the browser’s own reload/find/… shortcuts) keep working the moment the canvas is focused, and on desktop so do DevTools (F12), inspect mode (Alt+I) and Tab:

#![allow(unused)]
fn main() {
surface.set_key_handler(|key| matches!(key.code.as_str(), "KeyW" | "KeyA" | "KeyS" | "KeyD" | "Space"));
}

The document-level keyboard interceptor (set_keyboard_interceptor) is unaffected either way — it already sees every key before a focused surface does, on both backends.

On the web, an unclaimed key also keeps the browser’s default action — no longer preventDefault()ed just because the surface is focused, matching an unfocused <canvas>. Space, PageUp/Down and the arrow keys can scroll the page, and Tab leaves the canvas. A web game steering with any of those must claim them via set_key_handler, or the page scrolls under it.

Web (canvas viewport)

The same RenderSurface + create_render_surface() API works on rinch-web, but the model is inverted. On the web the browser composites, so rinch only creates and manages a real <canvas> “viewport hole” sized by layout; the app owns the GPU context (rinch links no wgpu on web). This mirrors desktop symmetrically: desktop = rinch owns the window and you submit frames; web = you own the canvas surface.

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::render_surface::{create_render_surface, RenderSurface};

let surface = create_render_surface();

// After mount, grab the <canvas> and create your own wgpu WebGPU surface on it.
// (canvas_element() returns None until the component has mounted.)
if let Some(canvas) = surface.canvas_element() {
    let wgpu_surface = instance
        .create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
    // request adapter/device (async on web), configure, and render each frame.
}

// Resize notification (ResizeObserver-driven). Size is PHYSICAL px
// (CSS px × devicePixelRatio), matching desktop — HiDPI-correct out of the box.
surface.set_resize_callback(move |w, h| reconfigure_wgpu_surface(w, h));

// Per-frame tick (drives requestAnimationFrame on web).
surface.set_render_callback(move |_writer, w, h| render_triangle(w, h));

rsx! { div { style: "flex:1;", RenderSurface { surface: Some(surface) } } }
}

Notes:

  • HiDPI out of the box. layout_size() reports physical pixels (CSS px × devicePixelRatio), rinch sizes the canvas backing store to match, and set_resize_callback pushes the new physical size on every resize so you can reconfigure your wgpu/WebGL surface.
  • Input (pointer / wheel / keyboard incl. KeyUp / focus) over the canvas is delivered to set_event_handler — rinch does not swallow it. Click the canvas to focus it for keyboard events.
  • CPU path still works & is portable. SurfaceWriter::submit_frame blits via a lazily-created 2D context; it self-disables once you claim a WebGPU/WebGL context. So the same app can run on desktop and web.
  • Clean teardown. The ResizeObserver and canvas listeners are removed when the component unmounts — no leaks.
  • See examples/webgpu-surface-web (a WebGPU triangle in a rinch DOM UI) — the web counterpart of examples/game-embed. Build with trunk serve over localhost (so navigator.gpu is available; the webgl cargo feature is a fallback).

API Reference: RenderSurface

Function / TypeDescription
create_render_surface()Create a new surface handle
RenderSurfaceHandleMain handle — set event handler, get writer/registrar
SurfaceWriterThread-safe CPU pixel submission (Send + Sync + Clone)
GpuTextureRegistrarThread-safe GPU texture registration (Send + Sync + Clone)
RenderSurfaceComponent — use in RSX with surface: Some(handle)
SurfaceEventInput event enum dispatched to handler

RenderSurfaceHandle methods:

MethodDescription
writer()Get a SurfaceWriter for CPU pixel submission
gpu_registrar()Get a GpuTextureRegistrar for GPU texture compositing
set_event_handler(handler)Set input event callback (main thread closure)
set_key_handler(handler)Fn(&SurfaceKeyData) -> bool; whether a key already delivered to set_event_handler should stop here instead of continuing to a window-level host keybinding (web) or DevTools/inspect/Tab (desktop). Unset: claims nothing (issue #482)
set_render_callback(cb)Per-frame FnMut(&SurfaceWriter, w, h) — drives requestAnimationFrame on web
set_resize_callback(cb)FnMut(w, h) fired on backing-size change (physical px) — reconfigure a GPU surface
layout_size()Get current physical (width, height) (web: CSS px × devicePixelRatio)
canvas_element()(web only) the <canvas> after mount — create a WebGPU/WebGL context on it
set_texture_source(texture, view, w, h)(desktop, gpu) Set GPU texture directly (main thread only)
has_texture_source()Check if a GPU texture is registered
id()Surface ID
viewport_name()Internal viewport name

Embed API

The embed API is for when your game owns the window and wgpu device. Rinch runs headless — you feed it platform events, it produces a Vello scene, and you render/composite it yourself.

Enable with: features = ["desktop"]

Quick Start

use rinch::prelude::*;
use rinch::embed::{RinchContext, RinchContextConfig, RinchOverlayRenderer};

#[component]
fn game_hud() -> NodeHandle {
    let health = Signal::new(100);
    rsx! {
        div { style: "position: absolute; top: 10px; left: 10px;",
            Text { size: "lg", color: "white", {|| format!("HP: {}", health.get())} }
        }
    }
}

fn main() {
    // Your game creates the window and wgpu device
    let (device, queue, surface, window) = my_engine::init();

    // Create rinch UI context
    let mut ctx = RinchContext::new(
        RinchContextConfig {
            width: 1280,
            height: 720,
            scale_factor: window.scale_factor(),
            theme: None,
        },
        game_hud,
    );

    // Create overlay renderer from your device
    let mut overlay = RinchOverlayRenderer::new(
        &device, 1280, 720, wgpu::TextureFormat::Rgba8Unorm,
    );

    // Game loop
    loop {
        let events = collect_platform_events(&window);
        let actions = ctx.update(&events);

        for action in &actions {
            match action {
                AppAction::SetCursor(cursor) => { /* set cursor */ },
                AppAction::Exit => return,
                _ => {}
            }
        }

        game.render(&device, &queue);
        let ui_texture = overlay.render(&device, &queue, ctx.scene());
        composite(&device, &queue, &surface, game_texture, ui_texture);
    }
}

RinchContext

RinchContext is the main handle. Create it during initialization, call update() each frame.

Multiple contexts. Several RinchContexts can coexist on one thread — a screen HUD plus N render-to-texture panels, say. Each context tracks signal changes through its own subscription (so creating or dropping one never silences another) and keeps its own document-scoped element-bounds signals, editor registrations, and focus requests. Stores/contexts are namespaced per context (#136): a create_store inside a context’s component lands in that context’s own namespace, its effects and event handlers resolve that namespace first, and lookups fall back to the thread-global namespace for stores created outside any context (e.g. before mounting). Two contexts can therefore create the same store type without overwriting each other, and dropping a context clears its namespace. One caveat remains: each context still processes the input events you feed it — route each window’s events only to its own context.

Background threads. The documented cross-thread pattern works in embed: Signal::send() / Signal::update_send() from a worker, and run_on_main_thread() for anything else, marshal the write onto the thread that created the context and apply it at the top of the next update() — before the events of that frame, so a queued write and an event handler are absorbed by the same layout pass. Everything that rides that transport comes with it: set_timeout, rinch-http completions, rinch-ws events. Signal::set() and Signal::update() still panic off the main thread — only the _send variants marshal.

The queue is process-global, so a RinchContext you drive drains work queued against any context on the thread. That is fine: the payload writes its own signals, signals are thread-local rather than per-document, and the document it touches is still repainted by its own update().

Before issue #172 an embedded context registered the main thread but no dispatcher, and a background-thread send() panicked on the calling thread. If you pinned an older rinch and wrapped your worker writes to avoid it, you can drop the workaround.

Timed work: the caret blink. A focused rich-text Editor’s caret toggles every 530 ms, and update() ticks that clock just as the desktop runtime does after each event-loop turn (issue #331). A host that calls update() every frame needs nothing more. An event-driven host — one that renders only when input arrives — asks ctx.next_wake() after each update(): it is the instant the caret next toggles (what desktop arms its ControlFlow::WaitUntil with), or None when nothing is blinking. Schedule a wake for it, or the caret toggles only when some unrelated event happens to arrive. needs_update() answers true once that instant has passed. A blurred host window (PlatformEvent::WindowFocus(false)) stops the blink with the caret solid and arms no wake. Each context has its own blink clock (issue #1149): two contexts on one thread that each have a focused editor both blink, each on its own phase, and blurring or typing in one leaves the other’s blink alone.

RenderSurface in an embedded context. A RenderSurface works inside a RinchContext the way it does on desktop: scene() tells each surface in that context’s document its physical layout size, runs its render callback, and paints its latest frame inline at the surface’s box (issue #331). Call scene() once per frame — every call runs the render callbacks again. A frame submitted from another thread makes needs_update() true and the next update() answer RequestRedraw. Several contexts on one thread each drive only their own surfaces, and a thread with no RenderSurface at all pays nothing for this in scene(). Three things are not composited by rinch in an embedded context, because the host owns compositing there: a surface registered under a data-viewport name (create_render_surface_with_name), a <video> / VideoViewport (whose frame sink is installed only by the desktop runtime), and a GpuTextureRegistrar texture on a RenderSurface (read back only through the desktop runtime’s own device, so in an embedded context it draws nothing, and its notify_frame_ready asks for no redraw). Treat them like a GameViewport hole: render into your own target and composite it under rinch’s overlay.

Input Routing

For HUD overlays, use wants_mouse and wants_keyboard to decide whether input goes to the UI or the game:

#![allow(unused)]
fn main() {
if ctx.wants_mouse(mouse_x, mouse_y) {
    ctx.update(&[mouse_event]); // UI element under cursor
} else {
    game.handle_mouse(mouse_x, mouse_y); // game content
}

if ctx.wants_keyboard() {
    ctx.update(&[key_event]); // text input focused
} else {
    game.handle_key(key); // game shortcuts
}
}

A mouse-clicked plain button does not claim wants_keyboard() — a Tab-focused one still does (issue #548). A <button>/<a href> is focusable with no tabindex needed, so a single click on one (no Tab involved) used to make wants_keyboard() answer true until the next click landed somewhere else — a host following the pattern above then stopped seeing its own Esc/hotkey presses the moment the user clicked any button in a HUD. The fix follows the browser’s own :focus-visible split rather than “is a node focused” or “did it register anything”:

  • A mouse click on a plain button/link only ever consumes Enter/Space through rinch’s own activation path, so it does not need the host’s keyboard — wants_keyboard() is false.
  • Tab (or a programmatic NodeHandle::focus()) onto that same plain button sets its keyboard-focus ring (is_focus_visible), and wants_keyboard() is true — so Tab navigation and Enter/Space activation of ordinary controls keep reaching rinch under the pattern above, exactly as before #548’s fix.
  • A custom widget that registered its own FocusEntry::on_key (register_focus_target — arrow-key navigation, a shortcut of its own) is true whichever way it was focused: the runtime can’t know which keys it wants without seeing them.
  • An open native <select> popup is always true too — its own arrow/Enter/Escape handling needs every key (a separate, pre-existing gap folded into the same fix).

A viewport hole must stay hittable — that is what routes the mouse. wants_mouse only reports “the game wants this” when the hit-tested node has a data-viewport ancestor. A hole that is not hittable inverts the routing: the hit falls through to body behind it, the walk up from body finds no data-viewport, and wants_mouse answers true — everywhere. The game silently receives no mouse input at all, with nothing in the layout or the render looking wrong.

Since #207 GameViewport needs no defending: every data-viewport node gets pointer-events: auto; background: transparent; from the UA stylesheet, so both GameViewport { name: "main" } and GameViewport { name: "main", style: "flex: 1;" } route correctly. Because those defaults are a UA rule rather than an inline style, restyling the hole cannot strip them — worth knowing, since a component’s universal style: prop replaces the component’s inline style rather than merging with it. Being a UA declaration, it also survives pointer-events: none inherited from a HUD root, and still loses to any pointer-events you declare on the viewport yourself (which is how you deliberately hand a region back to the UI).

The inheritance trap is still real for everything you build yourself. pointer-events is inherited, so pointer-events: none on a HUD root — the natural way to let clicks fall through to the game — reaches every descendant. Your own HUD controls, and any hand-rolled hole that isn’t a data-viewport node, need an explicit opt-in:

#![allow(unused)]
fn main() {
div { style: "pointer-events: none;",          // HUD root: clicks fall through
    GameViewport { name: "main", style: "flex: 1;" }   // hittable by default
    div { style: "pointer-events: auto;", /* real HUD controls */ }
}
}

That inheritance is spec-correct, not a bug — but the overlay idiom that triggers it is the obvious one to reach for, so it is worth designing around up front. (visibility: hidden is stronger still on desktop: it makes an entire subtree unhittable, and a descendant that sets visibility: visible is painted but still not hittable there — issue #843. A browser, and so rinch-web, lets that descendant take pointer events.)

Split Layout (Viewport Hole)

Use GameViewport to mark a transparent region where the game renders:

#![allow(unused)]
fn main() {
use rinch::embed::GameViewport;

#[component]
fn editor_ui() -> NodeHandle {
    rsx! {
        div { style: "display: flex; flex-direction: column; height: 100%;",
            div { class: "toolbar",
                Button { onclick: || save(), "Save" }
            }
            div { style: "display: flex; flex: 1;",
                div { style: "width: 200px;", /* side panel */ }
                GameViewport { name: "main", style: "flex: 1;" }
            }
        }
    }
}
}

Query the viewport rect to set your game’s render region:

#![allow(unused)]
fn main() {
if let Some(rect) = ctx.viewport_rect("main") {
    game.set_viewport(rect.x, rect.y, rect.width, rect.height);
}
}

A hole with nothing behind it: data-viewport-ready. Cutting the hole removes the ancestors’ background — <body>’s included, since the UA sheet makes it overflow-y: auto — so on a transparent window a hole nothing fills is see-through to the desktop, not to your app (#186). A viewport whose content can be absent opts out by stamping data-viewport-ready="false" on the hole; paint then leaves the backgrounds alone and the node paints its own background, which is the natural seat for a poster or an error affordance. Set it back to "true" once there is something to show.

The attribute is an opt-out, and absence means ready: a data-viewport node that never stamps it punches unconditionally, exactly as before. GameViewport is therefore unaffected — it stamps nothing, keeps its hole from the first frame, and needs no change. VideoViewport (rinch-video) is the one built-in that opts in: it stays "false" until the decoder has handed a real frame to the compositor, and flips back to "false" on a PlaybackState::Error. A node that does carry the attribute must say exactly "true" to punch, so a mis-stamped value fails to the safe side — an opaque placeholder, never a see-through window.

Not ready also means no frame, on both backends. A surface keeps the last buffer it was given, so a video that errors mid-stream still has one; while its node says "false" that frame is not drawn — the software backend paints the node as an ordinary element, and the GPU backend hands the compositor no layer for it — and the node’s own background is what shows. Say "true" again and the frame the surface holds is back, with no new frame needed.

Since #358 software cuts no hole for a video at all (see below), so there the gate decides only whether the frame or the node’s #000 placeholder is drawn.

A hole bigger than what fills it: black bars (GPU). The complementary case (#354). The GPU compositor aspect-fits a frame inside its viewport, so a source whose aspect ratio differs from the box leaves letterbox or pillarbox bars — inside the punched hole, but covered by no layer. That was see-through to the desktop for a video playing perfectly. The compositor now pushes an opaque black layer over the whole viewport box under the frame’s own layer, so the bars come out black exactly as a browser paints them for <video>, and the hole is always fully covered.

The black is painted by the compositor, not by the viewport element. That distinction is the whole design: the compositor draws under the Vello UI, so giving the element an opaque background would hide the video entirely. GameViewport is unaffected — its surface fills its box, so the fit is exact and the backdrop is entirely overdrawn.

On the software backend the same bars are black for a different reason — see the next note.

Software paints every viewport inline. The two backends route viewport frames differently, and the split is worth knowing if you are reading the compositing code (#358, #361).

RenderSurfacevideoGameViewport
softwareinlineinlineinline
GPUinlinecompositor + black backdropcompositor

Software used to blit its compositor frames onto the finished pixel buffer, after the whole UI had been painted, clipped only by the viewport’s overflow-clipping ancestors. That write has no notion of occlusion, so every overlay above a playing video or a game — the nav drawer, Modal, DropdownMenu, Select’s popup, tooltips, a HUD, DevTools — was overwritten. GPU has no such problem: its layers go down first and the Vello UI alpha-blends on top, so an opaque drawer already covers the layer there.

So on software a frame goes through paint instead, on the same inline path a RenderSurface component has always used: the data-viewport node draws the frame object-fit: contain inside its box, at its own z-order. Overlays occlude it by ordinary paint order, with no occlusion tracking anywhere, and a new frame’s damage is the viewport’s box — not a full repaint. A paint with no new frame (a hover elsewhere over a paused game) does not damage the viewport; it is repainted only where something else’s damage reaches it — a box moving across it, an overlay above it closing — and then from the frame it already holds.

It is not slower than the blit was. submit_frame checks, on the thread that submits, whether every pixel is opaque; an opaque frame is never premultiplied, and one that lands on whole pixels — at its natural size, or scaled to a box whose edges are whole pixels — is copied into the surface row by row, byte-identical to the sampled draw. Measured in release on one host (game_viewport_inline_bench), per frame on the UI thread, inline vs the old blit: a 1920×1080 frame in a 1920×1080 viewport 1.5 vs 4.8 ms, the same frame in a 960×540 viewport 2.3 vs 2.3 ms, a 320×180 frame in 640×360 0.7 vs 0.8 ms. A frame with any translucent pixel is premultiplied and sampled per draw, as any image is.

What still tells video and GameViewport apart is the hole:

  • Video punches none: it is no compositor frame, so its name is absent from the active-viewport set and #186’s filter reads “absent ⇒ do not punch”. The node fills opaque black under the frame, and the contain fit plus that fill are #354’s letterbox bars on this backend.
  • GameViewport keeps its hole while it has a frame, as it always had: its ancestors’ backgrounds are cut away under it and no black is drawn, so where the frame does not reach — a letterbox, or pixels the game left transparent — is the hole, see-through on a transparent window.

The two are told apart by an explicit flag set at video’s registration site, not by the viewport’s name — they share create_render_surface_with_name, so a naming rule would treat the game viewport as video. A surface carrying a GPU texture source never takes the inline path: it has no CPU pixels to paint, and is the GPU compositor’s.

Overlays: which ancestor sizes them. A position: absolute overlay is sized and placed against its containing block, as in a browser and under rinch-web: the padding box of its nearest positioned (or transformed) ancestor, however many unpositioned elements sit between them (#386), or the window when it has none (#204). So position: absolute; inset: 0 inside position: relative chrome fills the chrome even when the element directly enclosing it has auto height, and dropped into unpositioned containers it fills the window.

Two things to know. An overlay meant to cover one panel needs that panel to be position: relative, or it covers the next positioned ancestor up. And a position: relative inline element (a <span>) is a containing block too (#631): an overlay inside one resolves against the span’s text, not the block that holds the line. On one line that is the text’s box. For a span that wraps, the block runs from the first line’s start of the text to the last line’s end of it, so inset: 0 is usually zero wide there (the last line ends left of where the first began), as in a browser. Make the wrapper display: inline-block (or a block) when the overlay should cover a box.

Resize and Scale Factor

#![allow(unused)]
fn main() {
ctx.resize(new_width, new_height);
overlay.resize(&device, new_width, new_height);
ctx.set_scale_factor(window.scale_factor());
}

set_scale_factor also feeds Stylo’s device_pixel_ratio (issue #211): CSS resolution media queries (@media (min-resolution: 2dppx)), image-set() candidate selection, and border-width device-pixel snapping all follow the value you pass. A changed scale restyles the document on the next update(), which also re-lays it out at the new logical size. The initial value comes from RinchContextConfig::scale_factor and is live from the first layout.

Hit testing is transform-aware, so the usual position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) centering idiom is fully clickable — a transformed subtree is hit where it is painted.

Caveat at HiDPI on the embed path. The embed path resolves layout at logical size, so set_scale_factor genuinely matters here — and paint currently applies a CSS translate without multiplying it by that scale (#202). At any scale other than 1.0, a translated element therefore paints slightly off from where it hit-tests. Only translate is affected (rotate/scale/skew are unit-invariant), and only on the embed path. Centering with flexbox instead of a translate sidesteps it entirely until that fix lands.

Platform Events

Translate your engine’s events to rinch_platform::PlatformEvent:

#![allow(unused)]
fn main() {
use rinch_platform::{PlatformEvent, MouseButton, KeyCode, KeyRepeat, Modifiers};

PlatformEvent::MouseMove { x: 100.0, y: 200.0 }
PlatformEvent::MouseDown { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseUp { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseWheel { x: 100.0, y: 200.0, delta_x: 0.0, delta_y: -30.0 }
PlatformEvent::KeyDown {
    key: KeyCode::KeyA,
    // The layout-produced `KeyboardEvent.key` value, case-accurate ("A" under
    // Shift). `None` = unknown; the app then falls back to the physical `key`.
    logical_key: Some("a".into()),
    text: Some("a".into()),
    modifiers: Modifiers::default(),
    // Is this a fresh physical press, or the OS repeating a key still held
    // (issue #463)? Say `KeyRepeat::Repeat` for a re-sent held key, and
    // `KeyRepeat::Unknown` if your engine cannot tell — the runtime then
    // infers it from the press/release pair, as it always did. Answering
    // where you can is what keeps a swallowed release from stranding the
    // Enter/Space activation of a focused node.
    repeat: KeyRepeat::Fresh,
}
PlatformEvent::KeyUp {
    key: KeyCode::KeyA,
    // Spell it like the press, or a consumer pairing press with release by
    // key string ("is A still held") never matches.
    logical_key: Some("a".into()),
    modifiers: Modifiers::default(),
}
PlatformEvent::Resized { width: 1920, height: 1080 }
}

Pointer coordinates are logical (CSS) pixels, not physical ones (issue #299). MouseMove/MouseDown/MouseUp/MouseWheel, MouseWheel’s delta_x/delta_y, and the File* positions are all in the space the document is laid out in and hit_test probes. A windowing system that reports physical pixels — winit does — must divide first, and rinch_platform::to_logical_point(point, scale) is the shared conversion:

#![allow(unused)]
fn main() {
let (lx, ly) = rinch_platform::to_logical_point((position.x, position.y), window.scale_factor());
events.push(PlatformEvent::MouseMove { x: lx as f32, y: ly as f32 });
}

Forwarding the physical position instead displaces every click, hover, drag and scroll by the scale factor times its distance from the window origin — it is invisible at scale 1.0 and wrong everywhere else. Note this is the opposite of resize(w, h) and RinchContextConfig’s width/height, which stay physical; scale_factor is how rinch reconciles the two. wants_mouse(x, y) takes the same logical pair, so ask it with exactly what you put on the event. examples/game-embed does this.

API Reference: Embed

RinchContext:

MethodDescription
new(config, component)Create and mount a rinch UI
update(&events) -> Vec<AppAction>Process events, update layout, return actions
scene() -> &SceneGet the Vello scene (lazy rebuild)
resize(w, h)Notify of window resize (physical pixels)
set_scale_factor(scale)Update DPI scale factor
set_theme(&props)Replace this context’s theme (restyles on the next update())
viewport_rect(name) -> Option<LayoutRect>Query a GameViewport’s computed rect
wants_mouse(x, y) -> boolTrue if point hits UI (not viewport hole)
wants_keyboard() -> boolTrue for a text input, the editor, an open <select> popup, a generic node that registered on_key, or a keyboard-focused (Tab/NodeHandle::focus()) generic node — not a plain button/link a mouse click merely focused (issue #548; the click-vs-Tab split follows :focus-visible)
needs_update() -> boolTrue if UI needs repaint (including a due next_wake or a new RenderSurface frame)
next_wake() -> Option<Instant>When to call update() again with no input (the caret blink), or None
register_font(data)Register font data for text rendering
app() / app_mut()Access the underlying RinchApp

RinchOverlayRenderer:

MethodDescription
new(device, w, h, format)Create from game’s wgpu device
render(device, queue, scene) -> TextureViewRender scene to texture
resize(device, w, h)Resize render target
texture()Get the underlying wgpu Texture

RinchContextConfig:

FieldTypeDescription
widthu32Initial viewport width (physical pixels)
heightu32Initial viewport height (physical pixels)
scale_factorf64Display scale factor
themeOption<ThemeProviderProps>Theme configuration

Theming is per-context. Each RinchContext owns the theme CSS generated from its config.theme (or the default theme when None) — creating a second context, or a shell app changing the thread-global theme, never restyles an existing context (issue #138). To change an embedded context’s theme at runtime, call ctx.set_theme(&props); the document restyles on its next update().

Caveat: an embedded ThemeProvider component with a reactive dark_mode_fn/primary_color_fn writes the thread-global theme slot, which embed contexts deliberately ignore — RinchContext::set_theme is the supported path for runtime theme changes in the embed API.

LayoutRect:

FieldTypeDescription
xf32Absolute X position (logical pixels)
yf32Absolute Y position (logical pixels)
widthf32Width (logical pixels)
heightf32Height (logical pixels)

Which Pattern to Use?

ScenarioUse
Adding UI overlay to an existing game engineEmbed API — game keeps its window/GPU ownership
Building a tool with embedded viewports (e.g., level editor, paint app)RenderSurface — rinch handles the window, you embed content
Terminal emulator, video player, or custom canvas inside a rinch appRenderSurface — component-level integration
WASM game with HTML-based UINeither — use the browser-native DOM backend

Examples

  • examples/game-embed/ — Spinning cube with rinch HUD overlay (embed API)
  • examples/render-surface-demo/ — Painting app with canvas and navigator (RenderSurface)
  • examples/webgpu-surface-web/ — (web) WebGPU triangle in a rinch DOM UI — the app owns a wgpu <canvas> viewport (RenderSurface on rinch-web)