2 releases
Uses new Rust 2024
| 0.2.1 | Dec 14, 2025 |
|---|---|
| 0.2.0 | Dec 14, 2025 |
#734 in GUI
2MB
18K
SLoC
waterui-ffi
FFI bindings layer that bridges Rust view trees to native platform backends.
Overview
waterui-ffi is the C FFI layer at the heart of WaterUI's cross-platform architecture. It provides a type-safe, efficient bridge between Rust application logic and native UI backends, enabling WaterUI apps to render as true native widgets rather than custom-drawn pixels.
This crate serves three critical roles:
-
Entry Point Generation: The
export!()macro generates the C ABI entry points (waterui_init,waterui_app) that native backends call to initialize and run Rust applications. -
Type Conversion: Implements
IntoFFIandIntoRusttraits to safely convert between Rust types (views, reactive values, colors, fonts) and C-compatible representations that can cross the FFI boundary. -
C Header Generation: Uses
cbindgento automatically generatewaterui.h, which is consumed by Swift (Apple backend) and Kotlin/JNI (Android backend) to understand the FFI contract.
The FFI layer is designed to work in no_std environments and minimizes unsafe code through carefully designed abstractions.
Installation
Add to your Cargo.toml:
[dependencies]
waterui = "0.2"
waterui-ffi = "0.1"
For applications, you typically only need the export!() macro - all other FFI details are handled internally by WaterUI.
Quick Start
Every WaterUI application uses the FFI layer to expose itself to native platforms:
use waterui::prelude::*;
use waterui::app::App;
// Your application entry point
pub fn app(env: Environment) -> App {
App::new(main, env)
}
fn main() -> impl View {
text("Hello, WaterUI!")
}
// This macro generates waterui_init() and waterui_app() FFI entry points
waterui_ffi::export!();
The export!() macro expands to:
#[no_mangle]
pub unsafe extern "C" fn waterui_init() -> *mut WuiEnv {
// Initialize runtime, logging, executors
let env = waterui::Environment::new();
env.into_ffi()
}
#[no_mangle]
pub unsafe extern "C" fn waterui_app(env: *mut WuiEnv) -> WuiApp {
let env = env.into_rust();
let app = app(env); // Call your app() function
app.into_ffi()
}
Native backends then call these functions to initialize and retrieve the root view tree.
Core Concepts
FFI Conversion Traits
The crate defines two fundamental traits for crossing the FFI boundary:
IntoFFI - Converts Rust types to FFI-compatible representations:
pub trait IntoFFI: 'static {
type FFI: 'static;
fn into_ffi(self) -> Self::FFI;
}
// Example: Converting a String to a C-compatible byte array
impl IntoFFI for Str {
type FFI = WuiStr;
fn into_ffi(self) -> Self::FFI {
WuiStr(WuiArray::new(self))
}
}
IntoRust - Safely converts FFI types back to Rust types:
pub trait IntoRust {
type Rust;
unsafe fn into_rust(self) -> Self::Rust;
}
// Example: Converting C byte array back to String
impl IntoRust for WuiStr {
type Rust = Str;
unsafe fn into_rust(self) -> Self::Rust {
let bytes = unsafe { self.0.into_rust() };
unsafe { Str::from_utf8_unchecked(bytes) }
}
}
Opaque Types
For types where the internal structure isn't relevant to native code, use the OpaqueType trait:
impl OpaqueType for Environment {}
// Automatically implements:
// - IntoFFI converting to *mut WuiEnv
// - IntoRust converting from *mut WuiEnv
This pattern is used for Environment, AnyView, Binding<T>, and Computed<T>, which native code treats as opaque pointers.
Type Identification
The FFI layer uses WuiTypeId for O(1) type identification across the FFI boundary:
#[repr(C)]
pub struct WuiTypeId {
pub low: u64,
pub high: u64,
}
In normal builds, this wraps Rust's TypeId. In hot reload builds (with waterui_hot_reload_lib cfg), it uses a 128-bit FNV-1a hash of the type name, ensuring type IDs remain stable across dylib reloads.
Native backends use type IDs to determine which view type they're rendering:
let viewId = waterui_view_id(view)
if viewId == waterui_text_id() {
let textConfig = waterui_force_as_text(view)
return Text(textConfig.content.get())
} else if viewId == waterui_button_id() {
// ...
}
View Rendering Protocol
Native backends traverse the view tree using these core functions:
waterui_view_id(view)- Get the 128-bit type IDwaterui_view_body(view, env)- Expand composite views into their bodywaterui_force_as_<type>(view)- Downcast to specific view type (Button, Text, etc.)waterui_view_stretch_axis(view)- Query layout behavior
Composite views (user-defined) are recursively expanded via body(). Leaf views (Text, Button, Image) are downcast and mapped directly to native widgets.
Reactive System FFI
WaterUI's reactive primitives (Binding, Computed) cross the FFI boundary with full functionality:
Binding (Read/Write Reactive State)
// Rust side
let counter = Binding::int(42);
// FFI functions generated by ffi_binding! macro:
// - waterui_read_binding_i32(binding) -> i32
// - waterui_set_binding_i32(binding, value)
// - waterui_watch_binding_i32(binding, watcher) -> guard
// - waterui_drop_binding_i32(binding)
Native code can read, write, and subscribe to changes:
let binding: UnsafeMutablePointer<WuiBinding<Int32>>
let value = waterui_read_binding_i32(binding)
waterui_set_binding_i32(binding, value + 1)
// Watch for changes
let guard = waterui_watch_binding_i32(binding) { newValue, metadata in
print("Counter changed to \(newValue)")
}
Computed (Read-Only Derived State)
// Rust side
let doubled = counter.map(|n| n * 2);
// FFI functions generated by ffi_computed! macro:
// - waterui_read_computed_i32(computed) -> i32
// - waterui_watch_computed_i32(computed, watcher) -> guard
// - waterui_clone_computed_i32(computed) -> computed
// - waterui_drop_computed_i32(computed)
Computed values can also be created from native code using waterui_new_computed_<type>(), enabling native-driven reactivity.
Architecture
Data Flow
┌─────────────────────────────────────────────────────┐
│ Rust Application (waterui) │
│ - View tree definition │
│ - Reactive state (Binding, Computed) │
│ - Business logic │
└─────────────────┬───────────────────────────────────┘
│ .into_ffi()
▼
┌─────────────────────────────────────────────────────┐
│ FFI Layer (waterui-ffi) │
│ - Entry points: waterui_init(), waterui_app() │
│ - Type conversion: IntoFFI, IntoRust │
│ - View traversal: waterui_view_id(), _body() │
│ - Reactive primitives: Binding, Computed FFI │
└─────────────────┬───────────────────────────────────┘
│ C ABI (waterui.h)
▼
┌─────────────────────────────────────────────────────┐
│ Native Backend (Swift/Kotlin) │
│ - Apple: UIView/NSView │
│ - Android: Android View │
│ - Maps Rust views to platform widgets │
└─────────────────────────────────────────────────────┘
Component FFI Modules
The components/ directory contains FFI bindings for each UI component category:
layout- HStack, VStack, ZStack, ScrollView, Spacerbutton- Button with styles (plain, bordered, link)text- Text rendering, fonts, styled textform- TextField, SecureField, Toggle, Slider, Pickernavigation- NavigationStack, TabView, NavigationLinkmedia- Image, Video, Audio, LivePhotolist- List, ForEach, LazyVStacktable- Table with columns and rowsprogress- ProgressView, ProgressIndicatorgpu_surface- High-performance wgpu rendering surface
Each module defines:
- C-compatible struct representations (e.g.,
WuiButton) IntoFFIimplementations for converting Rust view configs- FFI view macros generating type ID and downcast functions
Helper Macros
The crate provides several code generation macros to reduce boilerplate:
opaque!(Name, RustType, ident) - Generate opaque pointer FFI for a type:
opaque!(WuiEnv, waterui::Environment, env);
// Generates: WuiEnv wrapper, IntoFFI, IntoRust, waterui_drop_env()
ffi_view!(RustView, FFIStruct, ident) - Generate view FFI functions:
ffi_view!(ButtonConfig, WuiButton, button);
// Generates: waterui_button_id(), waterui_force_as_button()
ffi_reactive!(Type, FFIType, ident) - Generate binding + computed FFI:
ffi_reactive!(i32, i32, i32);
// Generates: read/write/watch/drop for both Binding<i32> and Computed<i32>
into_ffi!{} - Derive IntoFFI for structs and enums:
into_ffi! {
ButtonStyle,
pub enum WuiButtonStyle {
Plain, Bordered, Link,
}
}
Examples
Creating a New FFI View Type
To add FFI support for a new view component:
// 1. Define the Rust view config (in components/controls/src/rating.rs)
pub struct RatingConfig {
pub value: Computed<f32>,
pub max: f32,
pub color: Color,
}
// 2. Add FFI bindings (in ffi/src/components/controls.rs)
use crate::{IntoFFI, reactive::WuiComputed, color::WuiColor};
into_ffi! {
RatingConfig,
pub struct WuiRating {
value: *mut WuiComputed<f32>,
max: f32,
color: *mut WuiColor,
}
}
ffi_view!(RatingConfig, WuiRating, rating);
// 3. Regenerate waterui.h
// cargo run --bin generate_header --features cbindgen --manifest-path ffi/Cargo.toml
// 4. Implement in Swift (backends/apple/Sources/WaterUI/Views/Rating.swift)
// if viewId == waterui_rating_id() {
// let config = waterui_force_as_rating(view)
// return RatingView(config: config)
// }
Implementing Custom Reactive Properties
// Generate FFI for a custom type in reactive state
use waterui_color::Color;
ffi_reactive!(Color, *mut WuiColor, color);
// Now Binding<Color> and Computed<Color> can cross FFI
Creating Native-Controlled Computed Values
// Swift side creates a computed that Rust can read
let computed = waterui_new_computed_i32(
dataPtr,
{ ptr in return getCurrentValue(ptr) },
{ ptr, watcher in return watchValue(ptr, watcher) },
{ ptr in cleanup(ptr) }
)
// Pass to Rust view
let text = waterui_text(computed)
C Header Generation
The crate includes a generate_header binary that uses cbindgen to produce waterui.h:
cargo run --bin generate_header --features cbindgen --manifest-path ffi/Cargo.toml
This generates the C header and automatically copies it to:
backends/apple/Sources/CWaterUI/include/waterui.h(SwiftUI backend)backends/android/runtime/src/main/cpp/waterui.h(Android backend)
The header is checked into version control, and CI verifies it's always up-to-date with the Rust code.
Native Backend Render Pipeline
Native backends (Android, Apple, etc.) must follow a specific initialization sequence when rendering WaterUI views.
Required Sequence
┌─────────────────────────────────────────────────────────────────────┐
│ 1. waterui_init() │
│ - Initializes panic hooks and global executors │
│ - Returns an Environment pointer │
│ - MUST be called first before any other waterui_* functions │
├─────────────────────────────────────────────────────────────────────┤
│ 2. Theme installation (recommended) │
│ - Install appearance: waterui_theme_install_color_scheme() │
│ - Install colors: waterui_theme_install_color() │
│ - Install fonts: waterui_theme_install_font() │
│ - Legacy: waterui_env_install_theme() is deprecated │
├─────────────────────────────────────────────────────────────────────┤
│ 3. waterui_app(env) │
│ - Creates the application from user's app(env) function │
│ - Returns WuiApp with windows and environment │
│ - MUST be called AFTER waterui_init() and theme installation │
├─────────────────────────────────────────────────────────────────────┤
│ 4. Render Loop (for each view) │
│ a. waterui_view_id(view) → Get the type ID │
│ b. Check if it's a "raw view" (Text, Button, etc.) │
│ - If raw: waterui_force_as_*(view) → Extract native data │
│ - If composite: waterui_view_body(view, env) → Get body view │
│ c. Render the native widget or recurse into body │
└─────────────────────────────────────────────────────────────────────┘
Raw Views vs Composite Views
WaterUI distinguishes between two kinds of views:
-
Raw Views: Leaf components that map directly to native widgets. Examples:
Text,Button,Color,TextField,Toggle,Slider,Stepper,Progress,Spacer,Picker,ScrollView. -
Composite Views: User-defined views that have a
body()method returning other views. When you encounter a view that isn't in the raw view registry, callwaterui_view_body(view, env)to get its body and continue rendering recursively.
Features
std(default) - Enable standard library supportcbindgen- Required for thegenerate_headerbinary
API Overview
Core Entry Points
waterui_init()- Initialize runtime and return Environmentwaterui_app(env)- Create application from user's app() functionwaterui_env_new()- Create new Environment (alternative to init)waterui_clone_env(env)- Clone Environment for child contexts
View Traversal
waterui_view_id(view)- Get type ID as 128-bit valuewaterui_view_body(view, env)- Expand composite view to its bodywaterui_view_stretch_axis(view)- Query layout stretch behaviorwaterui_empty_anyview()- Create empty viewwaterui_anyview_id()- Get AnyView type ID
Type Downcasting
waterui_force_as_<type>(view)- Downcast to specific view typewaterui_<type>_id()- Get type ID for comparison
Reactive Primitives
waterui_read_binding_<type>(binding)- Read current valuewaterui_set_binding_<type>(binding, value)- Update valuewaterui_watch_binding_<type>(binding, watcher)- Subscribe to changeswaterui_read_computed_<type>(computed)- Read derived valuewaterui_watch_computed_<type>(computed, watcher)- Subscribe to changeswaterui_new_computed_<type>(...)- Create native-controlled computedwaterui_drop_binding_<type>(binding)- Cleanup bindingwaterui_drop_computed_<type>(computed)- Cleanup computed
Memory Management
waterui_drop_<type>(ptr)- Free resource of given typewaterui_drop_retain(retain)- Drop retained value
Safety Considerations
The FFI layer involves extensive unsafe code by necessity:
- Pointer Validity: Callers must ensure pointers from FFI functions remain valid for their usage
- Ownership Transfer: Functions taking
*mut Ttypically take ownership and will free the memory - Thread Safety:
waterui_init()must be called once on the main thread only - Type Downcasting:
waterui_force_as_*()functions assume the view type matches
Native backends are responsible for:
- Properly managing the lifetimes of FFI pointers
- Calling drop functions when resources are no longer needed
- Not using pointers after they've been consumed
Performance
The FFI layer is designed for zero-overhead abstraction:
- Type IDs: O(1) comparison (128-bit integer equality)
- View Traversal: Single pointer dereference per level
- Reactive Updates: Direct function pointer callbacks, no allocation
- Arrays: Zero-copy views into Rust data via vtable-based slicing
The reactive system uses reference counting (Rc) on the Rust side and lets native code subscribe via lightweight watcher callbacks.
Development Workflow
When adding a new view type to WaterUI:
- Define the Rust view struct in the appropriate component crate
- Add FFI bindings in
ffi/src/components/<module>.rs - Regenerate the C header:
cargo run --bin generate_header --features cbindgen --manifest-path ffi/Cargo.toml - Implement the native renderer in Swift (
backends/apple) and Kotlin (backends/android) - Update tests to verify FFI contract
The workflow ensures Rust, C header, and native backends stay synchronized.
License
MIT
Dependencies
~31–78MB
~1M SLoC