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

The FFI bridge

In this chapter, you will:

  • See who owns the FFI layer now that apps no longer declare it
  • Learn the initialization sequence every native backend must follow
  • Install theme signals across the C ABI, slot by slot
  • Know the macros that generate the type-safe bindings, and how panics behave

WaterUI applications are written in Rust and render through backends written in Swift, Kotlin, or Rust. The FFI layer is the stable C ABI contract between them. Read this if you are adding a native view, debugging a cross-language issue, or writing a backend.

Who owns the FFI

Your application crate does not touch the FFI. It depends on waterui, exposes one function, and stops there:

use waterui::app::App;
use waterui::prelude::*;

pub fn app(env: Environment) -> App {
    App::new(main, env)
}

The water CLI generates and owns an FFI companion crate next to your project’s managed backends. That crate is the only place export!() appears:

//! Native FFI companion crate, generated by the CLI.

use waterui::app::App;
use waterui::env::Environment;

fn app(env: Environment) -> App {
    my_app::app(env)
}

waterui_ffi::export!();

If you find waterui-ffi = "0.2" in an application Cargo.toml, or waterui_ffi::export!() in application code, that is old scaffolding. Remove both; the CLI regenerates the companion.

The waterui-ffi crate

ffi/ translates between Rust types and C-compatible representations. Its core traits are:

  • IntoFFI — converts a Rust type to its FFI representation.
  • IntoNullableFFI — the same, for types with a designated null value, so Option<T> crosses the boundary without a wrapper.
  • IntoRust — converts an FFI value back to Rust. Unsafe; transfers ownership.

Two features select the ABI, and they are mutually exclusive: c-api for the Apple and Rust-side backends, android-jni for Android. Enabling both is a compile error, and so is targeting Android without android-jni. The crate is written with no_std structure but currently requires std — building it without the std feature is a compile error rather than a degraded build.

The export!() macro

export!() expands to the C entry points the native side calls.

waterui_init()

pub unsafe extern "C" fn waterui_init() -> *mut WuiEnv

Called once, on the main thread, at startup. In order, it:

  1. Installs a panic hook that forwards panics to tracing.
  2. Initializes platform logging — tracing-oslog under subsystem dev.waterui on Apple, tracing-android with tag WaterUI on Android, tracing-subscriber fmt elsewhere. Each honors RUST_LOG, with a quiet default that silences wgpu, naga, and JNI noise.
  3. Initializes the global async executor.
  4. Initializes the main-thread local executor, wired to an inspector probe when the inspector’s environment variables request one. Failure to start the inspector logs a warning and is not fatal.
  5. Creates an Environment and installs the app’s compile-time translation catalog into it (configure_environment!).
  6. Installs component runtimes that the target needs. On non-Apple targets that means waterui_video_gpu::install(&mut env), which provides the GPU video-player realization; Apple platforms bridge AVPlayer instead and install nothing here. When the generated companion selected a bundled Chromium runtime, its environment hook runs here too.
  7. Returns the environment as an opaque pointer.

Step 6 is worth pausing on: it is why the FFI companion is generated per project. Which component runtimes are linked and installed depends on which features that project actually uses, so unused ones never reach the binary.

waterui_app()

pub unsafe extern "C" fn waterui_app(env: *mut WuiEnv) -> WuiApp

Takes ownership of the environment — by now enriched with theme signals by the native side — calls your app(env: Environment) -> App, and returns:

#[repr(C)]
pub struct WuiApp {
    pub windows: WuiArray<WuiWindow>,  // first window is the main window
    pub menu_bar: *mut WuiAnyViews,
    pub env: *mut WuiEnv,              // returned to native for rendering
}

Android entry points

Android gets three extra exports, generated only when targeting Android:

extern "C" fn waterui_android_init() -> *mut c_void;
extern "C" fn waterui_android_app(env: *mut c_void) -> WuiAndroidAppHandles;
extern "system" fn JNI_OnLoad(vm: *mut c_void, reserved: *mut c_void) -> i32;

JNI_OnLoad caches JNI class references and the JavaVM pointer. waterui_android_app narrows WuiApp to the two opaque handles the single-activity backend needs — content and environment — and panics if the app declares anything other than exactly one window.

Initialization sequence

Every native backend follows the same protocol:

