Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Backend Architecture

In this chapter, you will:

  • See how each backend (Apple, Android, GTK4, Hydrolysis) maps Rust views to platform widgets
  • Understand the shared contract that all backends implement
  • Learn the architecture patterns for adding a new backend
  • Review the feature matrix across platforms

WaterUI supports multiple rendering backends, each targeting a different platform. All backends share the same FFI contract but differ in how they create and manage platform widgets. Think of each backend as a translator: it reads the same Rust view tree but speaks a different platform language.

The Backend Contract

Every backend must:

  1. Call waterui_init() to initialize the Rust runtime.
  2. Install theme signals (color scheme, colors, fonts) into the environment.
  3. Call waterui_app(env) to obtain the application’s window tree.
  4. Walk the view tree, dispatching each node by its WuiTypeId.
  5. Subscribe to reactive signals and update widgets when values change.
  6. Handle the application lifecycle (window management, event loop).

The waterui-backend-core crate provides shared infrastructure, including ViewDispatcher – a type-based dispatch table that routes AnyView instances to backend-specific handlers.

Note: You do not need to understand backend internals to use WaterUI. This chapter is for contributors, backend authors, and the deeply curious.

Apple Backend (Swift)

Location: backends/apple/ (git submodule)

The Apple backend is a Swift Package that integrates with UIKit (iOS/tvOS), AppKit (macOS), and WatchKit (watchOS). It is the most mature backend and serves as the reference implementation.

Architecture

Rust Library (.dylib / .a)
     |
     C ABI (waterui.h)
     |
Swift Package (WaterUIRuntime)
     |
     UIKit / AppKit Widgets

The Swift side maintains a ViewWatcher that walks the Rust view tree and creates corresponding UIKit/AppKit views:

Rust ViewiOS WidgetmacOS Widget
TextUILabelNSTextField
ButtonUIButtonNSButton
ToggleUISwitchNSSwitch
TextFieldUITextFieldNSTextField
ScrollViewUIScrollViewNSScrollView
NavigationStackUINavigationControllerNSNavigationController (custom)
GpuSurfaceMTKViewMTKView

Reactive Integration

The Swift backend subscribes to nami signals through FFI watchers. When a Computed<T> value changes, the Rust side invokes a C callback that the Swift side registered:

nami::Binding<String> changes
  --> Computed<Str> fires
    --> C callback invoked
      --> Swift closure updates UILabel.text

This happens on the main thread, ensuring UI updates are safe.

Theme Injection

The Swift backend reads system appearance and typography, creating reactive signals:

// Pseudocode
let colorSchemeSignal = waterui_computed_color_scheme_new { watcher in
    // Track UITraitCollection.userInterfaceStyle
    // Call waterui_call_watcher_color_scheme(watcher, .dark) on change
}
waterui_theme_install_color_scheme(env, colorSchemeSignal)

Each semantic color (foreground, background, accent, etc.) is mapped to the platform’s dynamic color system, so views automatically adapt to light/dark mode.

Build Integration

The Apple backend is a git submodule under backends/apple/. You drive it through the water CLI – never invoke xcodebuild or swift build yourself. water run --platform ios cross-compiles the Rust staticlib for the target triple, hands the path to the Swift package, performs code signing, and deploys to the chosen simulator or device. If you find a build step that the CLI cannot yet express, file an issue against cli/ rather than working around it from your own scripts.

Android Backend (Kotlin/JNI)

Location: backends/android/ (git submodule)

The Android backend uses JNI (Java Native Interface) to bridge Rust and Kotlin. It renders using Android’s native View system.

Architecture

Rust Library (.so)
     |
     JNI (Java Native Interface)
     |
Kotlin Runtime (dev.waterui.android)
     |
     Android Views / Compose Interop

JNI Bridge

On Android, the FFI macros generate JNI entry points alongside C functions:

// Generated by ffi_view!(TextConfig, WuiText, text)
extern "system" fn Java_dev_waterui_android_ffi_WatcherJni_textId(...) -> jobject;
extern "system" fn Java_dev_waterui_android_ffi_WatcherJni_forceAsText(...) -> jobject;

The JNI module (ffi/src/jni/) provides:

  • Class caching: JNI class references are cached at JNI_OnLoad time.
  • Struct-to-Java conversion: Rust #[repr(C)] structs are converted to Java objects field by field.
  • Pointer management: Rust pointers are passed as jlong values through JNI.

Initialization

The JNI_OnLoad function (generated by export!()) runs when the native library loads:

extern "system" fn JNI_OnLoad(vm: *mut c_void, _reserved: *mut c_void) -> i32 {
    ndk_context::initialize_android_context(vm, null_mut());
    jni::init(vm as *mut JavaVM)
}

This initializes the NDK context (for logging, asset access) and the JNI module (for cached class references and the JavaVM pointer).

Gradle Project Structure

The Android backend is a Gradle project with modules:

  • runtime: The Kotlin library that hosts the WaterUI view tree.
  • ffi: JNI bindings generated from Rust.

You do not invoke Gradle or adb directly. The water CLI orchestrates everything:

  1. Cross-compiling Rust for Android targets (aarch64-linux-android, etc.).
  2. Copying the .so into the Gradle project’s jniLibs/.
  3. Running the embedded Gradle wrapper to build the APK.
  4. Installing and launching the app on the chosen device or emulator.

If you ever feel tempted to call gradlew or adb install from a script, extend the CLI instead so the workflow stays reproducible across machines.

