A declarative UI framework in Swift, rendering through Vulkan.
The current application-facing API is documented in docs/api.md.
Views are described the way SwiftUI describes them — a body returning nested
value types — but the whole stack underneath is here: layout via Yoga, text via
HarfBuzz and FreeType, and a single Vulkan pipeline that draws everything. No
GTK, no Qt, no ImGui in the widget path.
struct Counter: View {
@State private var count = 0
var body: some View {
VStack(padding: 8) {
Text("count: \(count)", color: .accent)
Text("[ increment ]", onClick: { count += 1 })
}
}
}| Target | Contains | Depends on |
|---|---|---|
LavaText |
Editing logic: cursors, selection, undo, word/line navigation, soft wrap, syntax rules, search | nothing |
LavaMenu |
Application menu IR + declarative DSL (MenuBar / MenuItem); no drawing |
nothing |
LavaUI |
Views, Yoga layout, draw list, fonts, input, theming | LavaText, LavaMenu, CxxCanvas, CYoga |
HelloWorld |
Demo app (DemoExample) and an FBD diagram editor |
LavaUI, FBDModel |
Spotify / SpotifyApp |
LavaSpotify UI + Connect control of spotifyd | LavaUI, SpotifyCore |
SpotifyCore |
Spotify Web API, OAuth, cover download (no Vulkan) | nothing |
canvas/ (package) |
C++ engine (CxxCanvas) + Yoga (CYoga), built by SwiftPM |
system Vulkan/GLFW/FreeType/HarfBuzz |
LavaText and LavaMenu having no dependencies at all is deliberate:
editing logic and menu IR are where fiddly correctness lives, and keeping them
out of reach of Vulkan and C++ interop means they are tested headlessly. That
is enforced by the build graph rather than by discipline.
swift build # Swift + C++ canvas engine (SwiftPM compiles both)
swift run HelloWorld # demo
swift run Spotify # LavaSpotify (see docs/lavaspotify.md)
swift test # headless tests, no GPU neededPerformance is checked separately, against a committed baseline — a correctness suite cannot catch a frame that got 40x slower while still drawing the right thing:
swift build -c release && ./.build/release/LavaBench # exit 1 on a regressionSee docs/performance.md for what it measures, why it gates on work counts rather than milliseconds, and how to add a scenario.
The Vulkan engine lives under canvas/ and is a normal SwiftPM C++ target
(CxxCanvas). Resources are split by owner and packed by SwiftPM:
| Asset | Target | Location |
|---|---|---|
| SPIR-V shaders | CanvasResources |
canvas/Sources/CanvasResources/shaders/ |
| Default fonts | LavaUI |
Sources/LavaUI/Resources/fonts/ |
| Demo images | HelloWorld |
Sources/HelloWorld/Resources/ |
System packages: Vulkan, GLFW, FreeType, HarfBuzz (and on Linux for global
menus: GLib + libdbusmenu-glib). No Meson/Ninja — SwiftPM builds the C++
engine. SPIR-V is checked in; after editing GLSL run
canvas/scripts/compile_shaders.sh (needs glslc).
LavaSpotify + spotifyd (PulseAudio, two logins, Connect playback): docs/lavaspotify.md.
Linux only today. CxxCanvas/CYoga are gated on it, and the engine is
GLFW + Vulkan.
LavaUI is a normal SwiftPM product of this repo. SwiftPM also builds the nested
canvas package (C++ engine + shader resources) from the same checkout — you
only declare a dependency on this repository.
# Arch
sudo pacman -S vulkan-icd-loader vulkan-headers glfw freetype2 harfbuzz \
libdbusmenu-glib
# Debian / Ubuntu
sudo apt install libvulkan-dev libglfw3-dev libfreetype-dev libharfbuzz-dev \
libdbusmenu-glib-dev libglib2.0-devYou also need a working Vulkan ICD (e.g. vulkan-radeon, nvidia-utils,
vulkan-intel) and a Swift 6 toolchain.
mkdir MyApp && cd MyApp
swift package init --type executableEdit Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.macOS(.v13)], // ignored on Linux; keeps the manifest valid
products: [
.executable(name: "MyApp", targets: ["MyApp"]),
],
dependencies: [
// Prefer a tag once you pin releases:
// .package(url: "https://github.com/nikitapn/LavaUI.git", from: "0.1.0"),
.package(url: "https://github.com/nikitapn/LavaUI.git", branch: "main"),
],
targets: [
.executableTarget(
name: "MyApp",
dependencies: [
// Package identity = last path component of the URL ("LavaUI").
.product(name: "LavaUI", package: "LavaUI"),
],
swiftSettings: [
// Required: LavaUI talks to the C++ engine via C++ interop.
.interoperabilityMode(.Cxx),
]
),
],
// Match the engine (std::expected / C++23).
cxxLanguageStandard: .gnucxx2b
)Local clone instead of GitHub:
.package(path: "../LavaUI"), // folder name becomes the package id
// then: .product(name: "LavaUI", package: "LavaUI")Replace the generated source with something like
Sources/MyApp/MyApp.swift:
import Foundation
import LavaUI
#if canImport(CxxCanvas)
@main
struct MyApp {
static func main() {
guard let editor = LavaApp.open(title: "My App") else {
exit(1)
}
LavaApp.run(editor: editor) {
VStack(padding: 12) {
Text("hello from LavaUI", color: .accent)
}
}
}
}
#else
@main
struct MyApp {
static func main() {
FileHandle.standardError.write(
Data("MyApp: LavaUI requires Linux + CxxCanvas (Vulkan).\n".utf8)
)
exit(1)
}
}
#endifswift build
swift run MyAppSwiftPM will fetch this repo (including canvas/), compile Yoga + the Vulkan
engine, pack checked-in SPIR-V and default fonts, and link system libraries via
pkg-config. You do not need Meson, Ninja, or a prebuilt libcanvas.
Framework fonts and engine shaders ship with LavaUI / canvas. Your images belong on your executable:
// Package.swift — on the MyApp target:
resources: [
.process("Resources"),
],
// Load at runtime:
let icon = ImageStore.loadAsset(
named: "icon.png",
bundle: .module,
into: editor
)Put files under Sources/MyApp/Resources/.
The same package also exports headless libraries if you need them without a window:
| Product | Use |
|---|---|
LavaUI |
Full UI (what almost every app wants) |
LavaText |
Text editing logic only |
LavaMenu |
Menu IR / DSL only |
.product(name: "LavaText", package: "LavaUI"),The demo prints one line per rendered frame on stdout — idle frames print nothing, because idle frames are not rendered:
frame redraw body= 0.00 layout= 0.00 emit= 0.55 present= 0.73 total= 1.31 ms
frame body body= 1.41 layout= 5.88 emit= 0.65 present= 0.85 total= 8.83 ms
The first word is how much of body → layout → emit ran. Seeing body where
a drag or an animation should be redraw means something over-invalidated,
which is a lag bug before it is a throughput one. LAVAUI_DEBUG=1 enables it.
The view tree is retained; the draw list is immediate.
A View is a struct rebuilt whenever something changes. Behind it sits a
persistent node tree that owns identity, @State storage, Yoga nodes, cached
text measurements, and observation subscriptions. Rebuilding a view does not
rebuild that tree — it reconciles against it.
Identity is structural: the tree's shape is encoded in its types, so
TupleView<Text, Button> reconciles positionally with no keys and no diffing.
Only EitherView (an if) and ForEach (keyed) need real reconciliation.
Each frame that something changed:
body recompute (only nodes whose observed state changed)
→ Yoga layout (only dirty subtrees)
→ draw list emission (a flat POD buffer)
→ one Vulkan pipeline, in index order
The loop is frame-driven, not event-driven. A state change sets a dirty
flag; nothing walks the graph synchronously. The loop blocks in
glfwWaitEvents until input arrives, so an idle window costs nothing.
Everything draws through one ordered batch stream. Rectangles, rounded
rectangles, circles, stroked segments and glyphs share the quad pipeline:
shapes use a rounded-box signed distance field and glyphs sample an R8 atlas.
Large connected polylines switch to a dedicated LINE_STRIP pipeline and then
switch back without leaving the stream. Paint order remains emission order — a
caret can cover its own glyphs and a popup can cover a chart. Batches break on
scissor, texture, or pipeline changes.
Swift owns everything above the pixels. Layout, hit testing, text shaping, and input routing are Swift. C++ receives a draw command buffer and knows nothing about widgets. The rule for what stays in C++: retain what is expensive to build and keyed by content (the glyph atlas, Vulkan objects); re-emit everything keyed by position or structure.
Containers HStack VStack Spacer Divider ForEach ScrollView —
flexbox via Yoga, with if/else and optionals handled by the view builder.
Divider() takes its orientation from the container it lands in.
Content Text (hover, click, wrapping) · MarkdownView (headings, emphasis,
strong text, code, links, quotes, lists, and fenced code) · Image · DiagramHost
· Button (animated press and hover) · Toggle (animated knob, bound value)
· Slider (drag, optional step and readout)
· Canvas (app-owned paint: Yoga sizes a box, you emit into DrawList)
Input TextField (single and multi-line, soft wrap, selection, clipboard,
undo) · EditorView (line-number gutter, syntax rules, current-line highlight,
find, vertical and horizontal scrolling)
Modifiers .padding() .background() .cornerRadius() .frame()
.flexGrow() .blur() .backdropBlur() .agentId("…") — chains collapse
onto the content's own node, so styling costs no extra layout boxes unless the
content is a fragment. .agentId stamps a stable automation id for the agent
control plane (see docs/agent.md).
Overlays .overlay(isPresented:) { … } anchors content above everything —
menus, dropdowns, tooltips. Collected during the tree walk and emitted after
it, so a popup paints over later siblings and escapes any ancestor's scissor
rect. Input runs the other way round: overlays are hit-tested first, a click
inside never falls through, and a click outside dismisses instead of
activating what it landed on. Placement flips to the other side of the anchor
when there is no room.
Blur comes in two kinds, because "blur this view" and "frost what is behind
this view" are opposite operations that happen to share a Gaussian.
.blur(radius:) softens the view and its children, the way SwiftUI's does;
.backdropBlur(radius:) leaves the view sharp and frosts the window under it,
which is what glass is made of.
Both emit a barrier into the draw list rather than a shape, and the engine interrupts the frame there. Backdrop blur ends the main pass, reads the resolved frame, and composites the result under the view's own fill. Content blur instead draws the subtree — only the subtree — into an offscreen target cleared to transparent, blurs that, and composites it back with its own alpha, so a blurred view has a genuinely soft edge and whatever sits behind it shows through untouched. That path is why the whole pipeline emits premultiplied alpha: a Gaussian over straight alpha averages the colour of fully transparent texels into every edge, which reads as a dark halo around everything blurred.
Width comes from the downscale, never from wider tap spacing — the kernel is nine fixed taps, so stretching them over forty pixels does not blur, it stamps nine offset copies. The downscale tracks the radius, holding it at about two texels: too little and the taps have to reach too far, too much and the bilinear upsample shows its own grid. Radii do not share a grid either — one allocation is sized for the finest radius in the frame and each blur takes the sub-region it needs, so a two-pixel softening and a ten-pixel frost in the same frame are both right instead of both being dragged onto the coarser one.
Scopes do not nest, in either kind: an inner one would blur what the outer one just composited. On an overlay the backdrop scope is hoisted above the panel's chrome, so the glass frosts the window and not its own outline.
Animation Animated<T> interpolates on the node, so a press or hover
costs a draw-list re-emit and no body recompute. FrameScheduler holds the
earliest wake any component asked for; InvalidationLevel decides how much of
body → layout → emit actually has to run.
Optional localhost TCP API for automation: Yoga layout dump, region screenshots,
and synthetic pointer/keyboard input. Handlers run on the UI thread; a socket
watcher wakes glfwWaitEvents so requests do not wait on a mouse tick.
LAVA_AGENT_PORT=9876 swift run HelloWorld
python3 tools/lava_agent_cli.py find --query theme-toggle
python3 tools/lava_agent_cli.py click --sid theme-toggle
python3 tools/lava_agent_cli.py screenshot_node --sid theme-toggle -o t.pngStable targets use .agentId("kebab-name") (exported as sid); untagged nodes
get a structural path.
Wire to Grok Build / Claude Code: start the demo with LAVA_AGENT_PORT=9876,
then use the project MCP configs (.grok/config.toml, .mcp.json). Details:
docs/agent.md.
Transitions .transition(.slide(dy: -12)) animates a view appearing and
disappearing, wherever a reconciler can insert or remove one — an if, an
optional, a ForEach row. Leaving is the hard direction: the view describing
the node is already gone, so the node outlives its own removal in the
fragment's departingChildren, staying in the layout and the draw walk while
it fades, and inert to input the whole time.
System @State + @Binding with Observation · Theme (semantic tokens,
light and dark) · focus, hover, pointer capture, click counting · content
scaling
Text is grapheme-correct throughout. Cursors are String.Index, never
integers, so an arrow key steps over an emoji ZWJ sequence as one unit. Caret
positions map through HarfBuzz clusters, so ligatures and combining marks
behave. Shaping happens once, in Swift, and feeds both layout and drawing —
what is measured cannot drift from what is drawn.
Honest list, roughly in the order it hurts:
- Animated layout — a transition fades and offsets, but the space a removed view held collapses in one step at the end rather than shrinking with it. Smooth collapse needs the departing node's Yoga size animated and its content clipped while it shrinks.
- Stroked shapes — the SDF pipeline fills, it does not stroke, so an outline is faked with a filled plate behind an opaque panel. That fake has no answer for a translucent one: a frosted overlay currently gets no border at all, because the plate would show through the glass as a flat wash. One stroke width on the quad vertex would fix it.
- Environment —
Theme.currentandFontStore.defaultare globals. They should be environment defaults, not the only way to set a value. - Per-node invalidation — a change re-runs the whole tree. Correct, but coarser than it needs to be.
- Multi-window —
LayoutHost,FocusManagerandViewInvalidationall assume one window. - IME and BiDi — Latin-only, deliberately. Fine for a PLC editor; it should be a stated scope rather than a surprise.
- Block comments — syntax highlighting is line-at-a-time, so constructs spanning lines cannot be expressed. That is where a rule list needs to become a stateful lexer.
docs/declarative-ui-plan.md is the working plan and carries the reasoning
behind most of the decisions above, including several that were reversed and
why.