Skip to content

Repository files navigation

LavaUI

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 })
        }
    }
}

Layout of the repo

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.

Building

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 needed

Performance 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 regression

See 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.

Using LavaUI in a new project

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.

1. System packages (Linux)

# 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-dev

You also need a working Vulkan ICD (e.g. vulkan-radeon, nvidia-utils, vulkan-intel) and a Swift 6 toolchain.

2. Scaffold an executable package

mkdir MyApp && cd MyApp
swift package init --type executable

3. Depend on LavaUI from GitHub

Edit 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")

4. Minimal main

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)
    }
}

#endif

5. Build and run

swift build
swift run MyApp

SwiftPM 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.

App-owned assets

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/.

Optional products

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.

How it works

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.

What exists

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.

Agent control plane

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.png

Stable 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.

What is missing

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.
  • EnvironmentTheme.current and FontStore.default are 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-windowLayoutHost, FocusManager and ViewInvalidation all 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.

Notes

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.

About

A declarative UI framework in Swift, rendering through Vulkan

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages