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:
rect,roundedRect,circle,line, andpolylinepolygon,ring, andpieSlicetextandimagepushClip/popClip- explicit backdrop/content blur scopes
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.
ViewInvalidation.markNeedsRedraw()— andmarkNeedsLayout(),markNeedsBody()— say a frame is needed. Ordinary state changes do this for you; aCanvasthat draws from something observation cannot see does not, and has to say so itself.FrameScheduler.requestWake(in:)says when to look again. It unparks the loop and nothing more.FrameScheduler.requestRedraw(in:)does both: it unparks the loop at that deadline and marks the frame due when the deadline arrives.
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:
PerfCountersexposes frame/work counters used by LavaBench.WidgetProfilerrecords per-widget emission time..agentId(_:),AgentServer, andAgentHostexpose the UI to the Lava agent protocol; see agent integration.
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.
FileDialogcurrently depends onzenity.- A plain
forloop is unavailable in@ViewBuilder; identity requiresForEach. Canvasdrawing 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.