1. waterui_init()                       --> *mut WuiEnv
2. waterui_theme_install_color_scheme() --> install the light/dark signal
3. waterui_theme_install_color()        --> install color slots (x11)
4. waterui_theme_install_font()         --> install font slots (x6)
5. waterui_app(env)                     --> WuiApp { windows, menu_bar, env }
6. Render loop begins

Steps 2–4 inject reactive signals that track platform appearance changes, so the view tree resolves colors and fonts against live values rather than a snapshot.

Warning: The ordering is not a suggestion. Theme tokens fast-fail when they are missing: resolving an uninstalled color slot panics with WaterUI color token `...` is not installed in the environment, and reading the color scheme without one installed panics too. Your app() may reference theme::color::Foreground or .body() the moment it runs, so install theme signals before waterui_app(). (Use installed_color_scheme(env) if you need a non-panicking probe.)

Theme installation APIs

Color scheme

Native backends build a callback-driven signal rather than a constant, so the tree tracks system appearance changes:

WuiComputed_ColorScheme *scheme = waterui_new_computed_color_scheme(
    my_state,       // void* passed back to every callback
    read_scheme,    // WuiColorScheme (*)(const void*)
    watch_scheme,   // WuiWatcherGuard* (*)(const void*, WuiWatcher_ColorScheme*)
    drop_state);    // void (*)(void*)

waterui_theme_install_color_scheme(env, scheme);   // takes ownership

WuiColorScheme has two variants: Light (0) and Dark (1).

Color slots

WaterUI defines 11 semantic color slots:

SlotValuePurpose
Background0Primary background
Surface1Elevated surfaces (cards, sheets)
SurfaceVariant2Alternate surfaces
Border3Borders and dividers
Foreground4Primary text and icons
MutedForeground5Secondary/dimmed text
Accent6Interactive element highlights
AccentForeground7Text on accent backgrounds
AccentContainer8Container tied to the accent
Tertiary9Contrasting accent for complementary emphasis
TertiaryContainer10Container tied to the tertiary accent

Each slot is installed individually, and installing takes ownership of the signal:

WuiComputed_ResolvedColor *fg = create_foreground_signal();
waterui_theme_install_color(env, WuiColorSlot_Foreground, fg);

Installing a slot also mirrors the same signal into the matching waterui_graphics color slot, so GPU-drawn primitives track the identical value instead of drifting from the widget tree.

Font slots

WaterUI defines 6 font slots:

SlotValuePurpose
Body0Body text
Title1Titles
Headline2Headlines
Subheadline3Subheadlines
Caption4Captions
Footnote5Footnotes
WuiComputed_ResolvedFont *body = create_body_font_signal();
waterui_theme_install_font(env, WuiFontSlot_Body, body);

Querying theme values

Reads return a new reference that the caller must drop:

WuiComputed_ResolvedColor *accent = waterui_theme_color(env, WuiColorSlot_Accent);
// use the signal...
waterui_drop_computed_resolved_color(accent);

View traversal

waterui_view_id()

WuiTypeId waterui_view_id(const WuiAnyView *view);

Returns the 128-bit type ID. It is an FNV-1a hash of the Rust type name, not std::any::TypeId, so it stays stable across dynamic library boundaries — which the preview system depends on.

waterui_view_body()

WuiAnyView *waterui_view_body(WuiAnyView *view, WuiEnv *env);

Evaluates a composite view’s body(). Consumes the view pointer and returns a new one. The backend calls this whenever it does not recognize a type ID.

waterui_force_as_*()

For each raw view type, ffi_view! generates a paired id function and force-cast:

WuiText waterui_force_as_text(WuiAnyView *view);
WuiTypeId waterui_text_id(void);

The cast is unchecked and consumes the view — the caller must have compared the ID first. ffi_metadata! does the same for Metadata<T>:

WuiMetadataOpacity waterui_force_as_metadata_opacity(WuiAnyView *view);

and ffi_ignorable_metadata! for modifiers a backend may skip, such as MaterialBackground:

WuiIgnorableMetadataMaterialBackground
    waterui_force_as_ignorable_metadata_material_background(WuiAnyView *view);

waterui_view_stretch_axis()

WuiStretchAxis waterui_view_stretch_axis(const WuiAnyView *view);

Returns the view’s stretch axis without evaluating its body, so layout containers can size children cheaply.

