LavaUI docs

LavaUI API guide

This document describes the application-facing LavaUI API as it exists today. LavaUI uses SwiftUI-shaped value descriptions, Yoga layout, retained view nodes, and a Vulkan renderer. It is currently a Linux framework; some declarations are compiled only when CxxCanvas is available.

import LavaUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack(padding: 12, alignment: .center) {
            Text("Count: \(count)", color: .accent)
            Button("Increment") { count += 1 }
        }
        .frame(width: .pct(100), height: .pct(100))
    }
}

Application lifecycle

Open the native window once, load any application-owned resources, then enter the event/render loop:

guard let editor = LavaApp.open(
    title: "My App", width: 1280, height: 800
) else {
    exit(1)
}

let logo = ImageStore.loadAsset(
    named: "logo.png", bundle: .module, into: editor
)

LavaApp.run(editor: editor) {
    RootView(logo: logo)
}

LavaApp.open(title:assetsRoot:width:height:) creates the engine window, loads LavaUI's fonts, and installs the clipboard bridge. assetsRoot normally does not need to be supplied. LavaApp.run(editor:menu:onRawKey:makeRoot:) runs until the window closes. onRawKey sees key events before normal focus, overlay, and content-scale handling; return true to consume an event.

The optional menu closure builds a LavaMenu.MenuBar. See native menus for its DSL and Linux backends.

Monorepo apps that support both modes use LavaHost.open: it selects LavaClient.open when LAVA_CLIENT=1 and LavaApp.open otherwise. This keeps the conditional NPRPC dependency and LAVA_FRAME handling out of app entry points. The matching LavaHost.run selects the correct frame loop with the same menu, onRawKey, and root-builder arguments.

Use LavaHost.setMinimumSize(editor:width:height:) for a layout's resize floor. It applies glfwSetWindowSizeLimits to a local window or sends the deferred SetMinSize request to the compositor in client mode. Zero means no constraint on that axis.

Running under the compositor

LavaClient.open(title:width:height:) opens the framework with no local window, Vulkan device or GPU. LavaClient.run connects the editor to the C++ compositor's resources, shared draw arena and input stream:

guard let editor = LavaClient.open(title: "My App") else { exit(1) }
LavaClient.run(editor: editor) { RootView() }

The view tree, layout, invalidation and emit are all unaware of the difference. What changes is only what needs a screen:

Windowed Client
Size measured from the surface told, via Editor.setClientSize
Input GLFW callbacks injected (injectPointerMove, …)
renderFrame draws and presents succeeds, draws nowhere
capturePngBase64 a PNG nil
Font and texture ids the local device the resource host (below)
Retained scroll/hover the renderer answers nothing answers yet

Text still shapes normally: shaping is FreeType and HarfBuzz and never needed the device — only rasterizing into the glyph atlas does, and that belongs to whoever draws.

Idle costs nothing: with no GLFW to park in, pumpEvents blocks on a condition variable with the same contract (negative blocks, 0 polls, positive waits at most that long), so a client waiting for work uses no CPU.

A client owns no retained scene state, so a wheel notch reaches its own ScrollRouter handlers and moves nothing else — the scroll offsets live wherever the frame is drawn.

Who draws the window frame

Under the compositor an app chooses, once, at open:

// The compositor draws a title bar above the content (default).
LavaClient.open(title: "My App")

// No strip at all: the window is this app's content and nothing else.
LavaClient.open(title: "My App", frame: .client)

.server is the strip with the title and the three buttons, and it costs the app 32 points it never sees. .client gives them back — worth having whenever the app already draws a toolbar, and the only sensible choice for a pop-up or an overlay that should have no chrome at all.

With .client the app places the controls itself. They are drawn by LavaUI and performed by the compositor, so they behave the same in every app that uses them:

HStack(height: .pt(48), padding: 8) {
    if WindowBridge.drawsOwnChrome { WindowControls() }
    Text("My App")
    Spacer()
    Button("Settings") { … }
}
.windowDrag()
Piece What it is
WindowControls([.close, .minimize, .maximize]) the cluster, in the order given — list only what the window supports
WindowControlButton(.close) one control, for chrome that places them apart
WindowControlsStyle size, spacing, colours; .monochrome() for chrome that should not shout
.windowDrag() this view's empty space moves the window; double-click maximizes
WindowBridge.drawsOwnChrome whether this window has no frame but its own

.windowDrag() is the piece that is easy to forget: without it a frameless window can be closed and maximized but never moved. Anything interactive inside it still takes its own clicks — the hit test reaches children first — so a toolbar can be both a drag handle and a toolbar.

A press is all the drag takes. From the moment the compositor hears about it the pointer is the compositor's: the motion never reaches this process, and the drag ends somewhere the app is never told about. That is why nothing here tracks a drag, and why the app is sent a synthetic mouse-up as the grab starts.

In a windowed (GLFW) app drawsOwnChrome is false and the controls do nothing: the window manager's own frame is still there and already has all three. An app that runs both ways should gate its cluster on the flag rather than drawing buttons that lie.

The desktop's own corner radius is on WindowBridge.desktopCornerRadius, read from the compositor when the client connected. A window does not need it to be rounded — the compositor masks its corners whatever the client draws — but a menu, dialog or card the app draws inside its own window should match it rather than pick a number:

card.cornerRadius(WindowBridge.desktopCornerRadius)

0 means square, and it is 0 in a windowed app, where the radius belongs to whatever window manager drew the frame.

WindowBridge.desktopShadow carries the rest of the same story — blur, opacity and offsetY for the shadow the compositor casts under the focused window. An app giving its own dialog or menu a shadow should use these rather than pick numbers, or the two disagree in the same frame; blur == 0 means the desktop casts none and neither should the app.

Minimize hides the window without ending it. Getting it back is the compositor's business: a taskbar walks ListWindows and calls ActivateWindow, or Alt+Shift+M brings back everything the workspace has hidden at once.

Who names GPU resources

A GlyphInstance carries a font id and an image command carries a texture id, and both only mean something to the process that owns the atlas they index. GPUResourceHost is that question, and it is deliberately the whole seam between the two modes:

public protocol GPUResourceHost: AnyObject, Sendable {
    func registerFont(path: String, pixelSize: Float) -> UInt32?
    func registerImage(path: String, maxPixelSize: UInt32) -> UIImage?
    func registerImageAsync(path: String, maxPixelSize: UInt32,
                            completion: @escaping @Sendable (UIImage?) -> Void)
    func releaseImage(key: String)
}

Editor.resources is the host in use, and it is the editor itself unless told otherwise — which is why an ordinary app never learns this protocol exists. A client under a shared renderer assigns the compositor once, before loading anything:

guard let editor = LavaClient.open(title: "My App") else { exit(1) }
editor.resources = CompositorResources(compositor)

FontStore and ImageStore are unchanged above that line: same caches, same VRAM budget, same LRU eviction. Only the ids differ, and only the host knows them.

Note what does not cross: pixels. A host is asked to register a file it can open itself, never handed a decoded bitmap — so the process that will hold the texture is the one that decodes it, and a client needs no image codec at all. The consequence is a real constraint rather than an oversight: an image the client has only in memory (downloaded, generated) has to reach a path the renderer can open before it can be registered.

The two hosts split the async work differently, which is why registerImageAsync is a protocol requirement and not a helper. Locally the decode belongs on a worker and the upload must return to the main thread — it touches the device. Remotely the whole call is one round trip that touches nothing local, and the protocol's default implementation covers it.

Where finished frames go

FrameSink is the other half: where a DrawList writes its frame, and who it hands the frame to. Those are one question rather than two, because the property worth keeping is that a draw command is written once, into the memory its consumer reads it from — a sink that only received a finished list would have to copy it, and a 1 MB copy is 2% of a frame for something that should cost nothing. So a sink hands out buffers first and is told what was filled in afterwards.

EngineFrameSink is the default: the engine's own per-window buffers. ArenaFrameSink publishes into a DrawArena another process renders from:

let sink = ArenaFrameSink(id: arenaID, onPublish: { compositor.present(surfaceID) })
editor.publishFrames(to: sink!)

onPublish exists because a shared-memory store wakes nobody — the arena's published sequence is the authority on what is current, but somebody still has to say "come and look". That call stays outside LavaUI, which does not know what a compositor is.

Install it before LavaApp.run. A DrawList takes its sink when it is built, so swapping one in later would leave the running window on the old one — which is the safe way round, since the alternative is changing the storage under a frame that is halfway emitted.

Putting it together

LavaClient.open and LavaClient.run own the client-side setup. Applications choose the host at their entry point and otherwise keep the same view tree:

let editor = LavaClient.open(
    title: "My App", width: 720, height: 560
)!
LavaClient.run(editor: editor) { ClientView() }

Input comes back the other way: the compositor's SubscribeInput stream feeds Editor.postInputEvent through MainQueue, which hops it to the loop's thread and wakes it out of pumpEvents on the way.

What that buys is worth stating plainly, because it is the reason for all of it: kill -STOP the client and its list still scrolls. The renderer owns the scroll offset against a scene node id, so moving a subtree it already has needs nothing from the process that published it. Resume the client and it picks up from where the renderer got to, via NodeScroll. How far a stopped client can scroll is bounded by what it drew — see the emitted span and overscan in the retained scene tree.

The agent server (LAVA_AGENT_PORT) works against a client for every verb that does not need pixels — layout_tree, find, hit_test, click, scroll, settle. That is not a testing affordance bolted on: injection is a client's whole input path, which is what lets one run and be driven with nothing on the other end yet. HelloWorld takes LAVA_CLIENT=1 for exactly this.

View descriptions

Every composite view conforms to View and returns another view description from body:

struct StatusPanel: View {
    let connected: Bool

    var body: some View {
        HStack(padding: 8, alignment: .center) {
            Text(connected ? "Connected" : "Offline")
            Spacer()
            if connected {
                Text("live", color: .accent)
            }
        }
    }
}

@ViewBuilder supports multiple children, if, if/else, and optional children. It deliberately does not support a plain for loop because index identity is unstable. Use ForEach with an explicit stable key:

ForEach(tracks, id: \.id) { track in
    Text(track.title)
}

The Identifiable overload omits id:. EmptyView represents no content. dumpStructure() prints a description of a view tree for diagnostics.

LavaUI reconciles descriptions into a retained node tree. A body is not a render callback: it is recomputed only when observed data used by that body changes. Layout and painting can then run without reconstructing the tree.

State and bindings

@State

Use @State for view-owned values that affect structure, layout, or ordinary view properties. Its storage survives reconstruction of the view struct.

@State private var expanded = false

Toggle("Details", isOn: $expanded)
if expanded { DetailsView() }

@DrawState

Use @DrawState for high-frequency, paint-only values captured by an already mounted Canvas paint closure, such as a hover coordinate or drag boundary. A write requests redraw only; it does not recompute body or layout.

@DrawState private var cursorX: Float?

Do not use it when the value changes text outside the paint closure, layout, or view structure; those require @State.

Binding

Binding<Value> is a two-way value reference. Obtain one with $state, build one from closures, or bind a property of a reference model:

let binding = Binding(
    get: { model.query },
    set: { model.query = $0 }
)

let shorter = Binding(model, \.query)

For an @Observable reference model, @Bindable supplies SwiftUI-style dynamic member projection:

@Bindable var session: Session
TextField(text: $session.query, placeholder: "Search")

Use a reference model for state shared with code outside the view tree, such as an application menu.

Layout

Stacks

HStack lays children out horizontally and VStack vertically:

HStack(
    flexGrow: 1,
    width: .pct(100),
    height: .auto,
    padding: 8,
    alignment: .center,
    spacing: 8,
    wraps: false,
    onClick: { select() },
    onHover: { inside in hovered = inside }
) {
    Text("Leading")
    Spacer()
    Text("Trailing")
}

StackAlignment controls the cross axis: .start, .center, .end, or .stretch. spacing is the distance between adjacent children; nil uses the theme's stackSpacing (8 by default), while 0 removes the gap. wraps: true continues children onto another line when the main axis fills.

Spacer(flexGrow:) consumes remaining space. It is the idiomatic way to push later content to the trailing or bottom edge.

Dimensions and frames

Dimension accepts .auto, .undefined, .point(Float) / .pt(Float), and .percent(Float) / .pct(Float). Percent values use percentages, so .pct(100) fills the parent and .pct(50) uses half of it.

content.frame(
    width: .pt(320), height: .auto,
    minWidth: 160, minHeight: 40
)

flexGrow(_:) claims available main-axis space. flexShrink(_:) controls how the view contracts when the container is too small.

Modifier order can affect layout. In particular, .frame(...).padding(8) creates outer padding around the explicit frame, while .padding(8).frame(...) applies the fixed frame to the padded box.

Resizable panes

VSplitView stacks two panes with a divider the user drags; HSplitView is the same thing side by side (leading: / trailing:, minLeading / minTrailing). Nest them for more than two panes.

VSplitView(fraction: $session.timelineSplit, minTop: 200, minBottom: 90) {
    chart
    legend
} bottom: {
    EditorView(text: $session.log).flexGrow(1)
}

The split is a fraction of the space the two panes share, so a window resize keeps the proportion rather than growing one pane and pinning the other. minTop / minBottom are where the drag stops; they are deliberately not Yoga minHeights, which would become each pane's base size and skew the fraction. Pass initialFraction: instead of fraction: to let the view own the value.

Each pane is a column, like a VStack. Its height is the divider's decision, so content that should fill a pane needs .flexGrow(1); content taller than its pane is clipped, not overflowed onto its neighbour.

Dragging writes flex factors straight onto the panes and re-runs layout — no body pass per pointer pixel — and writes the binding once, on release. A view that displays the fraction therefore updates when the drag ends.

Dividers carry a resize cursor, which is the built-in use of .cursor(_:).

Pointer image

grabBar.cursor(.resizeUpDown)

CursorShape is .arrow, .text, .pointer, .crosshair, .resizeLeftRight, .resizeUpDown — the shapes that exist both in GLFW's standard cursors and in every X11 cursor theme, so a windowed app and a client show the same thing. The innermost view under the pointer that states one wins; ancestors cover the gaps.

TextField and EditorView state .text for themselves; everything else is the app's call. The modifier is applied over a control's own setting, so TextField(...).cursor(.pointer) still wins.

Stating a cursor makes a view hit-testable (the renderer has to report the pointer entering it), which costs a scene node — the same cost .hoverBackground already pays, and now the cost of every text field.

Windowed apps set the cursor on their own GLFW window. Clients send SetCursor to the compositor, which stores it against the surface and applies it while the pointer is inside — the compositor's own affordances (resize band, title bar, an active drag) still win, and the preference is dropped the moment the pointer leaves.

Scrolling and lazy content

ScrollView(.vertical, showsIndicator: true) {
    LazyVStack(items, rowHeight: 36, spacing: 2) { item in
        Row(item: item)
    }
}

ScrollView supports .vertical and .horizontal. LazyVStack and LazyVGrid virtualize fixed-size cells and must be placed in a vertical ScrollView:

LazyVGrid(
    albums, cellWidth: 180, cellHeight: 250, spacing: 10,
    scrollTarget: selectedIndex
) {
    AlbumCard(album: $0)
}

The grid determines the column count from available width. Lazy cells are unmounted when they leave the viewport, so state that must survive scrolling belongs in the model rather than inside a cell.

Pass an optional item index as scrollTarget to LazyVGrid or LazyVStack when keyboard selection should remain visible. A changed target scrolls only as far as needed to reveal its cell; later wheel input remains under user control until the target changes again.