Hydrolysis Backend

Location: backends/hydrolysis/ (and backends/hydrolysis-m3/ for the Material 3 component overrides).

Hydrolysis is the active Rust-side backend. It does not use platform widgets at all – it renders the entire UI on the GPU using vello for 2D graphics, parley for text shaping, and wgpu for the device interface. It is also the backend that drives waterui-testing, because the accessibility tree it produces is the contract that integration tests assert against.

Architecture

Rust View Tree
     |
     ViewDispatcher (Rust)        ----> AccessKit a11y tree
     |
     Hydrolysis widgets (text, layout, scroll, gestures, ...)
     |
     vello + parley scene
     |
     wgpu device + GPU surface

View Dispatch

Hydrolysis (and any other Rust-side backend) uses ViewDispatcher from waterui-backend-core to route views to type-specific handlers:

use waterui_backend_core::ViewDispatcher;
use waterui_core::{components::Native, Environment};

let mut dispatcher: ViewDispatcher<State, RenderContext, Widget> =
    ViewDispatcher::new();

dispatcher.register::<Native<TextConfig>>(|state, ctx, native, env| {
    // Build the Hydrolysis text widget from the config.
});

dispatcher.register::<Native<ButtonConfig>>(|state, ctx, native, env| {
    // Build the Hydrolysis button widget.
});

// Dispatch a concrete view; unknown types auto-expand via body().
dispatcher.dispatch(view, &env, context);

The dispatcher’s dispatch method is the render loop:

  1. Look up the view’s TypeId in the handler table.
  2. If a handler is registered, run it.
  3. Otherwise, evaluate body() and recurse on the result.

This is the same algorithm the FFI backends implement in Swift or Kotlin, but performed in Rust without crossing a language boundary.

GPU Discipline

Hydrolysis enforces several rules that you must respect when authoring GPU-backed components on top of it:

  • GPU only on the runtime path. Never read render targets back to CPU memory in the runtime render path. Offscreen capture for testing has its own dedicated entry points.
  • One GpuView per GpuSurface. GpuSurface::new(renderer) owns one GpuView for the lifetime of that surface. Persistent GPU resources for that renderer instance live in GpuView::setup(), not in hidden caches outside the surface’s lifetime.
  • No software fallback in production. Hydrolysis production surfaces reject software/noop wgpu adapters. Test-only constructors gated behind #[cfg(test)] (and the WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTER environment variable for diagnostics) opt in to compute-capable software adapters so CI can still run accessibility tests where no real GPU exists.

Accessibility as a Build Output

Every Hydrolysis component produces an accesskit accessibility tree as a first-class artifact, not as an after-thought layered on top of the visual output. waterui-testing consumes that tree directly, which means a UI component that fails accessibility coverage is failing its design contract, not just a CI lint.

Debug Tracing

Set WATERUI_DISPATCH_DEBUG=1 to log the dispatch tree as views are matched against handlers:

[dispatch] Native<TextConfig>
[dispatch] Metadata<Padding>
[dispatch]   Native<ButtonConfig>

Tip: This is invaluable when a view is not rendering as expected. It shows you exactly which view types the dispatcher matched and which ones fell through to body() recursion.

Other Rust-Side Backends

Two additional backend folders exist in the workspace:

  • GTK4 (backends/gtk/) – An early Linux backend over gtk4-rs. The upstream roadmap marks it as no longer supported, so do not target it for new work; treat it as historical reference.
  • TUI – Listed as work in progress in AGENTS.md. There is no stable terminal backend you can ship against today.

If you need a Linux-only target right now, drive Hydrolysis through water run --platform linux --backend hydrolysis.

How to Add a New Backend

If you want to bring WaterUI to a new platform, here is the roadmap:

  1. Create the backend crate in backends/your-backend/.

  2. Add a dependency on waterui-backend-core for the ViewDispatcher and shared types.

  3. Register view handlers for each native view type you support:

    dispatcher.register::<Native<TextConfig>>(|state, ctx, text, env| {
        // Create your platform's text widget
    });
  4. Handle metadata by registering handlers for Metadata<T> types:

    dispatcher.register::<Metadata<Opacity>>(|state, ctx, meta, env| {
        // Apply opacity, then render meta.content
    });
  5. Implement the application lifecycle: window creation, event loop, and signal-driven updates.

  6. Install theme signals: Map your platform’s appearance system to WaterUI’s color and font slots.

For backends that go through FFI (like the Apple and Android backends), you instead implement the view walker in the target language, using the C header or JNI functions to call into Rust.

Backend Status

BackendPathStatus
Applebackends/apple/Stable. Reference implementation.
Androidbackends/android/Stable.
Hydrolysisbackends/hydrolysis/Active Rust-side renderer. Drives waterui-testing.
Hydrolysis-M3backends/hydrolysis-m3/Material 3 component overrides for Hydrolysis.
GTK4backends/gtk/Unmaintained. The roadmap marks it as no longer supported.
TUI(not in tree)Work in progress.

Component coverage on each backend is an evolving target. Rather than freezing a feature matrix here that will rot, run water run --platform <target> against your view and read the dispatch trace – any view that falls through to its body() and never reaches a registered handler is a backend gap to file.

What’s Next

With backends covered, the next chapter shifts focus from framework internals to framework extension – how to author reusable WaterUI component libraries that integrate cleanly with the ecosystem.