FFI macros

ffi_safe!

Declares types as directly FFI-compatible (identity conversion in both directions):

ffi_safe!(u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, bool);

opaque!

Creates an opaque wrapper passed as a pointer, plus its drop function:

opaque!(WuiEnv, waterui::Environment, env);
// struct WuiEnv(Environment)
// C:   waterui_drop_env()
// JNI: WatcherJni.dropEnv()

ffi_view!

Generates the id and force-cast pair for a native view type, in both ABIs:

ffi_view!(TextConfig, WuiText, text);
// C-API: waterui_text_id(), waterui_force_as_text()
// JNI:   WatcherJni.textId(), WatcherJni.forceAsText()

The id returned is that of Native<TextConfig> — the wrapper the tree actually holds, not the user-facing Text.

ffi_metadata!

Same pattern for Metadata<T>, which is not wrapped in Native<T>:

ffi_metadata!(Opacity, WuiMetadataOpacity, opacity);
// C-API: waterui_metadata_opacity_id(), waterui_force_as_metadata_opacity()

into_ffi!

Derives IntoFFI for a struct or enum by converting field by field:

into_ffi! {
    TextConfig,
    pub struct WuiText {
        content: *mut WuiComputed<StyledStr>,
        paragraph_alignment: *mut WuiComputed<HorizontalAlignment>,
    }
}

The generated struct is #[repr(C)] and documented automatically. Note what crosses here: not a string, but a signal over a styled string. The backend subscribes to it and updates one label property when it fires.

Panics at the boundary

There is no catch-and-swallow wrapper around FFI entry points, and that is deliberate. A panic in Rust code called through extern "C" aborts the process rather than unwinding into C frames, which is the only sound behavior — a half-unwound Swift or Kotlin stack is not recoverable.

What you get instead is diagnosis. The panic hook installed by waterui_init() routes every panic through tracing, so it lands in the platform log (Console.app / logcat / stderr) with its message and location before the process goes down. Run with water run --logs debug to see it.

This is also why WaterUI’s fast-fail rules matter at this layer: an uninstalled theme token, an uncaught Metadata<T>, or a Native<T> with no handler all panic with a message that names exactly what the backend failed to provide, rather than rendering something subtly wrong.

C header generation

ffi/waterui.h is checked into the WaterUI repository and generated automatically. Never write or edit it by hand. If you are contributing to WaterUI and have changed an FFI signature, regenerate it from inside the upstream checkout:

cargo run --bin generate_header --features cbindgen --manifest-path ffi/Cargo.toml

CI verifies the checked-in header matches the generated output, so a missed regeneration fails your pull request instead of shipping a stale header. Application authors never run this.

Android JNI

On Android, the same macros emit JNI entry points instead of the C API:

// C-API (Apple / Rust-side backends)
extern "C" fn waterui_force_as_text(view: *mut WuiAnyView) -> WuiText;
extern "C" fn waterui_text_id() -> WuiTypeId;

// JNI (Android)
extern "system" fn Java_dev_waterui_android_ffi_WatcherJni_textId(...) -> jobject;
extern "system" fn Java_dev_waterui_android_ffi_WatcherJni_forceAsText(...) -> jobject;

ffi/src/jni/ converts between Rust structs and Java objects and caches class references. Because c-api and android-jni are mutually exclusive, one build produces one ABI.

Adding a new view to the FFI

Before doing any of this, check that the view genuinely needs a platform primitive. If it can be composed from existing primitives in Rust — as Form, Card, and Badge are — it should be, and it ships zero new C-ABI surface.

If it does need one:

  1. Define the view with raw_view! or configurable! in its component crate.
  2. Define a #[repr(C)] FFI struct (WuiMyView) under ffi/src/components/.
  3. Implement IntoFFI for the config, usually via into_ffi!.
  4. Call ffi_view!(MyViewConfig, WuiMyView, my_view).
  5. Regenerate the C header.
  6. Implement the handler in the Apple Swift package and the Android Kotlin runtime.

Header regeneration fails if any FFI type is not #[repr(C)]-compatible, so the mistake surfaces at build time.

Next: the layout engine

The FFI moves data across the language boundary but decides nothing about position. The next chapter covers WaterUI’s two-phase layout — how containers negotiate sizes with their children and place them in the final bounds.