Common modifiers

Modifiers apply to any View:

Modifier Effect
.padding(Float) Uniform inner spacing on every edge
.padding(Edge, Float) Inset listed edges only — e.g. .padding(.horizontal, 8)
.padding(EdgeInsets) Fully specified per-edge inset
.background(Color) Box fill
.hoverBackground(Color) Fill while hovered
.cornerRadius(Float) Rounded box corners
.frame(width:height:minWidth:minHeight:) Yoga dimensions and minimums
.flexGrow(Float) Main-axis growth, default argument 1
.flexShrink(Float) Contraction priority
.clipped() Scissor this view and its descendants to its layout box
.blur(radius:) Blur this view's own rendered content
.backdropBlur(radius:) Blur content already painted behind the view
.theme(Theme) Override the theme for this subtree
.font(UIFont) Override the font for this subtree
.transition(Transition) Animate insertion/removal appearance
.onDrop { urls in ... } Accept dropped files in this view's bounds
.agentId(String) Give automation/agent tooling a stable identifier

Paint-only modifier chains normally collapse onto one node. LavaUI adds a wrapper only for fragments or when modifier order creates a real layout boundary.

Built-in views

Text and controls

Text(
    "Artist",
    color: .primary,
    hoverFill: nil,
    hoverColor: .accent,
    cornerRadius: 4,
    lineLimit: 2,
    onClick: openArtist
)

Button("Save", isEnabled: canSave, action: save)
TextField(
    text: $query,
    placeholder: "Search",
    multiline: false,
    maxLines: 8,
    wraps: false,
    onSubmit: search
)
Toggle("Live parsing", isOn: $live)
Slider(value: $volume, in: 0...1, step: 0.01)
ColorPicker(color: $fill)

Color is authored sRGB (Color(r: 0.5) is #800000). ColorPicker binds one and edits it as an HSV square plus a hue strip, writing the same components hex and CSS use. Color(hex:), .hex, .hsv, and Color(hue:saturation:value:) are the pieces it is made of.

Text.lineLimit(_:) wraps within its resolved width and ellipsizes the last visible line. Clickable Text receives the theme hover surface by default; specifying hoverColor gives link-like text hover without a row fill.

TextField supports single- and multiline editing, selection, clipboard, undo, search-related text infrastructure, and optional soft wrapping. ButtonStyle, ToggleStyle, and SliderStyle expose the colors, geometry, padding, and animation duration for their controls.

Divider() infers its axis from its parent stack; it can also be initialized with .horizontal or .vertical and a DividerStyle.

Expand(title:isExpanded:style:content:) provides a disclosure section with an animatable body.

ComboBox is a closed field plus a dropdown — a switcher for one of several things, where a row of tabs would not fit:

ComboBox(
    selection: Binding(get: { active }, set: { switchTo($0) }),
    items: documents.enumerated().map {
        ComboBoxItem($0.element.name, tag: $0.offset, detail: $0.element.directory)
    },
    placeholder: "nothing open",
    width: .pt(230),
    maxVisibleRows: 12
)

The tag is any Hashable — an index, an id, a string — and is what the binding carries. detail is dim trailing text for telling similarly named rows apart; keep it short, since the list sizes to its widest row. Picking the row that is already selected does not write the binding, so a setter with side effects is safe to use directly. The list is an anchored overlay, so it paints above everything, escapes any enclosing clip, and dismisses on an outside click or Escape. It is mouse-driven: there is no arrow-key navigation, because a composed view cannot hold keyboard focus.

Images

Load an application resource once when possible:

let image = ImageStore.loadAsset(
    named: "cover.png", bundle: .module, into: editor
)

if let image {
    Image(image, width: .pt(160), height: .pt(160), contentMode: .fit)
}

For asynchronous/path-based artwork, keep the same leaf mounted:

Image(
    path: coverPath,
    width: .pt(160), height: .pt(160),
    placeholder: Environment.current.theme.inset,
    placeholderCornerRadius: 6,
    contentMode: .fill
)
.clipped()

ImageContentMode is .stretch, .fit, or .fill. Path images decode on demand, are cached by ImageStore, and request redraw without rebuilding the body. Definite point dimensions allow decoding near the displayed resolution.

Editor and Markdown

EditorView is the full code/log editor:

@State private var source = ""
private let controller = EditorController()

EditorView(
    text: $source,
    rules: highlightRules,
    style: CodeStyle(),
    showLineNumbers: true,
    visibleLines: 16,
    search: TextSearch(),
    decorations: diagnostics,
    onDecorationTap: { diagnostic in inspect(diagnostic) },
    controller: controller
)

controller.reveal(line: 120) // one-based physical line; focuses and centers it

let spot = controller.position()   // scroll offsets + selection, or nil if unmounted
controller.restore(spot ?? .start) // applied on the editor's next reconcile

EditorPosition is character offsets and scroll offsets — not String.Index — because the point of saving one is to restore it into a buffer that has since changed: another document in the same editor, or the same log a day later. Offsets are clamped to whatever is there on arrival. restore(_:) is never immediate for the same reason: at the moment an app switches documents the editor still holds the outgoing text, so the position is queued and applied by the pass that installs the new text. It is Codable, so a document-per-tab app can write it into its own session file.

Highlighting rules and search types are re-exported from LavaText. EditorDecoration adds severity, underline style, optional gutter icon, color, and message to a character range.

MarkdownView(markdown, style: MarkdownStyle(), font: nil) renders styled Markdown text. It handles Markdown as character styling in the native text renderer rather than embedding a browser.

Overlays

LavaUI has two overlay forms with different interaction semantics.

Composed overlay

Use overlay(alignment:inset:content:) for an always-present badge, floating button, or control. It takes no layout space and does not block interaction or dismiss on outside click:

content.overlay(alignment: .bottomTrailing, inset: 16) {
    Button("Assistant") { showAssistant = true }
}

OverlayAnchor provides all nine combinations of top/center/bottom and leading/center/trailing.

Presented overlay

Use overlay(isPresented:...) for a popup, dropdown, menu, or modal surface. It is emitted above the complete tree, escapes scroll clipping, receives input first, and dismisses on an outside click:

searchField.overlay(
    isPresented: $showResults,
    alignment: .below,
    style: OverlayStyle(minWidth: 680)
) {
    SearchResults()
}

.below and .above automatically flip at the viewport edge. For a modal plane or another custom frame, supply an OverlayPlacement:

root.overlay(
    isPresented: $showAssistant,
    placement: .viewport(inset: 24),
    style: OverlayStyle(backdropBlurRadius: 8)
) {
    AssistantView()
}

For arbitrary placement, initialize OverlayPlacement with a closure receiving the anchor frame, viewport frame, and the overlay's ideal size, and return an OverlayFrame in window coordinates.

Custom drawing with Canvas

Canvas participates in Yoga layout but owns no child views. Its paint closure receives an absolute CanvasFrame and a reused DrawList:

Canvas(
    label: "timeline",
    height: .pt(240),
    flexGrow: 1,
    onGesture: handleGesture,
    onWheel: handleWheel
) { draw, frame in
    draw.roundedRect(
        x: frame.x, y: frame.y, w: frame.w, h: frame.h,
        color: .background, radius: 6
    )
    draw.polyline(points, color: .accent)
}

onGesture receives .began, .moved, and .ended with local and window coordinates. Pointer capture keeps delivering a drag after it leaves the canvas. onWheel includes the local pointer position. continuousRedraw: true requests animation frames while a live canvas needs them.

Application-facing DrawList primitives include:

Coordinates passed to DrawList are window coordinates. Prefer polyline for large connected series: it emits one line-strip command and a contiguous vertex range rather than one command per segment.

Spatial UI with Scene3D

Scene3D is a Yoga leaf whose contents use a separate depth-tested graphics pipeline. Normal LavaUI views before and after it retain their draw-list order, and each scene clears depth only inside its own viewport.

@State private var hovered: Int?
let catalogLayout = CatalogLayout3D.focusedShelf()

Scene3D(
    camera: .perspective(
        position: [0, 0, 7], target: [0, 0, 0],
        fieldOfView: .degrees(42)
    ),
    height: .pt(320),
    flexGrow: 1,
    cameraControls: .orbit(
        minimumDistance: catalogLayout.recommendedMinimumCameraDistance(
            itemCount: albums.count, itemWidth: 1.25, itemHeight: 1.25
        ),
        maximumDistance: 14
    )
) {
    AmbientLight3D(intensity: 0.28)
    DirectionalLight3D(direction: [-0.35, -0.6, -1], intensity: 1.05)
    ForEach3D(Array(albums.enumerated()), id: \.element.id) { index, album in
        Box3D(
            id: album.id, width: 1.25, height: 1.25, depth: 0.08,
            color: .accent
        )
        .material3D(.albumCover(front: album.cover, edgeColor: .dim))
        .shadow3D(radius: 15, offsetX: 7, offsetY: 11, opacity: 0.3)
        .reflection3D(
            planeY: -0.63, opacity: 0.28,
            fadeDistance: 1.4, blurRadius: 1.25
        )
        .catalog3D(
            index: index, itemCount: albums.count,
            focusedIndex: hovered, layout: catalogLayout
        )
        .animation3D(.spring(response: 0.3, dampingFraction: 0.7))
        .onHover3D { inside in hovered = inside ? index : nil }
        .onTap3D { open(album) }
    }
}
.cornerRadius(8)

The initial predefined geometry is Plane3D and Box3D. Material3D supports a color or a textured front surface; .albumCover(front:edgeColor:) puts a cover texture on the front of a thin box and gives its remaining faces a separate edge color. Atlas-backed UIImage UVs are handled automatically. AmbientLight3D and DirectionalLight3D illuminate transformed face normals; scenes without explicit lights receive a neutral default rig. Spatial modifiers include position, offset3D, uniform/vector scale3D, axis-angle rotation3D, animation3D, onHover3D, and onTap3D. Object identifiers must be stable: retained transform animation and hit dispatch are keyed by id.

Use .animation3D(.spring(response:dampingFraction:)) for responsive hover motion. A shorter response reacts faster; a damping fraction below 1 adds overshoot, 1 is critically damped, and values above 1 settle without bouncing. .snappingPosition() jumps world x and only eases lift, turn, and scale — the app switcher uses this so the focused card stays planted at the camera centre. .smooth(duration:curve:) remains available for time-based motion.

CatalogLayout3D.focusedShelf() provides a depth-aware album/poster layout. Apply it with .catalog3D(index:itemCount:focusedIndex:layout:); the focused item lifts and scales while its neighbors spread, recede, and fan toward it. recommendedMinimumCameraDistance(...) returns a conservative orbit radius from the shelf and item dimensions, keeping the camera outside the catalog.

BookshelfLayout3D.bookStacks() is the other catalog: two packed stacks of books around a face-on cover. Neighbours stand at a steep yaw so a sliver of the front stays readable; the focused card turns to the camera. Pass itemHeight so cards of different aspect ratios sit on the same shelf plane rather than sharing a centre. The app switcher uses this layout.

.shadow3D(...) projects the transformed card silhouette into a shared offscreen mask and blurs it. Radius and offset are expressed in screen pixels, so the shadow remains visually consistent while the cover moves in depth. Shadow3DStyle can also be passed when the same configuration is shared by many objects. This is a scene-local spatial UI effect, not a general mesh shadow map.

.reflection3D(...) mirrors the fully transformed object across a horizontal world-space plane. The reflection preserves textures, fades with distance from the plane, and can use a small shared blur for a polished glass-floor look. planeY should align with the lower edge of objects at rest. Reflections are a lightweight planar UI effect rather than a second scene render, so they do not reflect arbitrary surrounding geometry.

Camera3D.perspective accepts position, target, field of view, and near/far planes. Pointer picking tests the projected triangles and selects the nearest depth, matching visible overlap. Transform interpolation lives on the retained scene node, so animation frames request redraw without recomputing body.

Pass cameraControls: .orbit(...) to enable retained scene navigation. Drag to orbit, Shift-drag to pan, and use the wheel or trackpad to zoom. Distance and pitch limits, input sensitivity, inertia, and deceleration are configurable through CameraControls3D; omit it for a fixed camera. A drag is distinguished from a click on release, so moving the camera does not activate a 3D object. Scene3D always emits a scissor matching its layout box, so projected objects and blurred shadows cannot paint outside the scene viewport; .clipped() is not required for this.

Physically based material parameters and imported meshes remain future layers on the same scene command path.

Theme, fonts, and animation

Theme contains semantic text colors, accent/selection colors, surfaces, border and corner geometry, control padding, caret width, and focus-ring configuration. Built-ins are listed on Theme.builtIns (dark, light, nebula, ember, moss, paper, graphite); the process-wide default is Theme.current. The desktop pushes one of those names as the system theme; Theme.named(_:) is how a client wears it.

Prefer semantic colors such as .primary, .secondary, .accent, .selected, .muted, and .dim. Color.opacity(_:) replaces alpha and lightened(_:) derives a lighter variant.

Use .theme(customTheme) and .font(customFont) for scoped overrides. Fonts can be loaded with UIFont(path:pixelSize:); FontStore owns the default, symbol font, content scale, and shape caches.

Transition.opacity, Transition.slide(dx:dy:), and custom Transition values support fade/offset animation with .linear, .easeOut, or .easeInOut curves.

Animated<T> is the imperative half, for code that paints rather than composes — a Canvas. It holds a current and a target value, animate(to: duration:curve:) retargets from wherever the value is now, and step(_ now:) advances it and returns whether it is still moving.

Scheduling frames

A frame reaches the screen only when two separate things are true: the loop is awake, and the window is dirty. It parks in pumpEvents when nothing is happening, and present emits nothing while the window is clean.

Use requestWake when something else will dirty the window at that moment — an AnimationDriver tick, a caret blink, a poll that will usually find nothing. Use requestRedraw when the deadline is the event: a hover that opens something after a delay, a fade with no view node behind it, anything a Canvas decides at paint time. Choosing wrong is invisible while the pointer keeps moving, because input dirties the window anyway, and shows up the moment the user holds still — which for a hover delay is every time.

Both are earliest-wins and single-shot: two callers asking for 500ms and 16ms get one wake at 16ms, and an animation that wants another frame asks again every frame. Canvas(continuousRedraw: true) is the standing version, for a canvas that needs a frame for as long as it is on screen.

Files, settings, and diagnostics

FileDialog.openFile, openFiles, and saveFile provide native-style file selection. The current backend is Linux zenity; calls block until selection or cancellation and return no result when unavailable or cancelled.

AppSettings.configure(appName:) selects the application settings file. string, int, bool, double, and generic Codable getters/setters are available, along with remove, removeAll, and keys.

For profiling and automation:

These are lower-level facilities; ordinary application views do not need them.

Current API boundaries

The API intentionally resembles SwiftUI, but it is not source-compatible with SwiftUI. Notable current boundaries are:

  • Linux is the working platform today.
  • Stack main-axis justification is not yet present; use Spacer.
  • Lazy containers require fixed cell/row heights.
  • FileDialog currently depends on zenity.
  • A plain for loop is unavailable in @ViewBuilder; identity requires ForEach.
  • Canvas drawing uses absolute window coordinates.

The full SwiftUI-shaped gap list, ordered from easy wins (opacity, lifecycle hooks, …) through platform work, is in swiftui-parity.md. Known bugs are in issues.md; product-specific gaps live in their own documents.

docs/api.md