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

Introduction

In this chapter, you will:

  • Discover what WaterUI is and why it exists
  • Understand how native rendering differs from web-view approaches
  • See the full range of supported platforms and backends
  • Get a taste of WaterUI with a working counter example

Pinned to upstream: every example and API name in this book is verified against waterui dev bee793e6305f (2026-05-29, “Install widget theme for webview semantics”). When the submodule bumps, the chapters bump with it.

Imagine writing your UI once in Rust and having it render as a truly native app on iOS, Android, macOS, and Linux – no web views, no custom rendering, just real platform widgets. That is what WaterUI gives you. If you have ever wished for the safety of Rust’s type system combined with the ergonomics of SwiftUI or Jetpack Compose, you are in the right place.

What is WaterUI?

WaterUI is a cross-platform, reactive, declarative UI framework for Rust. You describe what your interface should look like, and the framework takes care of how it renders – on every platform.

Unlike electron-style approaches that draw their own pixels inside a web view, WaterUI renders to native platform widgets. On Apple platforms (iOS and macOS) it bridges to SwiftUI/UIKit/AppKit through a Swift backend. On Android it bridges to Android Views via JNI/Kotlin. On Linux it delegates to GTK4. The result is an application that looks, feels, and performs like a first-class citizen on each operating system.

Rust View Tree  --->  FFI (C ABI)  --->  Native Backend  --->  Platform UI
                                          Swift / Kotlin / GTK4

Key Features

  • Cross-platform: iOS, Android, macOS, and Linux from one Rust codebase. The default native backends are Apple, Android, and GTK4; Hydrolysis provides an experimental self-drawn path for macOS, Linux, Windows, and Web.
  • Type-safe: Leverage Rust’s type system, ownership, and lifetimes to eliminate whole categories of runtime errors at compile time.
  • Reactive: WaterUI’s Binding<T>, Computed<T>, and Signal types automatically propagate changes through the view tree so the UI stays in sync with your data.
  • Declarative: Describe your UI as a composition of View values. Layout, styling, and interaction are expressed through method chaining and tuple composition rather than imperative mutation.
  • Native rendering: Each backend maps Rust views to the platform’s own widgets, giving you native text rendering, accessibility, animations, and input handling for free.

Supported Backends

BackendPlatform(s)TechnologyStatus
AppleiOS, macOSSwiftUI / UIKit / AppKit via SwiftStable
AndroidAndroidAndroid Views via Kotlin / JNIStable
GTK4LinuxGTK4 via gtk4-rsStable
HydrolysismacOS, Linux, Windows, WebSelf-drawn (Vello / tiny-skia / wgpu)Experimental

Note: You only need one backend to get started. Most readers begin with whichever platform they already have tooling for – macOS if you have a Mac, Linux with GTK4, or Android if you have Android Studio installed.

Framework Architecture

WaterUI is organised as a Cargo workspace. The table below lists the most important crates. You do not need to depend on them individually – the top-level waterui crate re-exports everything through waterui::prelude::*.

CratePathRole
waterui/Facade crate, re-exports components, prelude, macros
waterui-corecore/View trait, Environment, AnyView, reactive primitives
waterui-layoutcomponents/foundation/layout/VStack, HStack, ZStack, ScrollView, Spacer, grids
waterui-textcomponents/foundation/text/Text view, fonts, styled text, markdown
waterui-controlscomponents/foundation/controls/Button, Toggle, Slider, Stepper, TextField
waterui-navigationcomponents/foundation/navigation/Navigation containers, TabView
waterui-formcomponents/foundation/form/#[form] derive macro, form builder
waterui-mediacomponents/multimedia/media/Photos, video, audio playback
waterui-graphicscomponents/visual/graphics/GPU surfaces, filters, gradients, image analysis
waterui-canvascomponents/visual/canvas/Workspace canvas crate; not re-exported by waterui at this checkpoint
waterui-iconcomponents/foundation/icon/Cross-platform icon system
waterui-webviewcomponents/platform/webview/Embedded web views
waterui-macrosmacros/Proc macros: text!, #[form], #[preview]
waterui-ffiffi/C FFI bridge, export!() macro
waterui-clicli/The water CLI for scaffolding, building, running, packaging
waterui-strutils/str/Shared string utilities
waterui-urlutils/url/URL handling utilities
waterui-localeutils/locale/Localisation and formatting
waterui-assetscomponents/assets/Asset loading and management
namiutils/nami/ (vendored submodule)Fine-grained reactive implementation behind waterui::reactive; app code should use WaterUI re-exports

Backend Crates

CratePathRole
waterui-backend-corebackends/core/Shared backend abstractions
Apple backendbackends/apple/Swift Package (git submodule)
Android backendbackends/android/Gradle project (git submodule)
waterui-gtkbackends/gtk/GTK4 backend implementation
Hydrolysisbackends/hydrolysis/Self-drawn renderer (experimental)

Tip: You will rarely interact with individual crates directly. The waterui::prelude::* import gives you everything you need in day-to-day development.

Prerequisites

Before starting this book, you should be comfortable with:

  • Basic Rust – ownership, borrowing, traits, generics, and closures. If you are new to Rust, we recommend working through The Rust Programming Language first.
  • The command line – you will use the water CLI and cargo extensively.
  • One target platform – having Xcode (for Apple targets), Android Studio (for Android), or GTK4 development libraries (for Linux) installed will let you run examples on real hardware.

How to Use This Book

The book is structured in eight parts that build on each other:

  1. Getting Started – Install the toolchain, learn the CLI, create your first app, and understand the project layout.
  2. Core Concepts – The View trait, WaterUI reactive state, environment-based dependency injection, and modifiers.
  3. Building UIs – Text, layout, controls, forms, lists, conditional rendering, and navigation.
  4. Rich Content – Media, maps, web views, and barcodes.
  5. Graphics and Effects – Canvas drawing, GPU rendering, shaders, filters, particles, and gradients.
  6. Advanced Patterns – Animation, gestures, async views, error handling, accessibility, internationalisation, and plugins.
  7. Developer Tools – The preview system and hot reload.
  8. Under the Hood – How WaterUI renders, the FFI bridge, the layout engine, and backend architecture.

Most chapters contain runnable code examples. Clone the repository and use water create "Counter" --mode playground to set up sandbox projects as you follow along. Chapters that discuss workspace-only internals call that out explicitly.

A Taste of WaterUI

Here is a minimal counter application to give you a feel for the framework:

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

pub fn main() -> impl View {
    let counter = Binding::i32(0);

    vstack((
        text!("Count: {counter}"),
        hstack((
            button("Decrement")
                .action(|State(c): State<Binding<i32>>| c.set(c.get() - 1))
                .state(&counter),
            button("Increment")
                .action(|State(c): State<Binding<i32>>| c.set(c.get() + 1))
                .state(&counter),
        )),
    ))
}

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

This is the user crate’s src/lib.rs: it defines your root view and the public app(env) constructor. The CLI generates a companion FFI crate that exports the C entry points native backends need, so you do not write waterui_ffi::export!() in this file. The same Rust view code runs on the supported native targets without platform-specific #[cfg] branches.

Contributing

This book is open source. Found a typo, an unclear explanation, or want to add a chapter?

Head to The Water CLI to install your tools and create your first project.

The Water CLI

In this chapter, you will:

  • Install the water command-line tool
  • Understand the difference between playground and app project modes
  • Learn the key commands: create, run, build, package, and more

Every WaterUI project starts with the water CLI. It is your single entry point for creating projects, compiling Rust for mobile and desktop targets, launching apps on simulators and devices, and packaging for distribution. Think of it as cargo for cross-platform native apps – it wraps the complexity of Xcode, Gradle, and GTK4 build systems so you can focus on writing Rust.

Installation

The CLI is part of the WaterUI repository. Install it from source:

cargo install --path cli --locked

After installation, verify the tool is on your PATH:

water --help

Tip: If you are actively developing the CLI itself, use cargo build -p waterui-cli for faster iteration, then reinstall with cargo install --path cli when you need the updated binary in your PATH.

Project modes

WaterUI supports two project modes, each suited to a different stage of development. Choosing the right one upfront will save you time.

Playground mode

Playground mode is designed for quick experimentation. When you create a project with --mode playground, the CLI manages all native backend projects automatically inside the global build cache under ~/.water/build_cache/<absolute-project-path>/managed_backends/. You only write Rust.

water create "My Experiment" --mode playground

What you get on disk:

my-experiment/
  Cargo.toml
  Water.toml          # type = "playground"
  src/lib.rs
  assets/
    raw/
    images/

Playground projects:

  • Auto-initialise Apple and Android backends on every water run.
  • Re-scaffold backend templates automatically so manifest changes (such as permissions) are always picked up.
  • Store all generated native projects in the global build cache, keeping your working directory clean and free of platform clutter.

Tip: Playground mode is what you want while following this book. It keeps the boilerplate out of your way so you can concentrate on learning WaterUI itself.

App project mode

App mode (the default) gives you explicit control over backend configuration. Native backend projects live under a backends/ directory in your project root and are checked into version control.

water create "Production App" --backends apple,android

What you get:

production-app/
  Cargo.toml
  Water.toml          # type = "app"
  src/lib.rs
  assets/
    raw/
    images/
  backends/
    apple/            # Swift Package, checked in
    android/          # Gradle project, checked in
    gtk4/             # GTK4 backend crate, checked in
    ffi/              # Generated FFI companion crate

App projects are required for:

  • Customising native build settings (Xcode schemes, Gradle dependencies, etc.)
  • Adding platform-specific native code
  • Production deployment pipelines

Now that you understand both modes, let’s look at what the CLI can do.

Command Reference

water create

Scaffold a new WaterUI project.

# Interactive mode (prompts for name, bundle ID, backends)
water create

# Playground project
water create "Counter" --mode playground

# App project with explicit backends
water create "My App" --backends apple,android

# With custom bundle identifier
water create "My App" --bundle-id dev.waterui.myapp --backends apple

# Link to a local WaterUI checkout (for framework development)
water create "Dev App" --waterui-path ../waterui --backends apple

Arguments:

ArgumentDescription
nameProject display name (for example, “Water Example”). The folder name is derived as kebab-case.
--bundle-idBundle identifier (defaults to com.example.<snake_case_name>).
--backendsComma-separated list: apple, android, gtk4, hydrolysis. Only valid in app mode.
--modeapp (default) or playground.
--waterui-pathPath to a local WaterUI checkout (for framework development).

When run without arguments in an interactive terminal, the CLI prompts for each value with sensible defaults.

GTK4 app backends can only be scaffolded on Linux hosts at this checkpoint. Hydrolysis is available for macOS, Linux, Windows, and Web.

water run

This is the command you will use most often. It builds, packages, and runs the application on a target device – all in one step.

# Run on iOS Simulator (default device)
water run --platform ios

# Run on a specific iOS Simulator
water run --platform ios --device "iPhone 16 Pro"

# Run on Android (connected device or first emulator)
water run --platform android

# Run on macOS
water run --platform macos

# Run on macOS with the Hydrolysis renderer
water run --platform macos --backend hydrolysis

# Run on Linux (defaults to the GTK4 backend)
water run --platform linux

# Run on Windows
water run --platform windows

# Stream debug logs
water run --platform ios --logs debug

# Include native platform logs (verbose)
water run --platform ios --logs debug --native-logs

If you omit --platform, water run defaults to the host platform: macos on macOS, linux on Linux, and windows on Windows.

Arguments:

ArgumentDescription
--platform, -pTarget platform: ios, android, macos, linux, windows, web. Defaults to the host platform.
--backend, -bOverride the default backend for the platform.
--device, -dDevice name or identifier. If omitted, uses the first booted or available device.
--pathProject directory (defaults to .).
--logsMinimum log level to stream: error, warn, info, debug, verbose.
--native-logsInclude all native logs (NSLog, Android logcat), not just WaterUI logs.

The default backend for each platform is:

PlatformDefault backend
iOSApple
macOSApple
AndroidAndroid
LinuxGTK4
WindowsHydrolysis
WebHydrolysis

Valid backend/platform combinations:

BackendSupported platforms
AppleiOS, macOS
AndroidAndroid
GTK4Linux
HydrolysismacOS, Linux, Windows, Web

Note: If you have multiple simulators or emulators available, water run picks the first booted one. Use --device to target a specific device by name.

water build

Compile the Rust library for a target platform without packaging or running. This is useful in CI pipelines or when you want to check compilation without launching an app. water build only operates on app-mode projects; playground projects are built and packaged via water run and water package.

# Build for iOS device
water build --platform ios

# Build for iOS Simulator (specific architecture)
water build --platform ios-simulator --arch arm64

# Build for Android
water build --platform android --arch arm64

# Release build
water build --platform macos --release

# Build and copy to a specific output directory
water build --platform macos --output-dir ./out

Arguments:

ArgumentDescription
--platform, -pTarget: ios, ios-simulator, android, macos, linux, windows.
--backend, -bBackend override.
--arch, -aArchitecture: arm64, x86_64, armv7, x86. Apple/Android backends only.
--releaseBuild in release mode.
--pathProject directory (defaults to .).
--output-dirCopy the built library to this directory (Apple/Android backends only).

water package

Package the application for distribution. When you are ready to ship, this is how you produce installable artifacts. --backend is required.

# Package for iOS (physical device)
water package --platform ios --backend apple

# Release build for distribution
water package --platform ios --backend apple --release --distribution

# Package for Android (must specify architecture)
water package --platform android --backend android --arch arm64

# Package for Android (multiple architectures)
water package --platform android --backend android --arch arm64,x86_64

Arguments:

ArgumentDescription
--platform, -pTarget: ios, ios-simulator, android, macos, linux, windows, web.
--backend, -bRequired. Backend to package with.
--releaseBuild in release mode (optimised).
--distributionPackage for store distribution (App Store, Play Store).
--archTarget architecture(s) for Android (comma-separated). Required for Android.
--pathProject directory (defaults to .).

water preview

Render a view function to a PNG image without launching the full application. This is useful for visual testing and documentation.

# Preview a function on macOS
water preview my_card --platform macos --path ./app

# Custom frame size
water preview dashboard --platform ios --frame 390x844

# Custom output path
water preview login_screen --platform macos --output login.png

The function must be annotated with the #[preview] attribute macro:

use waterui::prelude::*;

#[preview]
fn my_card() -> impl View {
    text("Hello Preview!")
}

Arguments:

ArgumentDescription
function_pathFunction name or path (for example, dashboard::admin::card).
--platform, -pTarget: ios, macos, android.
--backendapple, android, or hydrolysis. Defaults to the platform’s native preview backend.
--frame, -fFrame size as WIDTHxHEIGHT (default: 375x667).
--output, -oOutput file path (default: preview.png).
--pathProject directory (defaults to .).

water doctor

Not sure if your environment is set up correctly? water doctor checks everything for you.

# Check toolchain
water doctor

# Attempt to fix missing dependencies automatically
water doctor --fix

The doctor checks for:

  • Rust toolchain and required targets
  • Xcode and command-line tools (macOS)
  • Android SDK and NDK
  • GTK4 development libraries
  • sccache (optional, for build caching)

Items marked [fixable] can be installed automatically with --fix.

Tip: Run water doctor any time something does not compile as expected. It often catches missing targets or outdated toolchains before you start debugging your own code.

water devices

List available simulators, emulators, and connected devices.

# List all devices across all platforms
water devices

# List only iOS simulators
water devices --platform ios

# List only Android devices and emulators
water devices --platform android

# JSON output (for scripting). --json is a global flag.
water --json devices --platform all

The output shows each device’s name, identifier, and state (booted/available).

water clean

Remove build artifacts.

# Clean all backends in the current project
water clean

# Clean only the Apple backend
water clean --backend apple

# Clean only the Android backend
water clean --backend android

# Recursively clean all WaterUI projects under a directory
water clean --recursive --path ~/projects

# Skip confirmation in recursive mode
water clean --recursive --yes

# Wipe the global managed build cache under ~/.water/build_cache
water clean --global-cache --yes

In recursive mode, the CLI finds every directory containing a valid Water.toml and clears each project’s managed build cache (for playgrounds) or target/ directory (for app projects).

water gc

Garbage-collect stale entries in the global managed build cache. Run this if playground caches under ~/.water/build_cache/ have piled up across many abandoned projects.

water gc build-cache

Next steps

With the CLI installed, continue to Installation and Setup to configure your platform toolchains, or jump straight to Your First App if you already have everything in place.

Installation and Setup

In this chapter, you will:

  • Install Rust and the required cross-compilation targets
  • Set up platform toolchains for Apple, Android, or Linux
  • Install the Water CLI and verify everything with water doctor
  • Create and run your first project to confirm the full pipeline works

Before you can build native apps with WaterUI, you need a working toolchain. This chapter walks you through every step – from installing Rust to seeing your first app launch on a real device or simulator. By the end, water doctor will give you a clean bill of health.

Note: You only need one target platform to get started. Pick the one you are most comfortable with and skip the rest for now. You can always come back and add more later.

Step 1: Install Rust

WaterUI requires Rust 1.88 or later (edition 2024). Install Rust with rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

After installation, confirm the version:

rustc --version
# rustc 1.88.0 (... 2025-...)

If your installed version is older, update:

rustup update stable

Required Rust Targets

Depending on which platforms you want to target, add the appropriate cross-compilation targets:

# iOS (physical devices)
rustup target add aarch64-apple-ios

# iOS Simulator (Apple Silicon)
rustup target add aarch64-apple-ios-sim

# iOS Simulator (Intel)
rustup target add x86_64-apple-ios

# Android (most common)
rustup target add aarch64-linux-android

# Android (emulator on Intel/AMD)
rustup target add x86_64-linux-android

# Android (older devices)
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android

Tip: You do not need to add all targets right away. Start with the platform you plan to develop on first. The water doctor --fix command can install missing targets automatically.

Step 2: Editor setup

Any editor with Rust support will work. A common starting point is Visual Studio Code with the following extensions:

Other popular choices include RustRover (JetBrains), Zed, and Helix.

Step 3: Platform toolchains

Install the tools for every platform you intend to target. Remember, you only need one to get started – you can always add the others later.

Apple (iOS / macOS)

Requirements:

  • macOS (required – Apple development tools only run on Mac)
  • Xcode 16 or later
  • Xcode Command Line Tools

Install Xcode from the Mac App Store, then install the command-line tools:

xcode-select --install

Verify the installation:

xcodebuild -version
# Xcode 16.x
# Build version ...

xcrun simctl list devices available
# Lists available simulators

You also need to accept the Xcode license if you have not already:

sudo xcodebuild -license accept

Warning: If you skip the license acceptance, builds will fail with a cryptic error. Save yourself the debugging and run this command now.

Android

Requirements:

  • Android SDK (API level 24+)
  • Android NDK (version 26+)
  • Java Development Kit (JDK 17+)

The easiest path is to install Android Studio, which bundles the SDK, NDK, and an emulator. After installation:

  1. Open Android Studio and go to Settings > Languages & Frameworks > Android SDK.
  2. Under the SDK Platforms tab, install at least one recent API level (e.g. Android 14, API 34).
  3. Under the SDK Tools tab, ensure NDK (Side by side) and Android SDK Command-line Tools are installed.

Set the required environment variables. Add these to your shell profile (~/.zshrc, ~/.bashrc, etc.):

export ANDROID_HOME="$HOME/Library/Android/sdk"  # macOS default
# export ANDROID_HOME="$HOME/Android/Sdk"        # Linux default

export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/<version>"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH"

Verify:

adb --version
# Android Debug Bridge version ...

emulator -list-avds
# Lists available Android Virtual Devices

If you do not have an AVD yet, create one through Android Studio’s Device Manager or via the command line:

avdmanager create avd -n Pixel_9 -k "system-images;android-34;google_apis;arm64-v8a"

Linux (GTK4)

Requirements:

  • GTK4 development libraries (4.x)
  • pkg-config

On Debian/Ubuntu:

sudo apt install libgtk-4-dev pkg-config

On Fedora:

sudo dnf install gtk4-devel pkg-config

On Arch Linux:

sudo pacman -S gtk4 pkgconf

On macOS (for running GTK4 apps locally):

brew install gtk4 pkg-config

Verify:

pkg-config --modversion gtk4
# 4.x.x

Step 4: Install the Water CLI

Clone the WaterUI repository and install the CLI:

git clone https://github.com/water-rs/waterui.git
cd waterui
cargo install --path cli --locked

Verify the installation:

water --help

Step 5: Verify with water doctor

This is the moment of truth. Run the doctor command to check your entire toolchain at once:

water doctor

You will see output like:

Checking development environment...
  ✓ Rust toolchain
  ✓ Xcode Command Line Tools
  ✓ iOS Simulator SDK
  ✓ Android SDK
  ✓ Android NDK
  ✓ GTK4
  ✓ sccache

All checks passed!

If any checks fail, items marked [fixable] can be repaired automatically:

water doctor --fix

For items that cannot be auto-fixed, the doctor output includes instructions for manual installation.

Tip: Bookmark this command. It is your first line of defense whenever something goes wrong with your build environment.

Step 6: Discover your devices

See which simulators, emulators, and physical devices are available:

water devices

Example output:

iOS Simulators
  ● iPhone 16 Pro (A1B2C3D4-...)
  ○ iPad Air (E5F6G7H8-...)

Android
  ○ Pixel_9 (emulator)

macOS
  ● Current Machine

Booted/connected devices are marked with a filled circle. Devices that need to be launched first are marked with an open circle. The water run command handles launching automatically.

Step 7: Create your first project

With everything in place, confirm the full pipeline works end-to-end. Create a playground project and run it:

water create "Hello World" --mode playground
cd hello-world
water run --platform macos

If you have an iOS Simulator available:

water run --platform ios

Or Android:

water run --platform android

You should see the default WaterUI demo application running on your chosen platform. The demo includes a counter, a form, controls, and a progress indicator – all rendered with native platform widgets.

Tip: If the app launches successfully, your environment is fully set up. If it does not, check the terminal output for errors and re-run water doctor to diagnose the issue.

Optional: build caching with sccache

WaterUI projects cross-compile for multiple architectures, which means you often recompile the same crates. sccache caches compilation results and can significantly speed up rebuilds.

# macOS
brew install sccache

# Linux
cargo install sccache

The Water CLI detects sccache automatically and uses it when available. If it is not found, you will see a warning:

  ⚠ sccache not found. Build efficiency may be reduced. Install with: brew install sccache

Troubleshooting

“No iOS simulators available”

You need to download a simulator runtime in Xcode:

  1. Open Xcode.
  2. Go to Settings > Platforms.
  3. Click the + button and download an iOS Simulator runtime.

“Android emulator not found”

Ensure ANDROID_HOME is set correctly and that you have at least one AVD created. See the Android section above.

“GTK4 not found”

Install the GTK4 development libraries for your operating system. See the Linux section above. On macOS, brew install gtk4 is required if you want to use the GTK4 backend.

Permission denied when running water

Make sure the cargo bin directory is on your PATH:

export PATH="$HOME/.cargo/bin:$PATH"

Next steps

Your development environment is ready. Continue to Your First App to build a counter application step by step and learn the fundamental WaterUI patterns along the way.

Your First App

In this chapter, you will:

  • Build a counter application from scratch
  • Learn how views, layout stacks, and reactive state work together
  • Add buttons with actions that update the UI automatically
  • Run the same code on macOS, iOS, Android, and Linux

There is no better way to learn a UI framework than to build something with it. In this chapter, you will create a counter app – simple enough to understand in one sitting, but rich enough to introduce the core WaterUI patterns you will use in every project: views, layout, reactive state, and user interaction.

Create the project

Scaffold a playground project:

water create "Counter" --mode playground
cd counter

This generates the following files:

counter/
  Cargo.toml
  Water.toml
  src/lib.rs
  assets/
    raw/
    images/

Open src/lib.rs in your editor. The template includes a full demo app, but you will replace it with your own code, building it up step by step.

Step 1: a minimal view

Replace the contents of src/lib.rs with the simplest possible WaterUI app:

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

fn main() -> impl View {
    "Hello, WaterUI!"
}

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

Here is what each piece does:

  • use waterui::prelude::* imports all commonly used items: View, Environment, Binding, layout functions, control constructors, macros, and more.
  • fn main() -> impl View is the root view function. It returns any type that implements the View trait. A &'static str implements View, so a bare string literal is a valid view that renders as text.
  • pub fn app(env: Environment) -> App is the application entry point. The native backends call this function through the generated FFI companion crate to obtain the App instance. The Environment is passed in by the backend and carries platform-provided services such as theme information.

You do not need to write waterui_ffi::export!() yourself. In playground mode, the CLI generates an FFI companion crate behind the scenes that calls your app(env) function and exports the C entry points the native backends expect. In app mode, the same companion lives at backends/ffi/.

Run it:

water run --platform macos

You should see a window displaying “Hello, WaterUI!” rendered with native platform text.

Tip: Try changing the string to something else and re-running water run. Each invocation rebuilds the project incrementally.

Step 2: using the text view

String literals work, but the text() function and text! macro give you control over styling and reactive interpolation. Use text() for static strings and text! whenever the displayed value depends on a reactive binding.

fn main() -> impl View {
    text("Hello, WaterUI!").bold().title()
}

The text() function creates a Text view. Method calls chain to configure it:

  • .bold() sets the font weight to bold.
  • .title() selects the platform’s title font preset.
  • Other options include .size(24.0), .italic(true), .underline(true), .headline(), .caption(), and more.

Now that you can display styled text, arrange multiple views together.

Step 3: layout with vstack and hstack

A single text view is not much of an app. WaterUI uses stacks to arrange views:

  • vstack((...)) arranges children vertically (top to bottom).
  • hstack((...)) arranges children horizontally (left to right).

Children are passed as a tuple:

fn main() -> impl View {
    vstack((
        text("Counter App").bold().title(),
        "A simple counting application",
    ))
}

vstack accepts a tuple of views. Each element can be a different type – the framework composes them without forcing you to box the children.

Tip: Try nesting an hstack inside a vstack to see how stacks compose. This nesting pattern is how you build complex layouts in WaterUI.

Step 4: adding reactive state

Now for the interesting part. WaterUI uses reactive bindings to manage state. When a binding’s value changes, any view that depends on it updates automatically – no manual refresh calls, no diffing.

Create a binding with one of the typed Binding constructors:

fn main() -> impl View {
    let counter = Binding::i32(0);

    vstack((
        text("Counter App").bold().title(),
        text!("Count: {counter}"),
    ))
}

Key concepts:

  • Binding::i32(0) creates a Binding<i32> initialised to 0. There are typed constructors for the common primitive shapes: Binding::i32, Binding::u32, Binding::f64, Binding::bool. For heap types such as String, use Binding::container(String::new()). There is no Binding::new.
  • text!("Count: {counter}") is the text! macro. It only accepts named placeholders that match a binding in scope (or an explicit alias such as text!("Count: {n}", n = counter)). When counter changes, the text updates automatically.

Important: Do not call .get() on signals directly inside a view body. Doing so reads the value once and breaks reactivity. Instead, use text!, watch(), .map(), or .zip() to create derived signals that track changes.

The display updates, but there is no way to change the count yet. Add some buttons.

Step 5: buttons and actions

A counter needs buttons. The button() function creates a Button view:

pub fn main() -> impl View {
    let counter = Binding::i32(0);

    vstack((
        text("Counter App").bold().title(),
        text!("Count: {counter}"),
        hstack((
            button("Decrement")
                .action(|State(c): State<Binding<i32>>| c.set(c.get() - 1))
                .state(&counter),
            button("Increment")
                .action(|State(c): State<Binding<i32>>| c.set(c.get() + 1))
                .state(&counter),
        )),
    ))
}

Breaking down the button pattern:

  1. button("Increment") creates a button with a text label. The label can be any View, not just a string.
  2. .action(|State(c): State<Binding<i32>>| ...) runs when the button is clicked. Each State<T> parameter extracts the matching injected value from the environment, in the order it was injected.
  3. .state(&counter) injects the counter binding into the button’s environment. Chain multiple .state(...) calls to inject multiple values – each becomes available to the action closure through a State<T> parameter.

Inside the action, c.get() reads the current value and c.set(...) writes a new one. The write triggers the reactive system, which updates the text!("Count: {counter}") view.

Run this and you have a working counter. Click the buttons and watch the count change in real time.

Button styles

Buttons support several visual styles:

// Primary action (filled background)
button("Submit").bordered_prominent().action(|| { /* ... */ });

// Secondary action (bordered)
button("Cancel").bordered().action(|| { /* ... */ });

// Link style (hyperlink appearance)
button("Learn more").link().action(|| { /* ... */ });

// Plain (no background or border)
button("Skip").plain().action(|| { /* ... */ });

Tip: Try changing .bordered_prominent() to .link() on one of your counter buttons to see how the style affects the appearance on your platform.

Async actions

For actions that need to perform asynchronous work, use action_async:

button("Fetch Data")
    .action_async(|| async {
        let data = fetch_from_server().await;
        process(data);
    });

Step 6: adding a spacer

Use spacer() to push views apart within a stack:

pub fn main() -> impl View {
    let counter = Binding::i32(0);

    vstack((
        text("Counter App").bold().title(),
        spacer(),
        text!("Count: {counter}").size(48.0),
        spacer(),
        hstack((
            button("Decrement")
                .bordered()
                .action(|State(c): State<Binding<i32>>| c.set(c.get() - 1))
                .state(&counter),
            spacer(),
            button("Increment")
                .bordered_prominent()
                .action(|State(c): State<Binding<i32>>| c.set(c.get() + 1))
                .state(&counter),
        )),
    ))
}

Spacers are flexible – they expand to fill all remaining space. In this layout:

  • The two spacer() calls in the vstack push the title to the top and the buttons to the bottom, centering the count in between.
  • The spacer() in the hstack pushes the two buttons to opposite edges.

The complete counter app

Here is the full src/lib.rs:

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

pub fn main() -> impl View {
    let counter = Binding::i32(0);

    vstack((
        text("Counter App").bold().title(),
        spacer(),
        text!("Count: {counter}").size(48.0),
        spacer(),
        hstack((
            button("Decrement")
                .bordered()
                .action(|State(c): State<Binding<i32>>| c.set(c.get() - 1))
                .state(&counter),
            spacer(),
            button("Increment")
                .bordered_prominent()
                .action(|State(c): State<Binding<i32>>| c.set(c.get() + 1))
                .state(&counter),
        )),
    ))
    .padding()
}

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

Note the .padding() call at the end – this adds platform-appropriate padding around the entire stack, preventing content from touching the screen edges.

Tip: Try extending this app on your own. Add a “Reset” button that sets the counter back to zero, or make the increment step configurable with a second binding.

Running on different platforms

The same code runs on every supported platform:

# macOS
water run --platform macos

# iOS Simulator
water run --platform ios

# Android
water run --platform android

# Linux (GTK4)
water run --platform linux

Each platform renders the counter using its own native widgets. The buttons look like iOS buttons on iOS, Material buttons on Android, and GTK4 buttons on Linux. You did not write a single line of platform-specific code.

Concepts recap

ConceptWhat you learned
View traitThe fundamental building block. Every UI element implements View.
text() / text!Display text, with optional formatting and reactive interpolation.
vstack() / hstack()Arrange views vertically or horizontally using tuple children.
Binding::i32() etc.Create reactive state. Changes propagate to dependent views automatically.
button()Create interactive buttons with .state() and .action() (extracted via State<T>).
spacer()Flexible space that pushes views apart within stacks.
App::new()Create the application entry point.

Next steps

Continue to Project Structure and Water.toml to see how WaterUI projects are organised, what goes in the Water.toml manifest, and how assets and fonts are managed.

Project structure and Water.toml

In this chapter, you will:

  • Understand how playground and app projects are laid out on disk
  • Learn every section of the Water.toml manifest
  • Discover how assets, fonts, and permissions are managed
  • Know when to switch from playground to app project mode

Every WaterUI project follows a consistent layout. Understanding this structure early will save you time when you need to add assets, configure permissions, or prepare for production. This chapter covers both project modes, the Water.toml and Cargo.toml manifests, and the asset system.

Playground project layout

When you create a project with --mode playground, the on-disk layout is minimal. This is the mode you have been using throughout this tutorial:

my-app/
  Cargo.toml           # Rust crate configuration
  Water.toml           # WaterUI project manifest
  src/
    lib.rs             # Your application code
  assets/
    raw/               # Arbitrary files (JSON, fonts, data)
    images/            # Image resources

The generated native backend projects live outside your project tree, in the global managed cache at:

~/.water/build_cache/<absolute-project-path>/managed_backends/
  apple/               # Generated Apple backend (Swift Package)
  android/             # Generated Android backend (Gradle project)
  ffi/                 # Generated FFI companion crate
  preview_ffi/         # Generated preview wrapper crate

Key characteristics:

  • You only edit Rust files and assets. The native backend projects in the global cache are generated and managed by the CLI.
  • The cache is rebuilt on every water run. Changes to Water.toml (such as adding permissions) flow into the native projects automatically.
  • Backend configuration is not allowed in Water.toml. The [backends] section must be absent for playground projects.
  • Permissions are configured in Water.toml. The [permissions] section is only available in playground mode.

Tip: Playground mode is ideal for learning, prototyping, and following this book’s examples. You do not need to think about native build systems at all. To reclaim disk space across abandoned playgrounds, run water gc build-cache or water clean --global-cache --yes.

App project layout

When you need more control – custom Xcode settings, platform-specific native code, or CI/CD integration – create a project with explicit --backends. The native projects live inside your repository under a backends/ directory:

my-app/
  Cargo.toml
  Water.toml
  src/
    lib.rs
  assets/
    raw/
    images/
  backends/
    apple/             # Swift Package (checked in)
      Package.swift
      Sources/
      ...
    android/           # Gradle project (checked in)
      app/
      build.gradle.kts
      ...
    gtk4/              # GTK4 backend crate (checked in)
    ffi/               # FFI companion crate (checked in)

Key characteristics:

  • Backend directories are version-controlled. You can customise native build settings, add platform-specific code, and manage backend dependencies.
  • The [backends] section in Water.toml tracks which backends are configured and their per-backend settings.
  • Permissions are managed in native projects directly (Info.plist for Apple, AndroidManifest.xml for Android).

Now let’s look at the configuration files that tie everything together.

Water.toml

The Water.toml file is the central configuration for a WaterUI project. It is a TOML file with the following sections.

[package]

The [package] section defines the application identity:

[package]
type = "playground"          # or "app"
name = "My Application"
bundle_identifier = "dev.waterui.myapp"

Fields:

FieldTypeDescription
type"playground" or "app"Project mode. Playground auto-manages backends; app requires explicit backend directories.
namestringHuman-readable application name displayed in the OS.
bundle_identifierstringUnique identifier (reverse domain notation). Used for iOS bundle ID and Android application ID.
assets_pathstringPath to the assets directory relative to project root. Defaults to "assets". Omitted from the file when it equals the default.
accessorybooleanWhen true, builds a headless (accessory) app on macOS – no dock icon, no menu bar. Defaults to false. Omitted from the file when false.

[backends]

The [backends] section is only present in app (type = "app") projects. It is populated when you run water create with --backends, or when you add a backend to an existing project with water backend add <name>.

[backends]
path = "backends"            # Base path for backend directories (relative to project root)

[backends.apple]
# Apple backend configuration (auto-generated)

[backends.android]
# Android backend configuration (auto-generated)

[backends.gtk4]
# GTK4 backend configuration (auto-generated)

For playground projects, this section must be absent. The CLI stores backend data in the global build cache instead.

Warning: Adding a [backends] section to a playground project or a [permissions] section to an app project causes the CLI to reject the manifest with an error. Each mode has its own configuration approach.

waterui_path

For framework developers who work on WaterUI itself, the waterui_path field points to a local checkout of the WaterUI repository:

waterui_path = "../waterui"

When set, all backends use this local path instead of published crate versions. The CLI sets this automatically when you create a project with --waterui-path.

[permissions]

The [permissions] section is only available in playground mode. It provides a declarative way to request native platform permissions without editing native project files:

[permissions.camera]
enable = true
description = "Required for barcode scanning"

[permissions.location]
enable = true
description = "Used to show nearby stores"

[permissions.microphone]
enable = true
description = "Needed for voice recording"

Each permission entry has two fields:

FieldTypeDescription
enablebooleanWhether to request this permission.
descriptionstringA user-facing explanation of why the permission is needed. This text appears in the system permission dialog.

When water run rebuilds a playground project, it reads these permissions and injects the appropriate entries into Info.plist (Apple) and AndroidManifest.xml (Android) automatically.

For app projects (type = "app"), permissions are managed directly in the native project files. Attempting to use [permissions] in an app project causes the CLI to reject the manifest with an error.

Note: Always write clear, user-facing descriptions for permissions. Vague descriptions like “We need this” will get your app rejected from app stores. Explain why the permission is needed in terms the user understands.

Cargo.toml

The Cargo.toml file is a standard Rust crate manifest. When water create scaffolds a project, it generates a Cargo.toml that:

  • Defines a plain library crate (crate-type = ["lib"]). The CLI generates a separate FFI companion crate that handles staticlib/cdylib exports, so your user crate stays a normal Rust library.
  • Depends on waterui with the assets, media, webview, and flow-markdown features enabled on native targets.
  • Uses Rust edition 2024.

A minimal generated Cargo.toml looks like:

[package]
name = "counter"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["lib"]

[dependencies]
waterui = { version = "0.2", default-features = false }

[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
waterui = { version = "0.2", default-features = false, features = ["assets", "media", "webview", "flow-markdown"] }

[features]
dev = ["waterui/dynamic_linking"]

Font management

Custom fonts are declared in Cargo.toml metadata so the build system can bundle them into native projects:

[[package.metadata.waterui.assets.font]]
name = "Inter"
local_path = "assets/raw/Inter-Variable.ttf"

[[package.metadata.waterui.assets.font]]
name = "JetBrainsMono"
local_path = "assets/raw/JetBrainsMono-Regular.ttf"

Each entry declares a font family name and either a local_path (relative to the crate root) or a remote_path URL the CLI downloads on demand. The Water CLI reads this metadata during packaging and copies the font files into the appropriate locations for each native backend. Built-in font names such as Inter, Roboto, JetBrainsMono, FiraCode, and SourceCodePro resolve from the registry automatically when neither path is provided.

Tip: Place local font files in assets/raw/ and declare them here. WaterUI handles bundling them into every platform’s app package automatically – no need to configure Xcode or Gradle font resources manually.

Asset Directory Layout

WaterUI enforces a strict asset layout to ensure cross-platform compatibility. All assets live under the directory specified by package.assets_path (default: assets/).

assets/
  raw/             # Arbitrary files: JSON, fonts, data files, etc.
    data.json
    Inter-Variable.ttf
  images/          # Image resources
    logo.png
    [email protected]

assets/raw/

Files placed here are bundled as-is into the application package. Use this for:

  • Custom fonts (.ttf, .otf)
  • Data files (.json, .csv, .toml)
  • Shaders (.wgsl, .metal)
  • Any other non-image resource

assets/images/

Image files placed here are processed by the asset pipeline. The pipeline handles:

  • Resolution variants (@2x, @3x suffixes)
  • Format conversion as needed per platform

The Application Entry Point

Every WaterUI application requires three things in src/lib.rs. You have seen all three in the previous chapter, but let’s formalise them here.

1. The Root View Function

A function that returns impl View:

fn main() -> impl View {
    text("Hello, World!")
}

The name main is a convention, not a requirement. You can name it anything.

2. The App Constructor

A public function named app that takes an Environment and returns an App:

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

The App struct holds the application’s windows and environment. The simplest form creates a single window with a default title. You can customise:

pub fn app(env: Environment) -> App {
    App::new(main, env).title("My Counter App")
}

For multi-window applications:

use waterui::app::App;
use waterui::prelude::*;
use waterui::window::Window;
use waterui::window::WindowState;

fn main_view() -> impl View { text("Main") }
fn settings_view() -> impl View { text("Settings") }
pub fn app(env: Environment) -> App {
    App::new_with_windows(
        [
            Window::new("Main", Binding::container(WindowState::Normal), main_view),
            Window::new("Settings", Binding::container(WindowState::Normal), settings_view),
        ],
        env,
    )
}

3. The generated FFI companion

Your user crate stops at app(env). The CLI generates a separate FFI companion crate in the managed backend cache for playground projects, or in backends/ffi/ for app projects. That companion depends on your crate and waterui-ffi, calls the export macro, and exposes the C-ABI functions native backends load.

Do not add waterui_ffi::export!() to src/lib.rs; it belongs in the generated companion crate, not in your application crate.

Putting It All Together

A complete, well-structured project looks like this:

my-app/
  Cargo.toml
  Water.toml
  src/
    lib.rs             # Entry point: main(), app(), export!()
    views/
      mod.rs           # View module declarations
      home.rs          # Home screen view
      settings.rs      # Settings screen view
  assets/
    raw/
      config.json
    images/
      logo.png
# Water.toml
[package]
type = "playground"
name = "My App"
bundle_identifier = "dev.waterui.myapp"
# Cargo.toml
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["lib"]

[dependencies]
waterui = { version = "0.2", default-features = false }

[target."cfg(not(target_arch = \"wasm32\"))".dependencies]
waterui = { version = "0.2", default-features = false, features = ["assets", "media", "webview", "flow-markdown"] }

[features]
dev = ["waterui/dynamic_linking"]
// src/lib.rs
use waterui::prelude::*;
use waterui::app::App;

mod views;

fn main() -> impl View {
    views::home()
}

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

Playground vs Full: When to Switch

Start with playground mode for:

  • Learning and experimentation
  • Prototyping ideas
  • Small personal projects
  • Following this book’s examples

Switch to full project mode when you need:

  • Custom native build settings
  • Platform-specific native code (Swift/Kotlin extensions)
  • CI/CD integration with native build tools
  • App Store or Play Store submission
  • Fine-grained control over backend dependencies

To move from playground to app mode, create a fresh app project with the backends you want and move your src/, assets/, and manifest settings over. If you intentionally want to keep generated native projects, copy them from the managed cache at ~/.water/build_cache/<absolute-project-path>/managed_backends/, set type = "app", and add a matching [backends] section.

Tip: There is no rush to switch. Many developers stay in playground mode well into development and only convert when they are ready to customise native settings for release.

What’s Next

With a solid understanding of how WaterUI projects are structured, you are ready to dive into the framework’s core concepts. In The View System, you will learn how the View trait works, how views compose, and how the framework turns your Rust types into platform-native UI.

The view system

In this chapter, you will:

  • Understand the View trait and how WaterUI builds UIs from composable pieces
  • Learn to create views using functions, structs, and built-in types
  • Discover how AnyView solves Rust’s type system challenges for dynamic UIs
  • See how raw views and composite views work together to form the rendering tree

Every piece of UI you see on screen – a text label, a button, a card, an entire page – is a View in WaterUI. Views are composable, declarative descriptions of what the screen should look like. You describe what you want, and the framework figures out how to render it.

If you have used SwiftUI or Jetpack Compose, this will feel familiar. If not, do not worry – the concept is straightforward, and this chapter will walk you through it from the ground up.

The View trait

At the heart of WaterUI lies a single trait:

pub trait View: 'static {
    fn body(self, env: &Environment) -> impl View;
}

A View consumes itself and, given an Environment, produces another View. The framework calls body() recursively until it reaches a raw view – a leaf node that the native backend knows how to render (such as Text, Button, or Color).

Key properties:

  • Consuming: body takes self by value. Views are cheap descriptors, created and consumed during rendering.
  • Contextual: The Environment carries dependency-injected values such as theme tokens, locale, and your own configuration.
  • Recursive: Composite views return other views, which themselves have bodies. The recursion terminates at raw views.
  • 'static bound: Views own all their data. No borrowed references, which keeps the lifecycle simple.

Note: 'static does not mean “lives forever”. It means views cannot hold temporary references. Wrap shared mutable data in a Binding.

Function views

The simplest way to create a view is with a plain function. Any FnOnce() -> V where V: View automatically implements View:

use waterui::prelude::*;

fn greeting() -> impl View {
    "Hello, World!" // &'static str implements View
}

Function views are the recommended starting point. They compose naturally and work with Rust’s type inference:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn counter(count: Binding<i32>) -> impl View {
    vstack((
        text!("Count: {count}"),
        button("Increment")
            .action(|State(count): State<Binding<i32>>| count.set(count.get() + 1))
            .state(&count),
    ))
}

text!("Count: {count}") captures the count binding from scope and rebuilds the rendered text whenever it changes. There is no need to wrap text construction in Dynamic::watch – the macro already takes care of subscribing to the signal.

Tip: Start with function views. Most components never need to become structs.

Struct views

When a component needs named configuration fields or builder-pattern ergonomics, define it as a struct:

use waterui::prelude::*;
use waterui::widget::condition::when;

struct ProfileCard {
    name: Binding<String>,
    avatar_url: Str,
    show_bio: bool,
}

impl View for ProfileCard {
    fn body(self, env: &Environment) -> impl View {
        let Self { name, avatar_url, show_bio } = self;
        vstack((
            text!("{name}"),
            when(show_bio, || text!("Bio goes here")),
        ))
    }
}

Struct views shine when:

  • The component has multiple configuration parameters.
  • You want a clear, self-documenting API surface.
  • The component is reused across many call sites with varying configurations.

Built-in view implementations

You do not always need to define your own views. Several standard types implement View directly:

TypeBehavior
()Empty view (renders nothing). Useful as a placeholder.
&'static str, String, Cow<'static, str>Render as text via Str.
Option<V: View>Renders the inner view if Some, nothing if None.
Result<V: View, E: View>Renders the Ok or Err view.
(V,)Single-element tuple renders the contained view.
FnOnce() -> VCalls the closure and renders the returned view.
Computed<V: View>Re-renders whenever the computed signal emits.

Tip: Option<V> is the simplest way to conditionally render. For full if/elif/else, use when(...).or(...).otherwise(...) from waterui::widget::condition instead of branching to AnyView.

The IntoView trait

IntoView converts arbitrary types into views within a given environment:

pub trait IntoView {
    type Output: View;
    fn into_view(self, env: &Environment) -> Self::Output;
}

Every View automatically implements IntoView (returning itself). The trait is useful for APIs that want to accept “anything that can become a view” while still allowing environment-aware conversions.

The TupleViews trait

When you build layouts, you often want to pass multiple children of different types to a container. TupleViews converts tuples of views (and Vec<V> / [V; N]) into a Vec<AnyView>:

pub trait TupleViews {
    fn into_views(self) -> Vec<AnyView>;
}

It is implemented for tuples up to 15 elements:

use waterui::prelude::*;

vstack((
    text!("Title"),
    button("Click me").action(|| {}),
    Color::red().height(2.0),
))

Layout containers also accept Vec<V> and [V; N] as children of a uniform type:

use waterui::prelude::*;

let items: Vec<_> = (0..5).map(|i| text!("Row {i}").anyview()).collect();
vstack(items)

Note: Tuples allow heterogeneous children (each element can be a different type). Vec and arrays require a single element type, so erase to AnyView if needed.

AnyView: type erasure

Rust requires every branch of an if/match to return the same type. AnyView erases the concrete type so heterogeneous branches can share a return type:

pub struct AnyView(Box<dyn AnyViewImpl>);

Create one with AnyView::new or the .anyview() modifier from ViewExt:

use waterui::prelude::*;

fn conditional_view(show_detail: bool) -> AnyView {
    if show_detail {
        text!("Detailed information here").anyview()
    } else {
        text!("Summary").anyview()
    }
}

AnyView::new automatically unwraps a nested AnyView, so wrapping is idempotent. It also supports inspection and downcasting:

use core::any::TypeId;
use waterui::prelude::*;

let view = text("hello").anyview();

assert!(view.is::<waterui::text::Text>());
assert_eq!(view.type_id(), TypeId::of::<waterui::text::Text>());

if let Some(text_view) = view.downcast_ref::<waterui::text::Text>() {
    let _ = text_view;
}

Tip: Prefer when(...).otherwise(...) over if/else with .anyview(). AnyView incurs a heap allocation and dynamic dispatch – reach for it only when you really do need heterogeneous storage.

Raw views vs composite views

WaterUI distinguishes two categories of views.

Raw views (leaf nodes)

Raw views are recognized by the backend and mapped to platform widgets. Their body() wraps the value in Native<T>, which the renderer intercepts before recursion. Examples: Str, Color, Spacer, Divider, and configuration structs like ButtonConfig.

The raw_view! macro implements both NativeView and View for a type:

// Default stretch axis (None) -- content-sized
raw_view!(MyCustomLeaf);

// Explicit stretch axis -- fills available space
raw_view!(Color, StretchAxis::Both);
raw_view!(Spacer, StretchAxis::MainAxis);

Composite views

Composite views have a meaningful body() that returns other views. The framework calls body() to expand them, recursing until it reaches raw views. Every function view and every struct that implements View manually is composite.

Tip: Think HTML: raw views are native elements (<div>, <input>, <img>), composite views are your custom components.

ConfigurableView and ViewConfiguration

Some raw views support hook-based theming through ConfigurableView and ViewConfiguration. This is how WaterUI lets you restyle built-in components without modifying their source.

pub trait ConfigurableView: View {
    type Config: ViewConfiguration;
    fn config(self) -> Self::Config;
}

pub trait ViewConfiguration: 'static {
    type View: View;
    fn render(self) -> Self::View;
}

When a configurable view’s body() runs:

  1. It extracts its Config.
  2. It looks up Hook<Config> in the Environment.
  3. If a hook is present, the hook returns the custom view.
  4. Otherwise the default native rendering is used.

A theme plugin installs hooks for ButtonConfig, ToggleConfig, etc., and the rest of your app stays untouched.

The configurable! macro

configurable! generates the boilerplate for a hookable raw view:

// Basic -- content-sized view
configurable!(Button, ButtonConfig);

// With explicit stretch axis
configurable!(Slider, SliderConfig, StretchAxis::Horizontal);

// With dynamic stretch axis based on configuration
configurable!(Progress, ProgressConfig, |config| match config.style {
    ProgressStyle::Linear => StretchAxis::Horizontal,
    ProgressStyle::Circular => StretchAxis::None,
});

It generates the wrapper struct, the ConfigurableView and ViewConfiguration impls, the NativeView impl, the View impl that consults Hook<Config>, and the From<Config> conversion.

Note: You will rarely call configurable! in application code. It is primarily for building component libraries or custom backends.

Putting it together

Here is a small example combining function views, struct views, conditionals, and reactive state:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn header(title: &'static str) -> impl View {
    text(title)
        .padding()
        .background(Color::blue())
        .foreground(Color::srgb(255, 255, 255))
}

struct ItemRow {
    label: Str,
    count: Binding<i32>,
    highlighted: bool,
}

impl View for ItemRow {
    fn body(self, env: &Environment) -> impl View {
        let Self { label, count, highlighted } = self;
        hstack((
            text(label),
            Spacer::flexible(),
            text!("{count}"),
        ))
        .padding()
        .background(when(highlighted, || Color::yellow().with_opacity(0.3)))
    }
}

fn shopping_list() -> impl View {
    let apples = Binding::i32(3);
    let bananas = Binding::i32(7);

    vstack((
        header("Shopping List"),
        ItemRow { label: "Apples".into(),  count: apples,  highlighted: true  },
        ItemRow { label: "Bananas".into(), count: bananas, highlighted: false },
    ))
}

Try adding an “Oranges” row and watch the layout pick it up automatically.

Next up: reactive state. The next chapter introduces Binding, Computed, and the signal combinators that drive UI updates.

Reactive state

In this chapter, you will:

  • Learn how Binding<T> gives your views mutable, reactive state
  • Understand how Computed<T> and signal combinators derive new values from existing ones
  • Use macros like s! and text! for reactive string formatting and localization
  • Discover List<T> for reactive collections and #[derive(Project)] for struct decomposition
  • Master the “golden rule” of reactivity that prevents subtle bugs

Imagine a counter app. The user taps a button and the number on screen updates instantly – no manual DOM manipulation, no message passing, no diffing algorithm. You change the data; the UI follows.

WaterUI delivers that through waterui::reactive, a fine-grained reactivity system re-exported by the top-level waterui crate. It provides signals, bindings, collections, and combinators so your views update automatically when data changes. This chapter walks through every reactive primitive you will use day to day.

The Signal trait

At the foundation of WaterUI reactivity is the Signal trait:

pub trait Signal: Clone + 'static {
    type Output;
    type Guard;

    fn get(&self) -> Self::Output;
    fn watch(&self, watcher: impl Fn(Context<Self::Output>) + 'static) -> Self::Guard;
}
  • get() returns the current value synchronously.
  • watch() registers a callback that fires whenever the value changes. It returns a guard – dropping the guard unsubscribes the watcher.
  • Context<T> wraps the new value along with optional metadata (e.g., animation hints). Call ctx.into_value() to extract the raw value.

Every reactive type in waterui::reactive implements Signal. This uniform interface is what makes the combinator system work – any signal can be mapped, zipped, filtered, or composed with any other signal.

Binding<T>: mutable reactive state

Binding<T> is the primary mutable state container. It is readable as a Signal and writable. Think of it as a reactive variable: read the current value, write a new value, and watchers get notified automatically.

Creating bindings

use waterui::prelude::*;
use waterui::Str;

// Typed constructors for primitives
let count = Binding::i32(0);
let ratio = Binding::f64(3.14);
let flag = Binding::bool(true);

// Container constructor for complex types
let name = Binding::container(String::from("Alice"));
let title = Binding::container(Str::from("Welcome"));

// Default value
let items: Binding<Vec<String>> = Binding::default();

Use the typed constructors (Binding::i32, Binding::u32, Binding::i64, Binding::u64, Binding::isize, Binding::usize, Binding::f32, Binding::f64, Binding::bool) for primitives, and Binding::container(value) for everything else (String, Str, Vec<T>, Option<T>, your own types).

Reading values

use waterui::prelude::*;

let count = Binding::i32(10);
let current = count.get(); // 10

Writing values

use waterui::prelude::*;

let count = Binding::i32(0);

// Direct set
count.set(42);

// Set with Into conversion
let name = Binding::container(String::from("Alice"));
name.set_from("Bob"); // &str -> String automatically

// Arithmetic operations on numeric bindings
count.add_assign(5);   // count += 5
count.sub_assign(2);   // count -= 2
count.mul_assign(3);   // count *= 3
count.div_assign(2);   // count /= 2
count.rem_assign(3);   // count %= 3

// Bitwise operations on integer bindings
count.bitand_assign(0xFF);
count.bitor_assign(0x10);
count.bitxor_assign(0x01);
count.shl_assign(2);
count.shr_assign(1);

// Append to a string-like or vec-like binding
let text = Binding::container(String::from("Hello"));
text.append(" World"); // "Hello World"

Mutating In Place

For complex mutations, use with_mut or get_mut:

let items = Binding::container(vec!["a".to_string(), "b".to_string()]);

// with_mut -- preferred, avoids extra clone for Container bindings
items.with_mut(|vec| {
    vec.push("c".into());
    vec.sort();
});

// get_mut -- returns a guard that writes back on drop
*count.get_mut() += 10; // modify and auto-commit

// IMPORTANT: Do NOT bind get_mut() to `let _`
// let _ = count.get_mut(); // This keeps the guard alive until scope end!
// Instead, use the one-liner pattern above.

Warning: Be careful with get_mut(). The returned guard writes the value back when it is dropped. If you accidentally bind it to a variable, the write-back is delayed until the variable goes out of scope, which can cause surprising behavior.

The with_mut method is more efficient for Container-backed bindings because it avoids an intermediate clone.

take()

Extract the value and replace it with the default:

let name = Binding::container("hello".to_string());
let taken = name.take(); // taken == "hello", name is now ""

Now that you know how to read and write bindings, let’s look at the specialized methods available for common types.

Boolean Bindings

Binding<bool> has specialized methods that make working with toggles and flags ergonomic:

let dark_mode = Binding::bool(false);

dark_mode.toggle();     // false -> true
let light = dark_mode.reverse(); // Binding<bool> that is always the opposite

// Conditional selection
let theme = dark_mode.bidirectional_select("dark".to_string(), "light".to_string());
// theme.get() == "dark" when dark_mode is true

// Produce Option from bool
let username = dark_mode.then("admin".to_string());
// Some("admin") when true, None when false

// Logical NOT via operator
let enabled = !dark_mode; // same as dark_mode.reverse()

Option Bindings

Binding<Option<T>> provides unwrapping helpers so you do not have to manually match on Some/None:

let maybe_name = Binding::container::<Option<String>>(None);

// Unwrap with default
let name = maybe_name.unwrap_or("Anonymous".to_string());
let name = maybe_name.unwrap_or_default();
let name = maybe_name.unwrap_or_else(|| generate_name());

// Check equality through Option
let is_alice = maybe_name.some_equal_to("Alice".to_string());

Setting a value on the unwrapped binding wraps it in Some automatically.

Numeric Bindings

For PartialOrd types:

let volume = Binding::container(0.5f32);

// Only accept values in range (reject out-of-range sets)
let safe = volume.range(0.0..=1.0);

// Clamp values to range (out-of-range values clamped to bounds)
let clamped = volume.clamp(0.0..=1.0);

For Signed types:

let number = Binding::i32(10);

let sign = number.sign();      // Binding<bool>: true if non-negative
let neg = number.negate();     // Binding<i32>: always the negation
let neg2 = -number;            // operator syntax for negate()

Tip: Use .range() for validation (silently rejects bad values) and .clamp() for correction (forces values into bounds). A volume slider, for example, would typically use .clamp(0.0..=1.0).

Bidirectional Mappings

Sometimes you need a derived binding that can be written to as well as read. Binding::mapping creates a two-way derived binding:

let celsius = Binding::f64(0.0);

let fahrenheit = Binding::mapping(
    &celsius,
    |c| c * 9.0 / 5.0 + 32.0,        // getter: celsius -> fahrenheit
    |binding, f| binding.set((f - 32.0) * 5.0 / 9.0), // setter: fahrenheit -> celsius
);

fahrenheit.set(212.0);
assert_eq!(celsius.get(), 100.0);

Try setting fahrenheit to 32.0 and check what celsius.get() returns.

Filtering

Create a binding that rejects invalid values:

let age = Binding::i32(25);
let valid_age = age.filter(|&a| a >= 0 && a <= 150);
valid_age.set(-1); // silently ignored
assert_eq!(age.get(), 25); // unchanged

Condition and Equality

let score = Binding::i32(85);

// Condition: arbitrary predicate -> Binding<bool>
let is_passing = score.condition(|&s| s >= 60);

// Equal to a specific value -> Binding<bool>
let is_perfect = score.equal_to(100);

Computed<T>: Derived Read-Only State

While Binding is for state you own and modify, Computed is for values you derive from other signals. It is a type-erased, read-only signal that wraps any Signal implementation behind a Box<dyn ...>:

pub struct Computed<T>(Box<dyn ComputedImpl<Output = T>>);

Create computed values from other signals:

let count = Binding::i32(5);

// From a binding (zero-cost conversion)
let computed: Computed<i32> = count.computed();

// Constant computed (never changes)
let always_42 = Computed::constant(42);

// Default computed
let zero: Computed<i32> = Computed::default(); // wraps 0

Computed<V: View> also implements View directly – it watches itself and dynamically re-renders whenever the inner view changes.

Note: Computed is useful when you need to store a signal in a struct field or pass it across an API boundary where the concrete signal type would be inconvenient. In most cases, you can work with concrete signal types directly.

SignalExt Combinators

The SignalExt trait is automatically available on all Signal types. It provides a rich set of combinators for deriving new signals – similar to how iterator adapters work in Rust’s standard library.

Transforming: map

The most fundamental combinator. It creates a new signal whose value is derived from another:

let count = Binding::i32(5);
let doubled = count.map(|n| n * 2);
assert_eq!(doubled.get(), 10);

count.set(10);
assert_eq!(doubled.get(), 20);

Combining: zip

When you need a value that depends on two signals, use zip:

let width = Binding::container(100.0f32);
let height = Binding::container(50.0f32);

let area = width.zip(&height).map(|(w, h)| w * h);
assert_eq!(area.get(), 5000.0);

zip creates a signal that emits whenever either input changes.

Type Conversion: map_into

let count = Binding::i32(42);
let as_i64 = count.map_into::<i64>();

Side Effects: inspect

let value = Binding::i32(0);
let inspected = value.inspect(|v| tracing::debug!("Value changed to {v}"));

inspect runs a side-effect function on each value but passes the original value through unchanged.

Deduplication: distinct

let noisy = Binding::i32(5);
let quiet = noisy.distinct(); // only emits when value actually changes

Tip: Use distinct() after expensive map() operations to avoid redundant downstream updates when the mapped result has not actually changed.

Caching: cached

let expensive = count.map(|n| heavy_computation(n));
let cached_result = expensive.cached(); // memoizes the last value

Type Erasure: computed

let signal = count.map(|n| n * 2);
let erased: Computed<i32> = signal.computed();

Comparison Helpers

These produce boolean signals from numeric or comparable values:

let score = Binding::i32(85);

let is_100 = score.equal_to(100);          // Signal<Output = bool>
let is_high = score.condition(|s| *s > 90); // arbitrary predicate
let above_50 = score.gt(50);               // greater than
let below_50 = score.lt(50);               // less than
let at_least_60 = score.ge(60);            // greater or equal
let at_most_90 = score.le(90);             // less or equal

Boolean Combinators

Combine boolean signals with familiar logical operations:

let logged_in = Binding::bool(true);
let is_admin = Binding::bool(false);

let not_logged = logged_in.not();
let can_edit = logged_in.and(&is_admin);
let can_view = logged_in.or(&is_admin);

// Conditional values
let badge = is_admin.then_some("Admin");    // Signal<Output = Option<&str>>
let role = is_admin.select("admin", "user"); // Signal<Output = &str>

Numeric Combinators

let temp = Binding::i32(-5);

let abs_temp = temp.abs();          // 5
let neg_temp = temp.negate();       // 5
let is_pos = temp.is_positive();    // false
let is_neg = temp.is_negative();    // true
let is_zero = temp.is_zero();       // false
let sign = temp.sign();             // false (negative)

Option Combinators

Work with Signal<Output = Option<T>> without unwrapping manually:

let maybe = Binding::container(Some(42i32));

let is_some = maybe.is_some();              // true
let is_none = maybe.is_none();              // false
let value = maybe.unwrap_or(0);             // 42
let value = maybe.unwrap_or_default();      // 42
let value = maybe.unwrap_or_else(|| 99);    // 42
let eq = maybe.some_equal_to(42);           // true

let nested = Binding::container(Some(Some(5i32)));
let flat = nested.flatten();                // Some(5)

let mapped = maybe.map_some(|n| n.to_string());  // Some("42")
let chained = maybe.and_then_some(|n| if n > 0 { Some(n) } else { None });

Result Combinators

let result = Binding::container::<Result<i32, String>>(Ok(42));

let is_ok = result.is_ok();
let is_err = result.is_err();
let ok_val = result.ok();       // Signal<Output = Option<i32>>
let err_val = result.err();     // Signal<Output = Option<String>>
let safe = result.unwrap_or_result(0);
let mapped = result.map_ok(|n| n * 2);
let mapped_err = result.map_err(|e| format!("Error: {e}"));

String Combinators

let text = Binding::container("hello world".to_string());

let empty = text.is_empty();             // false
let len = text.str_len();                // 11
let has_world = text.contains("world");  // true

Timer Combinators

These require the timer feature and are essential for handling rapid user input:

use std::time::Duration;

let rapid_input = Binding::container(String::new());

// Only emit after 300ms of inactivity
let debounced = rapid_input.debounce(Duration::from_millis(300));

// Emit at most once per 100ms
let throttled = rapid_input.throttle(Duration::from_millis(100));

Tip: Use debounce for search-as-you-type (wait until the user stops typing). Use throttle for scroll or resize handlers (limit update frequency).

constant(): Static Signals

For values that never change but need to participate in the signal graph:

use waterui::reactive::constant;

let tax_rate = constant(0.08);
let price = Binding::f64(100.0);

let total = price.zip(&tax_rate).map(|(p, r)| p * (1.0 + r));
assert_eq!(total.get(), 108.0);

A Constant<T> implements Signal but its watch() is a no-op – watchers are never notified because the value never changes. This makes it zero-overhead in the reactive graph.

Lazy: Deferred Constants

For expensive constant computations that should only run on first access:

use waterui::reactive::constant::Lazy;

let config = Lazy::new(|| {
    // Expensive computation, runs only once
    load_config_from_disk()
});

// First call computes and caches; subsequent calls return cached value
let value = config.get();

The s! Macro: Reactive String Formatting

Building formatted strings from multiple reactive values is a common need. The s! macro creates a signal that produces a formatted String, automatically capturing reactive variables from scope:

let name = Binding::container("Alice".to_string());
let age = Binding::i32(30);

// Named variable capture -- variables are found by name in scope
let greeting = s!("Hello {name}, you are {age} years old");
// greeting is a Signal<Output = String> that updates when name or age change

// Positional arguments
let msg = s!("Value: {}", count);

The macro supports up to 4 reactive variables. It automatically zips and maps them, producing a signal that re-formats whenever any input changes.

Rules:

  • Named placeholders like {name} are auto-captured from scope.
  • Positional placeholders like {} require explicit arguments.
  • You cannot mix named and positional placeholders in the same call.

Note: s! produces a Signal<Output = String>. If you need a Text view, use text! instead.

The text! Macro: Localized Reactive Text

The text! macro creates a localized Text view with full i18n support:

// Simple text -- looked up in i18n/*.toml files
text!("Hello, World!")

// With reactive placeholders
let name = Binding::container("Alice".to_string());
text!("Hello, {name}")

// Plural support -- {#count} marks the plural source
let count = Binding::i32(3);
text!("I have {#count} apple")
// English: "I have 3 apples" (other)
// English: "I have 1 apple" (one)

// Context disambiguation
text!("Right" @ "direction")  // different from text!("Right" @ "correct")

// Explicit binding
text!("Hello, {name}", name = get_current_user())

Translation files are TOML in the i18n/ directory:

# i18n/en.toml
"Hello, World!" = "Hello, World!"
"I have {#count} apple" = { one = "I have {count} apple", other = "I have {count} apples" }

# i18n/zh.toml
"Hello, World!" = "你好,世界!"
"I have {#count} apple" = { other = "我有{count}个苹果" }

The #[derive(Project)] Macro

When you have a Binding<Struct>, you often need to pass individual fields to different child views. The Project derive macro lets you decompose a struct binding into per-field bindings:

#[derive(Clone, Project)]
struct Person {
    name: String,
    age: u32,
}

let person = Binding::container(Person {
    name: "Alice".to_string(),
    age: 30,
});

// Decompose into individual field bindings
let projected: PersonProjected = person.project();
// projected.name: Binding<String>
// projected.age: Binding<u32>

// Changes propagate bidirectionally
projected.name.set_from("Bob");
projected.age.set(25);
assert_eq!(person.get().name, "Bob");
assert_eq!(person.get().age, 25);

The macro generates a PersonProjected struct with Binding<T> for each field. Each projected binding uses Binding::mapping internally, so changes in either direction are reflected.

Tuples also implement Project natively (up to 14 elements):

let pair = Binding::container((42i32, "hello".to_string()));
let (num, text) = pair.project();
num.set(100);
assert_eq!(pair.get().0, 100);

Tip: Project is especially useful when you have a form that edits a struct. Project the struct into per-field bindings and pass each one to its corresponding input control.

List<T>: Reactive Collections

For dynamic lists – think todo items, chat messages, or search results – Binding<Vec<T>> works but does not tell you what changed. List<T> is a reactive Vec that notifies watchers when its contents change, with fine-grained information about insertions, removals, and reorderings:

use waterui::reactive::collection::{Collection, List};

let items = List::new();

// Mutation methods
items.push("first".to_string());
items.push("second".to_string());
items.insert(1, "middle".to_string());
let removed = items.remove(0);  // returns "first"
let last = items.pop();         // returns Some("middle")
items.clear();
items.sort(); // for Ord types

// Reading
let snapshot: Vec<String> = items.snapshot(); // clone current contents
let len = items.len(); // via Collection trait

// Iteration (clones the list to avoid borrow conflicts)
for item in &items {
    // ...
}

List<T> implements the Collection trait, which supports range-based watching:

// Watch the entire collection
let all_items_guard = items.watch(.., |ctx| {
    let current = ctx.into_value();
    tracing::debug!("Items: {current:?}");
});

// Watch a specific range
let visible_items_guard = items.watch(1..4, |ctx| {
    tracing::debug!("Items 1..4: {:?}", ctx.into_value());
});

List<T> is reference-counted internally – cloning a List creates a shared handle. Modifications through any handle notify all watchers.

Using List with ForEach

To render a reactive list, use ForEach:

use waterui::views::ForEach;
use waterui::Identifiable;

#[derive(Clone)]
struct TodoItem {
    id: i32,
    title: String,
    completed: Binding<bool>,
}

impl Identifiable for TodoItem {
    type Id = i32;

    fn id(&self) -> Self::Id {
        self.id
    }
}

let todos: List<TodoItem> = List::new();
let list_view = ForEach::new(todos, |item| {
    hstack((
        text(item.title),
        Spacer,
        Toggle::new(&item.completed),
    ))
});

Each item must implement Identifiable so the framework can track insertions, removals, and reorderings efficiently.

Warning: Do not use Vec with Dynamic::watch for lists that change frequently. You will lose all diffing benefits and re-render the entire list on every change. Use List<T> with ForEach instead.

The BindingMailbox: Cross-Thread Access

Since Binding<T> is !Send (it uses Rc internally), you cannot send it across threads. If you need to update UI state from a background task – say, after fetching data from a network – the BindingMailbox provides an async interface:

let count = Binding::i32(0);
let mailbox = count.mailbox();

// Send from another task
async fn background_work(mailbox: BindingMailbox<i32>) {
    let current = mailbox.get().await;
    mailbox.set(current + 1).await;

    // Or send a mutation job
    mailbox.handle(|binding| {
        binding.add_assign(10);
    });
}

The mailbox spawns a local task that processes jobs sequentially on the UI thread.

Watching Signals Manually

While most reactive updates happen automatically through the view system, you can watch signals manually for side effects like logging, analytics, or synchronizing with external systems:

let count = Binding::i32(0);

let guard = count.watch(|ctx| {
    let new_value = ctx.into_value();
    tracing::debug!("Count changed to {new_value}");
});

// IMPORTANT: The guard keeps the watcher alive.
// Dropping the guard unsubscribes the watcher.
// Use .retain(guard) to tie it to a view's lifecycle.

To keep a manual watcher alive for the lifetime of a view:

fn my_view(count: Binding<i32>) -> impl View {
    let guard = count.watch(|ctx| {
        tracing::debug!("Count: {}", ctx.into_value());
    });

    text!("Hello")
        .retain(guard) // guard lives as long as the view
}

Feeding Signals into Views

There are several ways to connect reactive state to the UI. Let’s look at each approach and when to use it.

Dynamic::watch

Rebuild a view section whenever a signal changes:

let count = Binding::i32(0);

Dynamic::watch(count, |n| {
    text!("Count: {n}")
})

This is the most general approach – the closure receives the raw value and returns any View.

The text! and s! macros

For text content, the macros handle reactivity automatically:

let name = Binding::container("World".to_string());
text!("Hello, {name}") // updates when name changes

Component-level reactivity

Many WaterUI components accept signals directly:

let is_on = Binding::bool(false);
Toggle::new(&is_on) // Toggle reads and writes the binding

let progress = Binding::f64(0.5);
Slider::new(&progress) // Slider binds to the value

let label = Binding::container("Click me".to_string());
Button::new(text!("{label}")).action(|| { /* action */ })

The Golden Rule

Never call .get() in view body code to feed values into the UI.

This is the single most important rule for working with WaterUI reactivity. When you call .get(), you take a snapshot of the current value. The UI will never update when the signal changes because no watcher was registered:

// BAD -- breaks reactivity
fn bad_view(count: Binding<i32>) -> impl View {
    let n = count.get(); // snapshot! never updates
    text!("Count: {n}")  // n is a plain i32, not a signal
}

// GOOD -- reactive
fn good_view(count: Binding<i32>) -> impl View {
    Dynamic::watch(count, |n| text!("Count: {n}"))
}

// GOOD -- text! captures the binding reactively by name
fn also_good(count: Binding<i32>) -> impl View {
    text!("Count: {count}")
}

Warning: This is the number one source of “my UI is not updating” bugs. If your view is not reacting to state changes, check whether you are accidentally calling .get() in the view body.

Use .get() only in:

  • Event handlers and callbacks (e.g., on_tap(|| { let x = count.get(); ... }))
  • Watcher closures
  • Async tasks
  • Tests

Combining Multiple Signals

Use zip and map to derive values from multiple signals without breaking reactivity:

let first_name = Binding::container("Alice".to_string());
let last_name = Binding::container("Smith".to_string());

// Combine two signals
let full_name = first_name.zip(&last_name)
    .map(|(f, l)| format!("{f} {l}"));

// Use in a view
Dynamic::watch(full_name, |name| text!("{name}"))

For more than two signals, chain zip:

let a = Binding::i32(1);
let b = Binding::i32(2);
let c = Binding::i32(3);

let sum = a.zip(&b).zip(&c)
    .map(|((a, b), c)| a + b + c);

Tip: If you find yourself zipping more than three signals, consider whether they belong in a struct with #[derive(Project)]. It often leads to cleaner code.

Summary Table

TypeReadableWritableUse Case
Binding<T>YesYesPrimary mutable state
Computed<T>YesNoType-erased derived value
Constant<T>YesNoStatic value in signal graph
Lazy<F, T>YesNoDeferred constant computation
Map<S, F, O>YesNoTransformed signal
Zip<A, B>YesNoCombined signals
Distinct<S>YesNoDeduplicated signal
Cached<S>YesNoMemoized signal
Debounce<S>YesNoTime-delayed signal
Throttle<S>YesNoRate-limited signal
List<T>YesYesReactive collection
MacroPurpose
s!("...")Reactive string formatting
text!("...")Localized reactive text view
#[derive(Project)]Decompose struct bindings into per-field bindings

With reactive state under your belt, the next chapter introduces the Environment – WaterUI’s dependency injection system that lets you share configuration, themes, and services across your entire view tree.

The Environment

In this chapter, you will:

  • Understand how WaterUI’s type-indexed dependency injection works
  • Learn to insert, read, and override values that flow through the view tree
  • Use use_env and extractors to access shared configuration from any view
  • Build plugins and hooks that customize component rendering globally

Picture this: you are building an app with a dark mode toggle. When the user flips the switch, every single component – buttons, text labels, backgrounds – needs to update its colors. You could pass a theme parameter to every view function… but that would be tedious and fragile. What if a deeply nested component also needs the current locale, or an API client, or a navigation controller?

The Environment solves this. It is WaterUI’s type-indexed dependency injection system – a shared bag of values that flows automatically through the view hierarchy. Any view can read from it, and any view can extend it for its descendants. Think of it like React Context or SwiftUI’s @Environment, but with Rust’s type system as the key.

How It Works

Environment is a type-indexed map – each unique type can store at most one visible value. When a view’s body() is called, it receives a reference to the current environment. Child views inherit the parent’s environment, and any view can extend it with additional values for its descendants.

Internally, Environment is implemented as a structurally-shared overlay chain (an Rc<EnvironmentState> linked list backed by a BTreeMap at the root). That means cloning an environment is an Rc bump, and extending one is an O(1) overlay – no map copy required.

This design gives you:

  • Zero boilerplate: No keys, strings, or registration ceremonies. The type is the key.
  • Automatic scoping: Values inserted by a parent are visible to all descendants.
  • Override-friendly: A child can shadow a parent’s value for its subtree.
  • Clone-cheap: Environment clones share their backing state through Rc.

Note: Since each type can appear at most once, inserting the same type again replaces the previous value. If you need multiple values of the same type (e.g., two different Color values), see the Store section below.

Creating and Seeding

Empty Environment

let env = Environment::new();

Inserting Values

Use insert for imperative insertion. Both insert and with mutate the environment in place; with returns &mut Self so successive calls can be chained on a mutable handle:

let mut env = Environment::new();

// Imperative
env.insert(String::from("hello"));
env.insert(42i32);

// Fluent chaining on a &mut Environment
env.with(String::from("hello"))
   .with(42i32);

Since types are keys, each type can appear at most once. Inserting the same type replaces the previous value. To extend a borrowed environment without mutation, use env.extending(value), which returns a fresh Environment that overlays the new value on the original.

The store() Method: Namespaced Keys

What if you need two values of the same type? Use Store<K, V> where K is a zero-sized marker type. store consumes the environment and returns a new one (it is the chainable cousin of with):

use waterui_core::env::Store;

struct PrimaryColor;
struct AccentColor;

let env = Environment::new()
    .store::<PrimaryColor, _>(Color::blue())
    .store::<AccentColor, _>(Color::orange());

Now the environment holds two Color values distinguished by their marker type. Query them with:

let primary: Option<&Color> = env.query::<PrimaryColor, Color>();
let accent: Option<&Color> = env.query::<AccentColor, Color>();

Tip: Store is a lightweight pattern for when you need the same type in multiple roles. The marker types are zero-sized, so they add no runtime overhead.

Installing Plugins

let mut env = Environment::new();
env.install(MyThemePlugin);
env.install(LocalizationPlugin::new("en"));

The install method calls the plugin’s install method, which can insert multiple values, hooks, or other plugins. We will cover plugins in detail later in this chapter.

Reading Values

Direct Lookup

// Returns Option<&T>
if let Some(theme) = env.get::<MyTheme>() {
    // use theme
}

get_or_insert_with

Lazily initialize a value if it does not exist:

let config = env.get_or_insert_with(|| AppConfig::default());

Removing Values

env.remove::<MyTheme>();

Now that you know how to seed and query the environment directly, let’s see how views interact with it.

Accessing the Environment from Views

The use_env Function

use_env creates a view that receives extracted values from the environment. This is the primary way your views consume shared configuration:

use waterui_core::env::use_env;

let view = use_env(|theme: MyTheme| {
    let name = theme.name.clone();
    text!("Current theme: {name}")
        .foreground(theme.text_color)
});

The closure parameter must implement the Extractor trait (more on this below). If extraction fails (the value is not in the environment), the view panics.

For optional values, wrap the extractor in Option:

let view = use_env(|theme: Option<MyTheme>| {
    match theme {
        Some(theme) => {
            let name = theme.name.clone();
            text!("Theme: {name}").anyview()
        }
        None => text("No theme set").anyview(),
    }
});

Note: text! accepts only named placeholders captured from scope or aliased explicitly (text!("Hello, {name}", name = greeting())). Positional {} placeholders are rejected at compile time.

Tuple Extraction

Extract multiple values at once:

let view = use_env(|(nav, db): (NavigationController, Database)| {
    let name = db.name();
    text!("Connected to {name}")
});

Tuple extractors are implemented for tuples up to 8 elements.

The ViewExt::with Method

Inject a value into the environment for a view’s subtree:

// All descendants of this view will see MyConfig in their environment
my_view.with(MyConfig { debug: true })

This creates a With<V, T> wrapper that clones the current environment, inserts the value, and passes the modified environment to the child.

Tip: .with() is how you scope configuration to a subtree. For example, you can set a different theme for just the settings page without affecting the rest of the app.

The ViewExt::install Method

Install a plugin into the environment for a subtree:

my_view.install(MyPlugin)

This is equivalent to use_env(|mut env: Environment| { env.install(plugin); Metadata::new(view, env) }).

Extractor and Use<T>

The Extractor trait defines how to pull values from an Environment:

pub trait Extractor: 'static + Sized {
    fn extract(env: &Environment) -> Result<Self, Error>;
}

Built-in Extractors

TypeBehavior
EnvironmentClones the entire environment
Use<T>Looks up T in the environment, clones it
Option<T: Extractor>Wraps extraction – returns None instead of error
(A, B, ...)Extracts each element, up to 8-tuples

The Use<T> Wrapper

Use<T> is the standard way to extract a value. It implements Deref<Target = T>:

use waterui_core::extract::Use;

let view = use_env(|Use(config): Use<AppConfig>| {
    let debug = config.debug;
    text!("Debug: {debug}")
});

If the type is not found, extraction returns an error with a clear message describing the missing type.

Custom Extractors

The impl_extractor! macro generates an Extractor impl that delegates to Use<T>:

impl_extractor!(MyConfig);

// Now you can write:
let view = use_env(|config: MyConfig| {
    // config is extracted directly, no Use<> wrapper needed
});

Tip: Use impl_extractor! for types you access frequently. It saves you from writing Use<MyConfig> everywhere.

Metadata<T> and IgnorableMetadata<T>

Metadata attaches additional rendering instructions to views. There are two kinds, depending on whether the instruction is mandatory or optional.

Metadata<T> (Mandatory)

pub struct Metadata<T: MetadataKey> {
    pub content: AnyView,
    pub value: T,
}

If a renderer encounters Metadata<T> and does not handle it, calling body() will panic. This ensures critical rendering instructions (e.g., environment overrides, lifecycle hooks) are not silently dropped.

// Attach mandatory metadata
let view = Metadata::new(my_view, LifeCycleHook::new(LifeCycle::Appear, || {
    tracing::info!("View appeared!");
}));

IgnorableMetadata<T> (Optional)

pub struct IgnorableMetadata<T: MetadataKey> {
    pub content: AnyView,
    pub value: T,
}

If a renderer does not handle this metadata, body() simply returns the content view – the metadata is silently discarded. Use this for hints that improve the experience but are not required (e.g., accessibility labels).

let view = IgnorableMetadata::new(my_view, AccessibilityLabel::new("Submit button"));

MetadataKey

Both metadata types require the value to implement MetadataKey:

pub trait MetadataKey: 'static {}

This is a simple marker trait. Implement it for any type you want to attach as metadata.

Retain: Keeping Guards Alive

When you set up a manual watcher on a signal, the watcher is unsubscribed as soon as its guard is dropped. But view body functions are one-shot – they return a view and then their local variables go out of scope. Retain solves this by keeping an arbitrary value alive for the lifetime of the view:

pub struct Retain {
    // private fields
}

Use it through ViewExt::retain:

fn my_view(data: Binding<String>) -> impl View {
    let guard = data.watch(|ctx| {
        tracing::debug!("Data changed: {}", ctx.into_value());
    });

    text!("Watching data")
        .retain(guard)  // guard lives as long as this view
}

Without .retain(), the guard would be dropped at the end of the function, immediately unsubscribing the watcher.

You can retain multiple values by chaining or passing a tuple:

text!("Hello")
    .retain(guard1)
    .retain(guard2)

// Or combine into one retain
text!("Hello")
    .retain((guard1, guard2, some_subscription))

Warning: Forgetting to .retain() a watcher guard is a common bug. Your watcher will appear to “not work” because it gets unsubscribed immediately. If your side effect never fires, check that you are retaining the guard.

Hooks: Intercepting View Configuration

Hooks allow global interception of configurable views. This is the mechanism that powers WaterUI’s theming system. A Hook<C> wraps a function that receives an Environment and a ViewConfiguration, and returns a custom view:

pub struct Hook<C>(Box<dyn Fn(&Environment, C) -> AnyView>);

Construct one with Hook::new(|env, config| ...), where the closure may return any impl View – the constructor erases it to AnyView for you.

Installing a Hook

env.insert_hook(|env: &Environment, config: ButtonConfig| {
    // Return a custom view instead of the default button
    custom_button(config.label, config.action)
        .padding()
        .background(Color::blue())
});

Or using Hook::new directly when you need to keep the hook value around:

use waterui_core::view::Hook;

let hook: Hook<ButtonConfig> = Hook::new(|env, config: ButtonConfig| {
    my_custom_button(config)
});
env.insert(hook);

How Hooks Work

When a configurable view’s body() is called:

  1. It extracts its configuration via ConfigurableView::config().
  2. It checks env.get::<Hook<Config>>().
  3. If a hook exists, hook.apply(env, config) is called.
  4. The hook receives a modified environment with itself removed (preventing infinite recursion).
  5. If no hook exists, the default native rendering is used.

This is the mechanism behind WaterUI’s theming system – a theme plugin installs hooks for ButtonConfig, ToggleConfig, SliderConfig, etc., replacing the default platform rendering with themed versions.

Note: The hook receives an environment with itself removed. This means if your hook calls config.render(), the default rendering will be used for the inner view – no infinite recursion. This is intentional and allows hooks to wrap the default rendering rather than fully replace it.

Plugins: The Plugin Trait

Plugins bundle related environment setup into a reusable unit. Instead of manually inserting values, hooks, and configurations, you define a plugin that does it all:

pub trait Plugin: Sized + 'static {
    fn install(self, env: &mut Environment) {
        env.insert(self);
    }

    fn uninstall(self, env: &mut Environment) {
        env.remove::<Self>();
    }
}

The default install implementation stores the plugin itself in the environment. Override it to perform custom setup:

struct DarkThemePlugin;

impl Plugin for DarkThemePlugin {
    fn install(self, env: &mut Environment) {
        // Store self so we can check if installed
        env.insert(self);

        // Install hooks for themed components
        env.insert_hook(|env: &Environment, config: ButtonConfig| {
            dark_themed_button(config)
        });

        env.insert_hook(|env: &Environment, config: ToggleConfig| {
            dark_themed_toggle(config)
        });

        // Store theme colors
        env.insert(ThemeColors::dark());
    }
}

Install plugins at the app level or per-subtree:

// App-wide: install into the root environment before constructing App.
pub fn app(mut env: Environment) -> App {
    env.install(DarkThemePlugin);
    App::new(main, env)
}

// Per-subtree: only affects descendants of `settings_form`.
fn settings_page() -> impl View {
    settings_form()
        .install(HighContrastPlugin)
}

Practical Examples

Let’s see the environment in action with some real-world patterns.

Theming with Environment

use waterui::prelude::*;

#[derive(Clone, Debug)]
struct AppTheme {
    primary: Color,
    background: Color,
    text: Color,
}

impl AppTheme {
    fn light() -> Self {
        Self {
            primary: Color::blue(),
            background: Color::srgb(255, 255, 255),
            text: Color::srgb(0, 0, 0),
        }
    }

    fn dark() -> Self {
        Self {
            primary: Color::cyan(),
            background: Color::srgb(26, 26, 26),
            text: Color::srgb(255, 255, 255),
        }
    }
}

fn themed_card(title: &'static str) -> impl View {
    use_env(|theme: AppTheme| {
        text(title)
            .foreground(theme.text)
            .padding()
            .background(theme.background)
    })
}

fn app_root() -> impl View {
    // Inject a static theme into the subtree's environment. Each descendant
    // re-extracts `AppTheme` from the environment via `use_env`.
    vstack((
        themed_card("Welcome"),
        themed_card("Settings"),
    ))
    .with(AppTheme::light())
}

Tip: To swap themes reactively, scope two subtrees behind a when(dark_mode, || ...).otherwise(|| ...) – each branch installs the appropriate theme, and the framework rebuilds the relevant subtree when dark_mode flips.

Configuration Injection

#[derive(Clone, Debug)]
struct ApiConfig {
    base_url: String,
    timeout_ms: u32,
}

impl Plugin for ApiConfig {
    fn install(self, env: &mut Environment) {
        env.insert(self);
    }
}

fn api_status() -> impl View {
    use_env(|config: ApiConfig| {
        let base_url = config.base_url.clone();
        let timeout_ms = config.timeout_ms;
        text!("API: {base_url} ({timeout_ms}ms timeout)")
    })
}

pub fn app(mut env: Environment) -> App {
    env.install(ApiConfig {
        base_url: "https://api.github.com".into(),
        timeout_ms: 5000,
    });
    App::new(main, env)
}

Button Hook Customization

use waterui::prelude::*;
use waterui::shape::RoundedRectangle;

struct RoundedButtonPlugin;

impl Plugin for RoundedButtonPlugin {
    fn install(self, env: &mut Environment) {
        env.insert(self);
        env.insert_hook(|env: &Environment, config: ButtonConfig| {
            // Re-render the default button, then wrap it in chrome.
            config.render()
                .padding()
                .background(Color::blue())
                .clip(RoundedRectangle::new(0.2))
        });
    }
}

fn my_screen() -> impl View {
    vstack((
        // These buttons render through RoundedButtonPlugin's hook.
        button("Save").action(|| {}),
        button("Cancel").action(|| {}),
    ))
    .install(RoundedButtonPlugin)
}

Note: RoundedRectangle::new takes a normalized corner radius in 0.0..=0.5, not points. The 0.2 above produces softly rounded corners regardless of the button size.

Try installing RoundedButtonPlugin on just one section of your app and observe how only that subtree’s buttons are affected.

Summary

ConceptPurpose
EnvironmentType-indexed key-value store flowing through the view tree
env.insert() / env.with()Store a value (type = key)
env.get::<T>()Retrieve a value by type
Store<K, V>Namespace multiple values of the same type
use_env(closure)Create a view that accesses the environment
ViewExt::with(value)Inject a value for descendants
Extractor / Use<T>Type-safe extraction from environment
Metadata<T>Mandatory rendering instructions attached to views
IgnorableMetadata<T>Optional hints that renderers may discard
RetainKeep RAII guards alive for a view’s lifetime
Hook<C>Intercept and customize configurable view rendering
Plugin traitModular environment extensions with install/uninstall

With the environment in hand, you have a powerful tool for sharing state across your view tree. The next chapter covers modifiers – the chainable methods that let you style, position, and add behavior to any view.

Modifiers and ViewExt

In this chapter, you will:

  • Learn how modifier chaining works under the hood
  • Use layout modifiers to control sizing, spacing, and alignment
  • Apply visual effects like backgrounds, borders, shadows, and filters
  • Add interactivity with tap, gesture, and drag-and-drop modifiers
  • Understand why modifier order matters and how to get it right

You have your views and your reactive state. Now you need to make them look good and respond to user input. In WaterUI, you do this with modifiers – chainable methods that add styling, layout, and behavior to any view. Instead of passing dozens of parameters to a constructor, you build up the description one modifier at a time:

text!("Hello")
    .padding()
    .background(Color::blue())
    .on_tap(|| { /* ... */ })

This approach keeps your view constructors simple and your styling composable.

WaterUI modifiers preview showing padding background border opacity and filters

A Hydrolysis preview showing how modifier order changes rendered output. Example source.

How Modifiers Work

Every modifier method on ViewExt takes self (consuming the view) and returns a new type that wraps it. For example:

text!("Hello")          // Text
    .padding()          // Padding (wraps Text)
    .background(Color::blue())  // Background (wraps Padding)
    .border(Color::srgb(0, 0, 0), 1.0) // Metadata<Border> (wraps Background)

The resulting type is a nested structure. The renderer walks this structure from outside to inside, applying each modifier’s effect as it goes.

Because modifiers are type-level wrappers (not runtime property bags), the compiler can optimize aggressively and catch errors at compile time.

The ViewExt Trait

ViewExt is an extension trait automatically implemented for every View:

pub trait ViewExt: View + Sized {
    // ... modifier methods ...
}

impl<V: View + Sized> ViewExt for V {}

All methods are available through the WaterUI prelude – no special imports needed. The following sections catalog every modifier by category.

Layout Modifiers

Layout modifiers control sizing, spacing, and alignment – the fundamentals of placing your views on screen.

padding

Every UI needs breathing room. padding adds space around the view’s content:

// Default padding (14.0 points on all sides)
text!("Hello").padding()

// Custom edge insets
text!("Hello").padding_with(EdgeInsets::new(10.0, 20.0, 10.0, 20.0))

// EdgeInsets also supports From<f32> for uniform padding
text!("Hello").padding_with(16.0)

width, height, size

Fix the view to specific dimensions:

Color::red().width(100.0)
Color::red().height(50.0)
Color::red().size(100.0, 50.0) // both at once

min/max constraints

When you want a view that flexes within bounds:

text!("Flexible")
    .min_width(80.0)
    .max_width(300.0)
    .min_height(40.0)
    .max_height(200.0)

// Both axes at once
text!("Bounded").min_size(80.0, 40.0).max_size(300.0, 200.0)

alignment

Position the view within its allocated frame:

text!("Top Left").alignment(Alignment::TopLeading)
text!("Center").alignment(Alignment::Center)
text!("Bottom Right").alignment(Alignment::BottomTrailing)

ignore_safe_area

Extend the view’s bounds beyond safe area insets (useful for full-bleed backgrounds):

use waterui::prelude::*;

// Fill entire screen including notch/status bar area
Color::red().ignore_safe_area(EdgeSet::ALL)

// Only extend to top (under status bar)
header_view.ignore_safe_area(EdgeSet::TOP)

Frame Builder

The width, height, alignment, and constraint methods all return a Frame, which supports further chaining:

text!("Hello")
    .width(200.0)        // returns Frame
    .height(50.0)        // Frame method
    .min_width(100.0)    // Frame method
    .alignment(Alignment::Center) // Frame method

Tip: You can chain all frame-related modifiers together in one fluent call since they all return Frame.

Visual Modifiers

Visual modifiers affect the appearance of views without changing their layout. These are what make your views look polished.

background

Render content behind the view:

// Solid color
text!("Hello").background(Color::red())

// Material (platform blur effect)
text!("Hello").background(Material::Regular)

// Any view as background
text!("Hello").background(
    hstack((Color::red(), Color::blue()))
)

The background fills the view’s bounds. The content determines the layout size; the background stretches to fill it.

foreground

Set the foreground color for text and icons in the subtree:

// All text in this VStack will be red
vstack((
    text!("Hello"),
    text!("World"),
)).foreground(Color::red())

This works by injecting a ForegroundOverride into the environment, so it affects all descendants that do not override it themselves.

opacity

Control transparency. Any IntoSignalF32 is accepted, including a constant f32 or a reactive Binding<f32>:

// Static opacity
text!("Faded").opacity(0.5)

// Reactive opacity (useful for animations)
let alpha = Binding::f32(1.0);
text!("Dynamic").opacity(alpha)

The opacity modifier maps to compositor-native operations (no GPU pass) and is available directly on ViewExt.

overlay

Render content on top of the view, without affecting the base view’s size:

text!("Hello").overlay(
    Color::red().opacity(0.5)
)

Unlike ZStack, an overlay does not influence the layout of the underlying view.

Tip: overlay is great for badges, status indicators, or decorative elements that should sit on top of content without affecting layout.

shadow

Add a drop shadow. Shadow::new takes a color, an offset vector, and a blur radius:

use waterui::style::{Shadow, Vector};

text!("Shadowed").shadow(Shadow::new(
    Color::srgb(0, 0, 0).with_opacity(0.3),
    Vector { x: 2.0, y: 2.0 },
    4.0,
))

border

Add a border around the view:

// Simple border on all edges
text!("Bordered").border(Color::red(), 2.0)

// Full customization via Border builder
let custom = Border::new(Color::blue(), 2.0)
    .corner_radius(12.0)
    .edges(EdgeSet::HORIZONTAL);

text!("Custom").border_with(custom)

clip

Clip the view to a shape. The shape is normalized to the view’s bounds, so RoundedRectangle::new accepts a corner radius in 0.0..=0.5:

use waterui::shape::{Circle, RoundedRectangle};

// Clip to circle
avatar_view.clip(Circle)

// Clip to rounded rectangle (10% corner radius)
card_view.clip(RoundedRectangle::new(0.1))

visible

Control visibility. visible is implemented as a composition of opacity and hit testing – when hidden, the view fades to opacity 0.0 and stops receiving touches:

let show = Binding::bool(true);
text!("Now you see me").visible(show)

Transform Modifiers

Transforms are purely visual – they change how the view is drawn but do not affect layout calculations. This makes them ideal for animations.

scale

Scale the view around its center. Both axes accept any IntoSignalF32:

// Uniform scale
star_view.scale(1.5, 1.5)

// Non-uniform scale
text!("Stretched").scale(2.0, 1.0)

// Reactive (for animations)
let s = Binding::f32(1.0);
heart_view.scale(s.clone(), s)

// Scale from a specific anchor point
star_view.scale_from(0.5, 0.5, Anchor::TOP_LEFT)

rotation

Rotate the view in degrees:

// Static (positive = clockwise)
arrow_view.rotation(45.0)

// Reactive
let angle = Binding::f32(0.0);
spinner_view.rotation(angle)

// Rotate around a specific anchor
dial_view.rotation_from(90.0, Anchor::TOP_LEFT)

offset

Translate the view:

// Static offset
badge.offset(10.0, -5.0)

// Reactive (great for drag or animation)
let x = Binding::f32(0.0);
let y = Binding::f32(0.0);
draggable_view.offset(x, y)

Tip: Combine offset with reactive bindings and animations to create smooth drag interactions or slide-in effects.

Interaction Modifiers

These modifiers add gesture recognition and touch handling to views. They turn passive content into interactive controls.

on_tap

The simplest interaction – recognize a single tap:

text!("Click me").on_tap(|| {
    tracing::info!("Tapped!");
})

on_tap_gesture_count

Require a specific number of taps:

text!("Double-tap me").on_tap_gesture_count(2, || {
    tracing::info!("Double tapped!");
})

on_long_press_gesture

Recognize a long press:

text!("Press and hold").on_long_press_gesture(500, || {
    tracing::info!("Long pressed for 500ms!");
})

gesture

Attach any gesture recognizer:

use waterui::gesture::*;

text!("Custom gesture")
    .gesture(TapGesture::repeat(3), || {
        tracing::info!("Triple tap!");
    })

hittable

Control whether the view responds to touch/click events:

// Disable hit testing -- touches pass through
overlay_decoration.hittable(false)

// Reactive control
let interactive = Binding::bool(true);
my_view.hittable(interactive)

disabled

Disable the view – grays it out and blocks all interactions:

// Static
button("Submit").action(|| {}).disabled(true)

// Reactive
let is_loading = Binding::bool(false);
button("Submit").action(|| {}).disabled(is_loading)

disabled is a convenience that composes opacity(0.5), hittable(false), and the corresponding accessibility state.

draggable

Make a view draggable:

use waterui::drag_drop::DragData;

text!("Drag me").draggable(DragData::text("Hello!"))

drop_destination

Make a view accept dropped content:

text!("Drop here").drop_destination(|data: DragData| {
    tracing::info!("Received: {:?}", data);
})

Stateful Event Handlers

Sometimes your event handlers need to capture mutable state – for example, tracking a hover count or toggling a flag. Use ViewExt::state to inject cloneable state into the view’s environment, then extract it in handlers via the State<T> extractor:

use waterui::extract::State;

let count = Binding::i32(0);
let is_hovered = Binding::bool(false);

text("Hover Me!")
    .padding()
    .state(&count)
    .state(&is_hovered)
    .on_hover_enter(
        |State(count): State<Binding<i32>>,
         State(hovered): State<Binding<bool>>| {
            count.set(count.get() + 1);
            hovered.set(true);
        },
    )
    .on_hover_exit(|State(hovered): State<Binding<bool>>| {
        hovered.set(false);
    })

Each .state(&value) call inserts that binding into the subtree’s environment. Handlers extract whichever values they need via State<T> parameters; missing values become a clear runtime error.

Feedback Modifiers

These modifiers provide sensory feedback to the user, making your app feel more responsive and native.

on_tap_haptic

Trigger haptic feedback on tap (requires the std feature):

use waterkit_haptic::Intensity;

text!("Haptic tap").on_tap_haptic(Intensity::MEDIUM, || {
    // action
})

// Default medium intensity
text!("Haptic tap").on_tap_haptic_default(|| {
    // action
})

cursor

Set the cursor style when hovering (desktop platforms):

use waterui::cursor::CursorStyle;

text!("Click me").cursor(CursorStyle::PointingHand)

badge

Add a numeric badge overlay (common for notification counts):

let unread = Binding::i32(5);
SystemIcon::new("envelope").badge(unread)

Filter Modifiers

Filter modifiers apply GPU-accelerated visual effects. They come from the FilterViewExt trait (included in the prelude) and are great for image processing and polished UI effects.

blur

Apply a Gaussian blur:

photo_view.blur(10.0)

// Reactive
let blur_amount = Binding::f32(0.0);
photo_view.blur(blur_amount)

brightness

Adjust brightness:

photo_view.brightness(0.2)  // increase
photo_view.brightness(-0.2) // decrease

contrast

Adjust contrast:

photo_view.contrast(1.5) // higher contrast
photo_view.contrast(0.5) // lower contrast

saturation

Adjust color saturation:

photo_view.saturation(1.5) // more vivid
photo_view.saturation(0.0) // completely desaturated

grayscale

Convert to grayscale:

photo_view.grayscale(1.0) // fully grayscale
photo_view.grayscale(0.5) // partially desaturated

hue_rotation

Rotate the hue of all colors (degrees):

photo_view.hue_rotation(90.0)  // shift by 90 degrees
photo_view.hue_rotation(180.0) // invert hues

All filter modifiers accept any impl IntoSignalF32, which means you can pass a static f32, a Binding<f32>, or any signal-based value for animated filters.

Tip: Try animating blur or saturation with a reactive binding for smooth transition effects – for example, blurring the background when a modal appears.

Lifecycle Modifiers

These modifiers let you run code at specific points in a view’s lifecycle.

on_appear

Execute code when the view becomes visible:

text!("Hello").on_appear(|| {
    tracing::info!("View is now visible");
})

Note: body() being called does not mean the view is visible. A lazy container may resolve views ahead of time. Use on_appear for code that should run when the view is actually displayed on screen.

on_disappear

Execute code when the view is removed from the view hierarchy:

text!("Hello").on_disappear(|| {
    tracing::info!("View removed from hierarchy");
})

on_change

Monitor a signal and execute a handler when the value changes:

let search = Binding::container(String::new());

text_field("Search", search.clone())
    .on_change(&search, |value: String| {
        tracing::info!("Search changed to: {value}");
    })

This is a convenience over manual watch() + retain() – the watcher lifecycle is managed automatically. The handler receives the new Output value of the source signal.

task

Spawn an async task tied to the view’s lifecycle:

text!("Loading...").task(async {
    let data = fetch_data().await;
    // The task is cancelled when the view is removed
})

Event Modifiers

on_hover_enter / on_hover_exit

React to cursor hover (macOS, iPadOS with trackpad, Android API 24+):

text!("Hover me")
    .on_hover_enter(|| tracing::info!("Mouse entered"))
    .on_hover_exit(|| tracing::info!("Mouse exited"))

event

Attach a handler for any Event variant:

use waterui_core::event::Event;

text!("Interactive")
    .event(Event::HoverEnter, || { /* ... */ })
    .event(Event::HoverExit, || { /* ... */ })

Other Modifiers

metadata

Attach arbitrary metadata to a view:

text!("Important").metadata(MyCustomMetadata { priority: 1 })

tag

Tag a view for identification:

text!("Item").tag(42)

anyview

Convert to a type-erased AnyView:

let view: AnyView = text!("Hello").anyview();

retain

Keep a value alive for the view’s lifetime:

let guard = some_signal.watch(|_| { /* ... */ });
text!("Watching").retain(guard)

title

Wrap in a navigation view with a title:

content_view.title(text!("Settings"))

focused

Mark the view as focused when a binding matches:

let focus = Binding::container::<Option<Field>>(None);
text_field("Name", name).focused(&focus, Field::Name)

secure

Prevent screenshots of the view:

sensitive_content.secure()

context_menu

Attach a context menu (long-press on mobile, right-click on desktop). Menu content is built from MenuView implementations – ordinary Buttons with .action() work directly:

text("Right-click me").context_menu((
    button("Copy").action(|| { /* ... */ }),
    button("Paste").action(|| { /* ... */ }),
))

a11y_label / a11y_role

Set accessibility attributes:

SystemIcon::new("star").a11y_label("Favorite")
SystemIcon::new("star").a11y_role(AccessibilityRole::Button)

Tip: Always add a11y_label to icon-only buttons and interactive elements. Screen readers rely on these labels to describe your UI to users with visual impairments.

Modifier Order

Modifier order matters in WaterUI because each modifier wraps the previous result. The outermost modifier is applied first during rendering. Getting the order wrong is one of the most common sources of “why does my layout look wrong?”

A common pattern where order matters:

// Padding INSIDE the background
text!("Hello")
    .padding()           // padding applied first
    .background(Color::red()) // background wraps the padded view

// Padding OUTSIDE the background
text!("Hello")
    .background(Color::red()) // background applied first
    .padding()           // padding wraps the background

Similarly for transforms:

// Rotate then offset -- rotates in place, then translates
view.rotation(45.0).offset(100.0, 0.0)

// Offset then rotate -- translates first, then rotates around original center
view.offset(100.0, 0.0).rotation(45.0)

Warning: If your background does not seem to extend behind your padding, or your border appears inside your content area, check your modifier order.

General guidelines:

  1. Layout modifiers (padding, frame, alignment) should go before visual modifiers.
  2. Gestures should go after layout/visual modifiers so the hit area matches what the user sees.
  3. Lifecycle hooks can go anywhere – they do not affect rendering.
  4. Background goes after padding if you want the background to include the padded area.

Try swapping .padding() and .background() on a view and observe the difference.

Complete Example

Here is a complete example that puts many modifier categories together:

use waterui::prelude::*;
use waterui::shape::RoundedRectangle;
use waterui::style::{Shadow, Vector};

fn card(title: &'static str, count: Binding<i32>) -> impl View {
    let is_highlighted = count.clone().map(|n| n > 10);
    let background = is_highlighted.map(|on| {
        if on { Color::blue() } else { Color::grey() }
    });

    vstack((
        text(title).foreground(Color::srgb(255, 255, 255)),
        text!("{count}"),
        button("Increment").action(move || {
            count.set(count.get() + 1);
        }),
    ))
    .padding()
    .background(background)
    .border(Color::srgb(0, 0, 0), 1.0)
    .clip(RoundedRectangle::new(0.15))
    .shadow(Shadow::new(
        Color::srgb(0, 0, 0).with_opacity(0.2),
        Vector { x: 0.0, y: 2.0 },
        4.0,
    ))
    .on_appear(|| tracing::info!("Card appeared"))
}

Summary

CategoryModifiers
Layoutpadding, padding_with, width, height, size, min_width, max_width, min_height, max_height, min_size, max_size, alignment, ignore_safe_area
Visualbackground, foreground, overlay, shadow, border, border_with, clip, visible
Transformscale, scale_from, rotation, rotation_from, offset
Interactionon_tap, on_tap_gesture, on_tap_gesture_count, on_long_press_gesture, gesture, gesture_observer, hittable, disabled, draggable, drop_destination, state
Feedbackon_tap_haptic, on_tap_haptic_default, cursor, badge
Filterblur, brightness, contrast, saturation, grayscale, hue_rotation, opacity
Lifecycleon_appear, on_disappear, on_change, task
Eventevent, on_hover_enter, on_hover_exit
Othermetadata, tag, anyview, retain, title, focused, secure, context_menu, a11y_label, a11y_role, with, install

You now have the complete toolkit for building views, managing reactive state, sharing configuration through the environment, and styling everything with modifiers. The next part of the book, Building UIs, puts all of these concepts together as you work with text, layouts, controls, forms, and navigation.

Text and typography

In this chapter, you will:

  • Display text using text() for static content and text! for reactive, localized strings
  • Style text with semantic fonts, weights, colors, and decorations
  • Build rich text with StyledStr, including Markdown and concatenation
  • Add syntax highlighting for source code

Text is the most fundamental building block in any user interface. Whether you are showing a headline, a label beside a toggle, or a paragraph of help text, you reach for the Text component first. WaterUI gives you a small two-function API: text() for plain content that does not change, and the text! macro for reactive strings that interpolate captured bindings and (optionally) consult a translation catalog.

WaterUI typography preview with title headline caption and styled text

A Hydrolysis preview of WaterUI text rendered with semantic typography and colors. Example source.

Static text with text()

The simplest way to display text is the text() function. It accepts anything that converts into Text — a &'static str becomes localized through the catalog, while String and Str are used verbatim:

use waterui::prelude::*;

fn greeting() -> impl View {
    text("Hello, World!")
}

Text sizes itself to fit its content and never stretches to fill extra space. When the available width is limited, it wraps to multiple lines automatically.

Layout behavior

Here is what you need to know about how Text participates in layout:

  • Sizing: fits its content naturally, like a label.
  • In stacks: takes only the space it needs, leaving room for siblings.
  • Wrapping: wraps when width is constrained — for example by a parent Frame.
use waterui::prelude::*;

fn row() -> impl View {
    // Push two labels apart in a row.
    hstack((text("Name"), spacer(), text("Value")))
}

Tip: Because Text never stretches on its own, you can safely place it in any stack without worrying about it gobbling up space from sibling views.

Reactive text with text!

Static strings are fine for fixed labels, but most apps need text that updates in response to state. The text! macro captures any named placeholder from the surrounding scope and re-evaluates whenever those bindings change. When a i18n/<locale>.toml catalog is present, the same call site also resolves the matching translation:

use waterui::prelude::*;

fn counter_label(count: &Binding<i32>) -> impl View {
    // Captures `count` from scope; the rendered text updates on every change.
    text!("Count: {count}")
}

The macro only accepts named placeholders. Either name a binding directly ({count}), or alias an expression with name = expr:

use waterui::prelude::*;

fn welcome(get_name: impl Fn() -> String) -> impl View {
    text!("Hello, {name}", name = get_name())
}

Warning: text! does not accept positional {} placeholders. Writing text!("Count: {}", count) will not compile. Use a named placeholder ({count}) and capture the binding from scope, or pass an explicit alias (name = expr).

Why not .get() and format!?

Calling .get() on a binding inside a view body reads the current value once and never re-runs. The text would freeze at construction time. Always express formatting through text! so the framework tracks the dependency:

use waterui::prelude::*;

fn show(value: &Binding<f64>) -> impl View {
    // Reactive: updates whenever `value` changes.
    text!("Value: {value:.2}")
}

Displaying arbitrary values

Any signal whose output type implements Display can be rendered with Text::display:

use waterui::prelude::*;

fn show_price(price: &Binding<f64>) -> impl View {
    Text::display(price.clone())
}

Text::display maps the signal through to_string() internally, so the text updates whenever the signal does.

Locale-aware formatting

For specialised formatting — locale-specific dates or numbers — use Text::format:

use waterui::prelude::*;
use waterui::text::locale::Formatter;

fn formatted<T: 'static + Clone>(value: &Binding<T>, fmt: impl Formatter<T> + 'static) -> impl View {
    Text::format(value.clone(), fmt)
}

Implement the Formatter<T> trait for any type whose presentation depends on the active locale.

Translation files for text!

If your app supports multiple languages, place TOML files under i18n/ in the crate root:

# i18n/en.toml
"Count: {count}" = "Count: {count}"

# i18n/zh.toml
"Count: {count}" = "计数:{count}"

The macro picks the right translation based on the active Locale in the environment. Missing translation files are not an error — text! falls back to the format string itself.

Note: Plural forms use {#count} syntax in the format string and a TOML table with one/other keys. See waterui/macros/src/locale.rs for the full grammar.

Font system

WaterUI provides a semantic font system with six built-in presets. Each preset resolves to a platform-appropriate size and weight through the environment, so a “Larger Text” accessibility setting cascades into your screen automatically:

PresetDefault SizeDefault Weight
Body16ptNormal
Title24ptSemiBold
Headline32ptBold
Subheadline20ptSemiBold
Caption12ptNormal
Footnote10ptLight

Use the convenience methods on Text. They work on values produced by both text() and text!:

use waterui::prelude::*;

fn typography() -> impl View {
    vstack((
        text("Page Title").title(),
        text("Main heading").headline(),
        text("Section header").sub_headline(),
        text("Body content").body(),
        text("Small note").caption(),
        text("Legal text").footnote(),
    ))
}

Custom font configuration

For fine-grained control, build a Font value and pass it to .font():

use waterui::prelude::*;
use waterui::text::font::{Font, FontWeight};

fn custom() -> impl View {
    text("Custom").font(
        Font::default()
            .size(18.0)
            .weight(FontWeight::Medium)
            .family("monospace"),
    )
}

Font weights

The FontWeight enum provides nine standard weights:

pub enum FontWeight {
    Thin,       // 100
    UltraLight, // 200
    Light,      // 300
    Normal,     // 400 (default)
    Medium,     // 500
    SemiBold,   // 600
    Bold,       // 700
    UltraBold,  // 800
    Black,      // 900
}

Size, weight, italic shortcuts

You do not always need to construct a Font. Text provides direct shortcuts. .size() and .weight() accept signals, so they can react:

use waterui::prelude::*;

fn highlight(emphasised: &Binding<bool>) -> impl View {
    vstack((
        text("Large bold text").size(28.0).bold(),
        // Italic toggles reactively from the binding.
        text("May be italic").italic(emphasised.clone()),
    ))
}

Color

Colors are zero-sized marker types you can pass into .color() or .foreground(). The built-in palette includes Red, Blue, Green, Orange, Purple, Cyan, Yellow, Pink, and Grey:

use waterui::prelude::*;

fn status() -> impl View {
    vstack((
        text("Error message").color(Red),
        text("Success").color(Green),
        text("Highlighted").background_color(Yellow),
    ))
}

Note: .color() on Text sets an explicit foreground color for that specific text view. The more general .foreground() modifier from ViewExt sets the inherited foreground for an entire view subtree, so children respect the cascade.

Text decorations

Underline

use waterui::prelude::*;

fn link_label(highlighted: &Binding<bool>) -> impl View {
    text("Click here").underline(highlighted.clone())
}

.underline() accepts any IntoSignal<bool>, so the decoration can toggle reactively.

Strikethrough

Strikethrough lives on StyledStr, not on Text:

use waterui::prelude::*;
use waterui::text::styled::StyledStr;

fn deprecated() -> impl View {
    text(StyledStr::plain("Deprecated").strikethrough(true))
}

Concatenating text

Sometimes you need mixed styles within a single line. Text implements Add and AddAssign, so styled fragments compose with +:

use waterui::prelude::*;

fn name_row() -> impl View {
    text("Name: ").bold() + text("Alice")
}

The resulting Text preserves the styling of each fragment.

Rich text with StyledStr

For full control over rich text, build a StyledStr directly. Each chunk carries its own Style, which includes font, foreground color, background color, italic, underline, and strikethrough:

use waterui::prelude::*;
use waterui::text::styled::{Style, StyledStr};

fn intro() -> impl View {
    let mut styled = StyledStr::empty();
    styled.push("Bold intro: ", Style::default().bold());
    styled.push("normal continuation", Style::default());
    text(styled)
}

Markdown shorthand

StyledStr::from_markdown parses a small subset of Markdown — headings, bold, italic, strikethrough, inline code, and paragraphs — into a styled string in one step:

use waterui::prelude::*;
use waterui::text::styled::StyledStr;

fn release_notes() -> impl View {
    text(StyledStr::from_markdown("**Bold** and *italic* with `code`"))
}

Syntax highlighting

If your app displays source code, WaterUI ships a syntect-backed highlighter. highlight_text is synchronous: it consumes a borrowed source string and a mutable highlighter, and returns a fully styled StyledStr:

use waterui::prelude::*;
use waterui::text::highlight::{DefaultHighlighter, Language, highlight_text};

fn code_view(source: &str) -> impl View {
    let mut highlighter = DefaultHighlighter::default();
    text(highlight_text(Language::Rust, source, &mut highlighter))
}

The Language enum covers Rust, Swift, Python, TypeScript, and many others. Each chunk in the resulting StyledStr carries the appropriate syntax color.

Quick reference

Method / FunctionPurpose
text("...")Static text, localized for &'static str
text!("Count: {n}")Reactive, localized text capturing n
Text::display(sig)Render any Signal<Output: Display>
Text::format(v, fmt)Locale-aware formatted text
.title()Apply the Title font preset
.headline()Apply the Headline font preset
.sub_headline()Apply the Subheadline font preset
.body()Apply the Body font preset
.caption()Apply the Caption font preset
.footnote()Apply the Footnote font preset
.size(f64)Set a custom font size (accepts signals)
.bold()Set font weight to Bold
.weight(w)Set a specific font weight (accepts signals)
.italic(sig)Toggle italic styling reactively
.color(c)Set the text foreground color
.background_color(c)Set the text background color
.underline(sig)Toggle underline reactively
.font(f)Apply a fully custom Font

Now that you can display and style text, it is time to learn how to arrange views on screen. In the next chapter, you will explore stacks, frames, grids, and the rest of the layout system.

Layout: stacks, frames, and grids

In this chapter, you will:

  • Arrange views vertically, horizontally, and in layers using stacks
  • Control spacing, alignment, and sizing with frames and padding
  • Build grid-based layouts and scrollable content
  • Use absolute positioning and pin constraints for free-form layouts

Every app needs to place things on screen — a title at the top, a button at the bottom, a sidebar on the left. WaterUI uses a declarative layout system inspired by SwiftUI: you compose views using stacks, spacers, frames, and grids, and the framework resolves sizes and positions through a proposal-based layout protocol. All values are in logical pixels (points/dp) — the same unit as Figma and Sketch. Native backends convert to physical pixels automatically.

WaterUI layout preview showing VStack HStack and ZStack composition

A Hydrolysis preview of WaterUI stack layout primitives. Example source.

Stacks

Stacks are your primary tool for arranging views. Think of them as the rows and columns of your interface. WaterUI provides three kinds: vstack (vertical), hstack (horizontal), and zstack (overlay).

vstack — vertical layout

vstack arranges children from top to bottom. It accepts a tuple of views:

use waterui::prelude::*;

fn profile_card() -> impl View {
    vstack((
        text("Alice").title(),
        text("Software Engineer"),
        text("San Francisco"),
    ))
}

Default spacing between children is 10pt and alignment is center.

Custom spacing and alignment

Use the struct constructor for full control:

use waterui::prelude::*;

fn left_aligned() -> impl View {
    VStack::new(HorizontalAlignment::Leading, 16.0, (
        text("Left-aligned"),
        text("Also left-aligned"),
    ))
}

Or chain the builder methods on vstack:

use waterui::prelude::*;

fn trailing_8pt() -> impl View {
    vstack((
        text("Item 1"),
        text("Item 2"),
    ))
    .alignment(HorizontalAlignment::Trailing)
    .spacing(8.0)
}

Horizontal alignment options

pub enum HorizontalAlignment {
    Leading,  // left in LTR locales
    Center,   // default
    Trailing, // right in LTR locales
}

hstack — horizontal layout

hstack arranges children left to right. This is what you reach for when building toolbars, rows of buttons, or any side-by-side arrangement:

use waterui::prelude::*;

fn toolbar() -> impl View {
    hstack((
        text("WaterUI"),
        spacer(),
        button("Settings").action(|| {}),
    ))
}

Default spacing is 10pt and alignment is center (vertical).

Custom spacing and alignment

use waterui::prelude::*;

fn top_aligned() -> impl View {
    HStack::new(VerticalAlignment::Top, 20.0, (
        text("Top-aligned"),
        text("Also top"),
    ))
}

Vertical alignment options

pub enum VerticalAlignment {
    Top,
    Center,  // default
    Bottom,
    FirstBaseline,
    LastBaseline,
}

zstack — overlay layout

When you need to layer views on top of each other — a badge on an avatar, text over an image — reach for zstack. The last child in the tuple renders on top, and the stack sizes itself to fit the largest child:

use waterui::prelude::*;

fn badge() -> impl View {
    zstack((
        Blue,
        text("Overlay").color(Yellow),
    ))
}

zstack alignment

Control where children are positioned within the stack:

use waterui::prelude::*;

fn corner_badge(image: impl View, dot: impl View) -> impl View {
    ZStack::new(Alignment::TopTrailing, (image, dot))
}

The Alignment enum has nine positions:

pub enum Alignment {
    TopLeading, Top, TopTrailing,
    Leading, Center, Trailing,    // Center is the default
    BottomLeading, Bottom, BottomTrailing,
}

Spacer

Spacer is a flexible gap that expands to push views apart. It adapts to its parent container: in an HStack it expands horizontally, in a VStack it expands vertically. This is one of the most useful layout tools you have.

use waterui::prelude::*;

fn pushed_to_the_edge() -> impl View {
    hstack((
        text("Title"),
        spacer(),
        button("Done").action(|| {}),
    ))
}

Minimum length

Use spacer_min to set a minimum length the spacer never shrinks below:

use waterui::prelude::*;

fn at_least_20pt() -> impl View {
    hstack((text("A"), spacer_min(20.0), text("B")))
}

Divider

The Divider widget draws a thin line for visual separation between sections:

use waterui::prelude::*;

fn sectioned() -> impl View {
    vstack((
        text("Section 1"),
        Divider,
        text("Section 2"),
    ))
}

Padding

Add breathing room around a view with the Padding wrapper, or its ViewExt shortcuts. padding() applies a default 14pt inset; use padding_with(EdgeInsets) for exact control:

use waterui::prelude::*;

fn padded() -> impl View {
    vstack((
        // Default 14pt on every side.
        text("Default").padding(),

        // Equal padding on all sides.
        text("Padded").padding_with(EdgeInsets::all(16.0)),

        // Symmetric vertical and horizontal.
        text("Symmetric").padding_with(EdgeInsets::symmetric(8.0, 16.0)),

        // Explicit edges.
        text("Custom").padding_with(EdgeInsets::new(10.0, 20.0, 15.0, 25.0)),
    ))
}

EdgeInsets provides three constructors. Note the explicit-edge order:

ConstructorDescription
EdgeInsets::all(v)Equal inset on every edge
EdgeInsets::symmetric(vertical, horiz)Vertical and horizontal insets
EdgeInsets::new(top, bottom, leading, trailing)Explicit edges

Frame

When you need a view to be a specific size, or at least a minimum width, wrap it in Frame. It supports minimum, ideal, and maximum dimensions:

use waterui::prelude::*;
use waterui::layout::frame::Frame;

fn fixed() -> impl View {
    Frame::new(text("Fixed"))
        .width(200.0)
        .height(100.0)
}

fn bounded() -> impl View {
    Frame::new(text("Bounded"))
        .max_width(300.0)
        .max_height(200.0)
}

Frame alignment

Control how the child is positioned within the frame:

use waterui::prelude::*;
use waterui::layout::frame::Frame;

fn bottom_right() -> impl View {
    Frame::new(text("Bottom-right"))
        .width(300.0)
        .height(200.0)
        .alignment(Alignment::BottomTrailing)
}

Frame methods

MethodDescription
.width(f32)Set the ideal width
.height(f32)Set the ideal height
.min_width(f32)Set the minimum width
.max_width(f32)Set the maximum width
.min_height(f32)Set the minimum height
.max_height(f32)Set the maximum height
.alignment(a)Align the child in frame

Tip: Use Frame only when you need explicit size constraints. Most views have sensible natural sizes, and stacks distribute space for you.

Scrolling

When content might exceed the available space, wrap it in a scroll view. This is essential for long lists and tall forms:

use waterui::prelude::*;

fn long_list() -> impl View {
    scroll(
        vstack((
            text("Item 1"),
            text("Item 2"),
            text("Item 3"),
        )),
    )
}

Three convenience constructors map to ScrollView:

FunctionDirection
scroll(content)Vertical only
scroll_horizontal(c)Horizontal only
scroll_both(c)Both directions

Grid

For content that naturally falls into rows and columns — a settings panel with labels and values, or an image gallery — use Grid. You specify the number of columns, and the grid distributes children into rows automatically:

use waterui::prelude::*;

fn settings_grid() -> impl View {
    grid(2, [
        row((text("Name"), text("Alice"))),
        row((text("Age"), text("30"))),
        row((text("City"), text("SF"))),
    ])
}

Grid customisation

use waterui::prelude::*;
use waterui::layout::grid::{Grid, GridRow};

fn three_col(rows: Vec<GridRow>) -> impl View {
    Grid::new(3, rows)
        .spacing(16.0)
        .alignment(Alignment::Leading)
}

Default spacing is 8pt in both directions, and default alignment is Center. The grid sizes columns equally based on the available width; row heights are determined by the tallest item in each row.

Overlay

An Overlay layers content on top of a base view without changing the base’s layout sizing. Use it for badges, highlights, and decorations:

use waterui::prelude::*;

fn avatar_with_badge(avatar: impl View, dot: impl View) -> impl View {
    overlay(avatar, dot).alignment(Alignment::TopTrailing)
}

Unlike zstack, the overlay’s size is determined entirely by the base child. The overlay content is positioned within those bounds according to the alignment.

Note: If you need both children to contribute to the overall size, use zstack instead. Overlay is for decorations that should not affect layout.

Background

background() renders a view behind another view. The content child determines the size, and the background fills those bounds:

use waterui::prelude::*;

fn highlighted() -> impl View {
    background(text("Foreground content"), Blue)
}

Absolute positioning

For the rare cases where stacks and grids are not enough — floating action buttons, custom popovers, canvas-like UIs — use absolute with the PositionExt extensions:

use waterui::prelude::*;

fn floating_ui(fab: impl View) -> impl View {
    absolute((
        Color::grey(),
        text("Center").position_in(UnitPoint::CENTER),
        fab.position_in_offset(
            UnitPoint::BOTTOM_TRAILING,
            UnitPoint::BOTTOM_TRAILING,
            -16.0,
            -16.0,
        ),
    ))
}

Positioning methods

The PositionExt trait provides these methods on any View:

MethodDescription
.position(x, y)Center at absolute coordinates
.position_anchor(anchor, x, y)Anchor point at absolute coordinates
.position_in(unit)Center at fractional parent position
.position_in_anchor(anchor, pos)Anchor at fractional parent position
.position_in_offset(anchor, pos, dx, dy)Fractional position plus offset
.pin(constraints)Edge-based pinning

UnitPoint constants

UnitPoint uses normalised coordinates (0.0 to 1.0):

UnitPoint::TOP_LEADING     // (0.0, 0.0)
UnitPoint::TOP             // (0.5, 0.0)
UnitPoint::TOP_TRAILING    // (1.0, 0.0)
UnitPoint::LEADING         // (0.0, 0.5)
UnitPoint::CENTER          // (0.5, 0.5)
UnitPoint::TRAILING        // (1.0, 0.5)
UnitPoint::BOTTOM_LEADING  // (0.0, 1.0)
UnitPoint::BOTTOM          // (0.5, 1.0)
UnitPoint::BOTTOM_TRAILING // (1.0, 1.0)

Pin constraints

Pin-based positioning uses edge distances to compute position and size. This is useful when you want a child to stretch between edges or sit at a fixed offset from a corner:

use waterui::prelude::*;
use waterui::layout::PinConstraints;

fn fill_with_inset(child: impl View) -> impl View {
    child.pin(PinConstraints::all(12.0))
}

fn corner_badge(badge: impl View) -> impl View {
    badge.pin(
        PinConstraints::new()
            .trailing(12.0)
            .bottom(12.0)
            .width(28.0)
            .height(28.0),
    )
}

When both leading and trailing are set, the width is computed automatically. The same applies to top and bottom. Explicit .width() and .height() override the computed dimensions.

StretchAxis

Every view has an associated StretchAxis that tells parent layouts whether it wants to expand along one or both axes. Understanding this concept helps you predict how views behave inside stacks:

pub enum StretchAxis {
    None,       // content-sized (e.g. Text, Button)
    Horizontal, // expands width (e.g. TextField, Slider, VStack)
    Vertical,   // expands height
    Both,       // fills available space (e.g. ScrollView, Absolute)
    MainAxis,   // expands along parent stack's main axis (Spacer)
    CrossAxis,  // expands along parent stack's cross axis
}

Stacks use this information to distribute surplus space. For example, Spacer reports MainAxis, so in an HStack it expands horizontally and in a VStack it expands vertically. A TextField reports Horizontal, so it fills available width but keeps its intrinsic height.

Tip: If a view is not expanding as you expect, check its stretch axis. A Text view never stretches; a TextField stretches horizontally; a ScrollView stretches in both directions.

Dynamic stacks via for_each

Stacks support dynamic children through for_each. Instead of a fixed tuple, you provide a reactive collection and a generator that returns one view per element:

use waterui::prelude::*;
use waterui::reactive::collection::List as ReactiveList;

#[derive(Clone)]
struct TodoItem { id: i32, title: String }

impl Identifiable for TodoItem {
    type Id = i32;
    fn id(&self) -> i32 { self.id }
}

fn todo_list(items: ReactiveList<TodoItem>) -> impl View {
    VStack::for_each(items, |item| text(item.title))
        .spacing(8.0)
        .alignment(HorizontalAlignment::Leading)
}

This integrates with the LazyContainer system for efficient rendering of large collections. See the Lists and collections chapter for the full story.

Layout tips

  1. Start with stacks. Most layouts can be expressed as nested vstack and hstack calls. Use spacer() to distribute remaining space.
  2. Use Frame only when needed. Text and controls have natural sizes; apply explicit frames only for fixed-size regions or constraints.
  3. Prefer Alignment over manual positioning. Stack alignment handles most needs. Reserve absolute for truly free-form layouts.
  4. Logical pixels everywhere. Backends handle screen density for you.
  5. Inspect StretchAxis when something refuses to fit. It tells you whether a view will fight for or yield space.

With layout under your belt, you are ready to make your interfaces interactive. In the next chapter, you will learn about buttons, toggles, sliders, and other controls that let users take action.

Buttons and controls

In this chapter, you will:

  • Wire button actions to reactive state with .action() and the State<T> extractor
  • Use Toggle, Slider, Stepper, and TextField for primary user input
  • Apply button styles to convey hierarchy (primary vs. secondary actions)
  • Build dropdown menus from labels and nested submenus

Imagine you are building a settings screen. You need toggles for on/off preferences, a slider for brightness, a text field for a username, and buttons to save or cancel. WaterUI provides a comprehensive set of interactive controls for exactly these scenarios. Each control follows a consistent pattern: you create it with a constructor or convenience function, configure it with builder methods, and wire it to reactive state through bindings.

WaterUI controls preview with buttons toggle slider stepper and progress

A Hydrolysis preview of WaterUI controls rendered from real bindings. Example source.

Button

Buttons are the most common interactive element. WaterUI buttons support multiple action patterns, custom labels, and visual styles.

Simple action

The simplest button takes a label and a closure with no arguments:

use waterui::prelude::*;

fn dismiss() -> impl View {
    button("Dismiss").action(|| {
        // Handle click.
    })
}

Reactive state via .state() and State<T>

Most real buttons need to mutate some reactive state — for example, increment a counter. The pattern is two-sided: pass the binding into the action through State<T> extractors, then attach the binding to the button’s environment with .state():

use waterui::prelude::*;

fn increment(counter: &Binding<i32>) -> impl View {
    button("Increment")
        .action(|State(count): State<Binding<i32>>| {
            count.set(count.get() + 1);
        })
        .state(counter)
}

Chain several .state() calls when an action needs more than one binding. They line up positionally with the State<T> parameters in the action closure:

use waterui::prelude::*;

fn reset(x: &Binding<i32>, y: &Binding<i32>) -> impl View {
    button("Reset")
        .action(|State(x): State<Binding<i32>>, State(y): State<Binding<i32>>| {
            x.set(0);
            y.set(0);
        })
        .state(x)
        .state(y)
}

Note: .state() is a ViewExt method that injects the value into the button’s local environment. Inside the action, the State<T> extractor pulls it back out by type and position. This avoids manual clone() dances at every call site and keeps the binding free of accidental capture.

Environment extraction

Any value already present in the environment can be extracted directly — no State wrapper needed. For example, the navigation controller is injected by NavigationStack:

use waterui::prelude::*;
use waterui::navigation::NavigationController;

fn back_button() -> impl View {
    button("Go Back").action(|nav: NavigationController| nav.pop())
}

You can mix State<T> parameters with environment extractors in the same action; the extraction order follows the parameter order.

Async actions

Use action_async when the handler needs to await something. The future is spawned on the local executor, so it can await network calls or file I/O:

use waterui::prelude::*;

async fn fetch_from_server() -> String { unimplemented!() }

fn fetch_button(result: &Binding<String>) -> impl View {
    button("Fetch Data")
        .action_async(|State(result): State<Binding<String>>| async move {
            let data = fetch_from_server().await;
            result.set(data);
        })
        .state(result)
}

Button styles

ButtonStyle controls visual emphasis. Choose the right style to communicate the importance of an action:

StyleDescription
AutomaticPlatform default (default)
PlainNo background or border
LinkHyperlink appearance
BorderlessNo visible border, hover/press effects
BorderedSubtle border, for secondary actions
BorderedProminentFilled background, for primary actions

Apply with .style() or convenience methods:

use waterui::prelude::*;

fn cta_row() -> impl View {
    hstack((
        button("Primary").bordered_prominent().action(|| {}),
        button("Secondary").bordered().action(|| {}),
        button("Subtle").plain().action(|| {}),
        button("Learn More").link().action(|| {}),
    ))
}

Tip: Use bordered_prominent for the main call-to-action on a screen, and bordered or plain for secondary actions. This creates a clear visual hierarchy.

Custom labels

button(...) accepts any value that converts into a semantic Label, including raw strings. For richer content — an icon plus text, for instance — build a Label:

use waterui::prelude::*;
use waterui::icon::system_icon;

fn add_button() -> impl View {
    button(label("Add Item").icon(system_icon::plus())).action(|| {})
}

Buttons inside a Menu must use a semantic label or set accessibility_label, otherwise the menu cannot announce them to assistive technology.

Toggle

Toggle is a boolean switch backed by a Binding<bool>. It is the natural choice for any on/off setting — Wi-Fi, dark mode, or notification preferences:

use waterui::prelude::*;

fn settings(is_enabled: &Binding<bool>, dark_mode: &Binding<bool>) -> impl View {
    vstack((
        // With a label.
        toggle("Wi-Fi", is_enabled),
        // Without a label.
        Toggle::new(dark_mode),
    ))
}

Toggle styles

pub enum ToggleStyle {
    Automatic, // platform default
    Switch,    // sliding pill
    Checkbox,  // square with checkmark
}

Apply with .style():

use waterui::prelude::*;

fn dark_mode_switch(dark: &Binding<bool>) -> impl View {
    Toggle::new(dark)
        .label("Dark Mode")
        .style(ToggleStyle::Switch)
}

Layout behavior

With a label, Toggle expands horizontally to fill available space, placing the label on the leading edge and the switch on the trailing edge. Without a label, it is content-sized.

Slider

Slider lets users select a value from a continuous range by dragging a thumb. The constructor takes the binding directly; the range defaults to 0.0..=1.0 and is overridden with .range(...):

use waterui::prelude::*;

fn volume_slider(volume: &Binding<f64>) -> impl View {
    slider(volume).range(0.0..=100.0)
}

Labels

use waterui::prelude::*;

fn brightness_slider(brightness: &Binding<f64>) -> impl View {
    slider(brightness)
        .label("Brightness")
        .min_value_label("Dark")
        .max_value_label("Bright")
}

Layout behavior

Slider expands horizontally to fill available space but has a fixed height. In an hstack, it takes up all remaining width after other views are sized:

use waterui::prelude::*;

fn volume_row(volume: &Binding<f64>) -> impl View {
    hstack((
        text("Volume"),
        slider(volume).range(0.0..=100.0),
    ))
}

Stepper

Stepper provides +/- buttons for incrementing or decrementing an i32 value. Use it for precise, discrete adjustments — picking a quantity, setting a timer:

use waterui::prelude::*;

fn quantity_stepper(quantity: &Binding<i32>) -> impl View {
    stepper(quantity)
}

Configuration

use waterui::prelude::*;

fn item_stepper(count: &Binding<i32>) -> impl View {
    stepper(count)
        .label("Items")
        .range(1..=10)
        .step(1)
}

By default, the stepper displays the current value as its label. Use .label(...) to replace it with custom content, or .value_formatter(...) to customise how the value is rendered:

use waterui::prelude::*;

fn temperature_stepper(temperature: &Binding<i32>) -> impl View {
    stepper(temperature)
        .value_formatter(|v| format!("{v}°C"))
        .range(-20..=50)
        .step(5)
}

Layout behavior

With a label, Stepper expands horizontally. The label sits on the leading edge and the +/- buttons on the trailing edge, with flexible space between.

TextField

TextField is a single-line text input field backed by a Binding<Str>. You will use it for usernames, search queries, email addresses, and any short text input:

use waterui::prelude::*;

fn username_field(username: &Binding<Str>) -> impl View {
    vstack((
        // With a label.
        field("Username", username),
        // Without a label, with a placeholder prompt.
        TextField::new(username).prompt("Enter your name"),
    ))
}

Styled text binding

For rich text editing, bind to a StyledStr directly:

use waterui::prelude::*;
use waterui::text::styled::StyledStr;

fn rich_field(value: &Binding<StyledStr>) -> impl View {
    TextField::styled(value)
}

Multi-line input

TextField defaults to a single line. Set a higher line limit, or disable it entirely, for paragraph-style entry:

use waterui::prelude::*;

fn notes(notes: &Binding<Str>) -> impl View {
    TextField::new(notes).line_limit(5)
}

fn unbounded(notes: &Binding<Str>) -> impl View {
    TextField::new(notes).disable_line_limit()
}

Custom selection menu

.selection_menu(...) adds custom actions to the text selection context menu. It accepts any MenuView — most commonly a tuple of buttons or Menu instances:

use waterui::prelude::*;

fn field_with_menu(value: &Binding<Str>) -> impl View {
    TextField::new(value).selection_menu((
        button("Custom Action").action(|| {}),
    ))
}

Layout behavior

TextField expands horizontally to fill available space but has a fixed height. This makes it work naturally in forms and hstack layouts.

Menu displays a popup of commands when its label is tapped. The items argument is any MenuView — most commonly a tuple of Button, nested Menu, or Divider:

use waterui::prelude::*;

fn options_menu(selected: &Binding<String>) -> impl View {
    Menu::new(
        "Options",
        (
            button("Copy").action(|| {}),
            button("Paste")
                .action(|State(s): State<Binding<String>>| s.set("Pasted".into()))
                .state(selected),
            Divider,
            Menu::new(
                "More",
                (button("Reset").action(|| {}),),
            ),
        ),
    )
}

The first argument is the menu’s semantic label (any IntoLabel). Buttons inside a menu convert automatically into MenuItem::Commands.

Control summary

Here is a quick reference of the controls covered in this chapter and how they behave in layout:

ControlBinding typeStretchPurpose
ButtonAction closureNoneTrigger actions
ToggleBinding<bool>HorizontalOn/off switch
SliderBinding<f64>HorizontalContinuous range selection
StepperBinding<i32>HorizontalDiscrete value adjustment
TextFieldBinding<Str> / Binding<StyledStr>HorizontalSingle-line text input
MenuAction closuresNonePopup of commands

These controls are the building blocks, but what if you need to collect several pieces of data at once? In the next chapter, you will learn how WaterUI’s form system can automatically generate an entire editing UI from a Rust struct.

Forms and data entry

In this chapter, you will:

  • Generate a complete form UI from a Rust struct with #[derive(FormBuilder)]
  • Understand how Rust types map to UI controls automatically
  • Use pickers, date pickers, color pickers, and secure fields
  • Validate user input with composable validators
  • Build a registration form from scratch

Every app that collects user data needs forms — registration screens, settings panels, profile editors. Building these by hand means wiring up a text field for each string, a toggle for each boolean, a stepper for each number. WaterUI’s form system solves this by generating UI controls from your Rust data structures. Derive a single trait, and your struct becomes an editable form.

WaterUI form preview with field toggle stepper slider and accent color swatch

A Hydrolysis preview of stable WaterUI data-entry controls used by forms. Example source.

The FormBuilder trait

The FormBuilder trait is the foundation of the form system. It maps a type to a view that can edit a Binding of that type:

pub trait FormBuilder: Sized {
    type View: View;

    fn view<L: IntoLabel>(
        binding: &Binding<Self>,
        label: L,
        placeholder: Str,
    ) -> Self::View;

    fn binding() -> Binding<Self>
    where
        Self: Default + Clone,
    {
        Binding::default()
    }
}

You can implement this trait manually for full control, but in most cases the derive macro does the work for you.

The derive macro

Annotate your struct with #[derive(FormBuilder)], and each field generates an appropriate control:

use waterui::prelude::*;

#[derive(Default, Clone, Debug, FormBuilder, Project)]
pub struct UserProfile {
    /// Display name
    pub name: String,
    /// Account active status
    pub active: bool,
    /// User's current level
    pub level: i32,
}

That is it — three lines of fields, and WaterUI knows how to render a text field, a toggle, and a stepper. The derive macro relies on the Project derive to expose per-field bindings; you can either derive both or use the #[form] attribute, which derives Default, Clone, Debug, FormBuilder, and Project in one step.

Rendering a form

Use the form() function to create a view from a binding:

use waterui::prelude::*;

#[derive(Default, Clone, Debug, FormBuilder, Project)] struct UserProfile { name: String }
fn profile_editor() -> impl View {
    let profile = UserProfile::binding();
    form(&profile)
}

Pre-fill with initial data by constructing the binding directly:

use waterui::prelude::*;

#[derive(Default, Clone, Debug, FormBuilder, Project)] struct UserProfile { name: String }
fn edit_profile(initial: UserProfile) -> impl View {
    let profile = Binding::container(initial);
    form(&profile)
}

Tip: Try it yourself — define a struct with a mix of String, bool, and i32 fields, derive the form, and watch the controls appear.

Type-to-component mapping

The derive macro maps Rust types to controls automatically:

Rust typeUI componentNotes
StringTextFieldDoc comment becomes placeholder
StrTextFieldWaterUI’s interned string type
boolToggleSwitch-style control
i32 (and other integers)StepperWith +/- buttons
f64 / f32SliderRange 0.0..=1.0 by default
ColorColorPickerPlatform-native color selector

Field labels and placeholders

The derive macro converts each field name from snake_case to a "Title Case" label. A doc comment on the field becomes the placeholder argument passed to FormBuilder::view:

use waterui::prelude::*;

#[form]
pub struct ContactForm {
    /// Enter your email address
    pub email: String,
}

For a String field, that doc comment surfaces as the TextField’s prompt.

Numeric fields

  • i32 (and other integer widths) maps to a Stepper with the full i32::MIN..=i32::MAX range.
  • f64 and f32 map to a Slider with range 0.0..=1.0.

If you need different ranges or formatting, use a manual implementation (below).

Color fields

Color fields produce a ColorPicker with platform-native UI:

use waterui::prelude::*;

#[form]
pub struct ThemeConfig {
    pub accent_color: Color,
}

Manual form implementation

For custom layouts or fields outside the automatic mapping, implement FormBuilder yourself:

use waterui::prelude::*;
use waterui::form::secure::{Secure, secure};

#[derive(Clone, Project)]
struct LoginForm {
    username: String,
    password: Secure,
}

impl FormBuilder for LoginForm {
    type View = waterui::layout::stack::VStack<((waterui::component::TextField, waterui::form::SecureField),)>;

    fn view<L: IntoLabel>(binding: &Binding<Self>, label: L, placeholder: Str) -> Self::View {
        // Project the struct binding into per-field bindings.
        let projected = binding.project();
        vstack((
            <String as FormBuilder>::view(&projected.username, label, placeholder),
            secure("Password", &projected.password),
        ))
    }
}

The key trick is Project. Deriving it (or using #[form]) gives you a LoginForm::project(binding) helper that returns a struct of per-field Bindings, so each control sees only the slice of state it needs.

Individual form controls

Beyond the automatic mapping, WaterUI provides specialised controls for specific data entry tasks. You can use these in both auto-generated and manually built forms.

Color picker

ColorPicker provides a platform-native color selection interface:

use waterui::prelude::*;
use waterui::form::picker::color::ColorPicker;

fn accent_picker(accent: &Binding<Color>) -> impl View {
    ColorPicker::new("Accent Color", accent).with_alpha()
}

.with_alpha() enables the alpha channel; .with_hdr() enables HDR color selection.

Date picker

DatePicker adapts to the bound type — jiff::civil::Date, Time, or DateTime — and supports several picker layouts:

use waterui::prelude::*;
use waterui::form::picker::date::{DatePicker, DatePickerType};
use jiff::civil::Date;

fn birthday_picker(date: &Binding<Date>) -> impl View {
    DatePicker::new(date)
        .label("Birthday")
        .ty(DatePickerType::Date)
}

Date picker types:

TypeShows
DatePickerType::DateDate only
HourAndMinuteHour and minute
HourMinuteAndSecondHour, minute, and second
DateHourAndMinuteDate, hour, and minute
DateHourMinuteAndSecondDate, hour, minute, and second

Picker (selection list)

Picker lets users select from a list of options. Each item is a text(label).tag(value) — the label is what the user sees, the tag is the value written back into the binding:

use waterui::prelude::*;
use waterui::form::picker::{Picker, PickerStyle};

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Plan { Free, Pro, Team }

fn plan_picker(selection: &Binding<Plan>) -> impl View {
    let items = vec![
        text("Free").tag(Plan::Free),
        text("Pro").tag(Plan::Pro),
        text("Team").tag(Plan::Team),
    ];
    Picker::new(items, selection).style(PickerStyle::Menu)
}

Picker styles:

StyleAppearance
AutomaticPlatform default (segmented on iOS)
MenuDropdown menu button
RadioVertical radio button group

Secure field

SecureField masks input and uses automatic memory zeroing (via zeroize) for password-grade security. Use it for passwords, API keys, and other sensitive data:

use waterui::prelude::*;
use waterui::form::secure::{Secure, secure};

fn password_field(password: &Binding<Secure>) -> impl View {
    secure("Password", password)
}

The Secure type wraps a String with:

  • Display redaction: Debug output shows Secure(****).
  • Memory zeroing: the inner string is zeroed on drop.
  • Hashing helper: .hash() produces a bcrypt hash.

Warning: Never store raw passwords. Always use .hash() before persisting to a database or sending over the network.

Building a registration form

Here is a complete example that ties auto-generation, a submit button, and reactive state together:

use waterui::prelude::*;

#[form]
pub struct Registration {
    pub username: String,
    pub email: String,
    pub age: i32,
    pub newsletter: bool,
}

fn registration_form() -> impl View {
    let form_data = Registration::binding();

    vstack((
        text("Create Account").title(),

        // Auto-generated form
        form(&form_data),

        // Submit button: capture the form state and read it on click.
        button("Register")
            .bordered_prominent()
            .action(|State(data): State<Binding<Registration>>| {
                let registration = data.get();
                waterui::log::info!(
                    username = %registration.username.as_str(),
                    "registration submitted"
                );
            })
            .state(&form_data),
    ))
}

Validation

A form is only as good as the data it collects. WaterUI provides a composable validation system through the Validator trait and the Validatable extension. Range<T>, regex::Regex, and the marker Required come out of the box:

use waterui::prelude::*;
use waterui::form::valid::{Required, Validator};
use regex::Regex;

fn build_validators() {
    // Range validator (note: `Range<T>`, exclusive end)
    let age_validator = 18i32..100;
    assert!(age_validator.validate(42).is_ok());

    // Regex validator (validates `&str` and `String`).
    let email_validator = Regex::new(r"^[^@]+@[^@]+\.[^@]+$")
        .expect("email validator regex must compile");
    assert!(email_validator.validate("[email protected]").is_ok());

    // Combine validators with `.and()` and `.or()`.
    let required_email = Required.and(
        Regex::new(r"^[^@]+@[^@]+\.[^@]+$")
            .expect("email validator regex must compile"),
    );
    assert!(required_email.validate("").is_err());
}

Built-in validators

ValidatorValidates
Range<T>Value falls within start..end (exclusive end)
RegexString matches a regular expression
RequiredValue is Some(...) for Option<T>, or non-empty for &str

Combinators

  • validator_a.and(validator_b) — both must pass; short-circuits on first failure.
  • validator_a.or(validator_b) — at least one must pass.

ValidatableView

Wrap a form control with validation to get automatic error display:

use waterui::prelude::*;
use waterui::form::valid::ValidatableView;
use regex::Regex;

fn validated_email(value: &Binding<Str>) -> impl View {
    ValidatableView::new(
        TextField::new(value),
        Regex::new(r"^[^@]+@[^@]+\.[^@]+$").unwrap(),
    )
}

ValidatableView filters the binding (rejecting invalid values from being committed) and displays the validation error message below the control.

Accessing form data

The binding returned by FormBuilder::binding() provides field-level access through projection:

use waterui::prelude::*;

#[form] pub struct Registration { pub username: String }
fn show_summary() -> impl View {
    let form_data = Registration::binding();
    let projected = Registration::project(&form_data);

    vstack((
        // Read a field value.
        text(projected.username.get()),
        // Display reactive values.
        text!("Name: {username}", username = projected.username.clone()),
    ))
}

Form layout tips

  1. Use vstack for vertical forms. Stack form controls vertically for a natural settings-screen layout.
  2. Mix auto-generated and manual controls. Use form() for the basic fields, then add custom controls (pickers, buttons) manually around it.
  3. Validate before submission. Use the Validator combinators to check all fields before processing the form data.
  4. Pre-fill with initial data. Pass an initial struct to Binding::container() instead of relying on Default.

You now know how to collect structured data from users. But what about displaying collections of data back to them? In the next chapter, you will learn how to render dynamic lists and collections efficiently.

Lists and collections

In this chapter, you will:

  • Display dynamic collections with List::for_each and the Identifiable trait
  • Use waterui::reactive::collection::List for reactive, fine-grained collection updates
  • Group rows with the new ListSection semantic marker
  • Build a complete contacts list with add and remove operations

Every app needs a way to show lists of data — a chat thread, a to-do list, a feed of posts, a directory of contacts. Unlike a fixed set of views you write by hand, these collections grow and shrink at runtime as data changes. WaterUI provides List<V> as the native list surface, for_each to map collections to views, and the new ListSection marker to group rows under semantic headers.

WaterUI list preview with section headers rows detail rows and footer

A Hydrolysis preview of sectioned WaterUI lists. Example source.

A first dynamic list

List::for_each is the bridge between a data collection and the rows on screen. You give it a collection and a generator that returns one ListItem per element:

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListItem};

#[derive(Clone)]
struct TodoItem {
    id: i32,
    title: String,
    done: bool,
}

impl Identifiable for TodoItem {
    type Id = i32;
    fn id(&self) -> i32 { self.id }
}

fn todo_list(items: Vec<TodoItem>) -> impl View {
    List::for_each(items, |item| {
        ListItem::new(hstack((
            text(item.title),
            spacer(),
            if item.done { text("Done") } else { text("Pending") },
        )))
    })
}

List is a native, scrolling, platform-styled surface. On iOS it renders as an inset-grouped UITableView; on macOS as an NSTableView with group rows; on Material backends as a list with section dividers.

The Identifiable trait

Every item in a for_each generator must implement Identifiable. The trait provides a stable identity for each element so the framework can efficiently diff, insert, and remove rows when the collection changes:

use core::hash::Hash;
pub trait Identifiable {
    type Id: Hash + Ord + Clone;
    fn id(&self) -> Self::Id;
}

Common Id types are integers, UUIDs, and string keys.

Warning: The identity must be stable — the same data item should always return the same id. Changing an item’s id forces the framework to treat it as a removal followed by an insertion, which is more expensive than an in-place update.

Reactive collections with waterui::reactive::collection::List

A plain Vec works for static data, but lists usually change at runtime. The List<T> type from waterui::reactive::collection is a reactive collection: every mutation emits a fine-grained change notification that the UI observes:

use waterui::reactive::collection::List as ReactiveList;
#[derive(Clone)] struct TodoItem { id: i32, title: String, done: bool }

fn seed() {
    let items: ReactiveList<TodoItem> = ReactiveList::new();

    items.push(TodoItem { id: 1, title: "Buy milk".into(), done: false });
    items.push(TodoItem { id: 2, title: "Write docs".into(), done: false });

    let _ = items.pop();
    items.insert(0, TodoItem { id: 3, title: "Urgent".into(), done: false });
}

Note: Both the rendering surface and the data backend are called List. To keep them apart in code, alias one of them — this chapter uses ReactiveList for the data type and the bare List for the view.

Initialise from a Vec:

use waterui::reactive::collection::List as ReactiveList;
#[derive(Clone)] struct TodoItem { id: i32, title: String, done: bool }
fn from_seed(item1: TodoItem, item2: TodoItem) {
    let _ = ReactiveList::from(vec![item1, item2]);
}

Sectioned lists

The pinned waterui adds a ListSection semantic marker so a single List can express multiple logical groups. Mark the first row of a section with .section(ListSection::new("Header")) and subsequent items belong to that section until another marker is encountered:

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListItem, ListSection};

#[derive(Clone)]
struct ContactRow {
    id: i32,
    name: String,
    section: Option<&'static str>,
}

impl Identifiable for ContactRow {
    type Id = i32;
    fn id(&self) -> i32 { self.id }
}

fn directory(contacts: Vec<ContactRow>) -> impl View {
    List::for_each(contacts, |contact| {
        let mut row = ListItem::new(text(contact.name.clone()));
        if let Some(section) = contact.section {
            row = row.section(ListSection::new(section));
        }
        row
    })
}

ListSection carries an optional header label and footer. Use ListSection::unlabeled() for a visual divider with no header, and .footer(...) to append a caption-style note below the section. The visual treatment is delegated to the platform — iOS renders inset-grouped sections, macOS uses NSTableView group rows, and Material backends translate the marker into section dividers.

Tip: Reach for ListSection whenever a list contains more than one kind of row. Grouping rows by topic is exactly the use case the upstream dev branch added the marker for.

Editing: delete and reorder

List exposes editing(...), on_delete(...), and on_move(...) builders. The handlers pull ListDelete (a row index) and ListMove (from/to indices) from the environment so you can dispatch on them just like any other extractor:

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListDelete, ListItem, ListMove};
use waterui::reactive::collection::List as ReactiveList;

#[derive(Clone)] struct Contact { id: i32 }
impl Identifiable for Contact { type Id = i32; fn id(&self) -> i32 { self.id } }
fn editable(items: ReactiveList<Contact>) -> impl View {
    let editing = Binding::bool(false);
    List::for_each(items.clone(), |item| ListItem::new(text(item.id.to_string())))
        .editing(editing.clone())
        .on_delete(
            move |State(items): State<ReactiveList<Contact>>, ListDelete(index): ListDelete| {
                items.remove(index);
            },
        )
        .on_move(
            move |State(items): State<ReactiveList<Contact>>, ListMove(movement): ListMove| {
                let _ = (items, movement);
                // perform reorder on the reactive list
            },
        )
        .state(&items)
}

Per-row controls — ListItem::deletable(false), for instance — refine the behavior on a row-by-row basis.

Static lists from iterators

When the data is genuinely static, you can collect any iterator of views into a VStack:

use waterui::prelude::*;

fn fruit_list() -> impl View {
    let names = ["Apple", "Banana", "Cherry"];
    let stack: VStack<_> = names.into_iter().map(text).collect();
    stack
}

This produces a VStack with default 10pt spacing. There is no virtualization here — every item is laid out at once — so prefer List::for_each plus a reactive collection for anything that might grow.

Building a complete list

Here is a contacts screen with a reactive list, a typed Contact model, and an add button:

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListItem};
use waterui::reactive::collection::List as ReactiveList;

#[derive(Clone)]
struct Contact { id: i32, name: String }

impl Identifiable for Contact {
    type Id = i32;
    fn id(&self) -> i32 { self.id }
}

fn contacts_screen() -> impl View {
    let contacts: ReactiveList<Contact> = ReactiveList::from(vec![
        Contact { id: 1, name: "Alice".into() },
        Contact { id: 2, name: "Bob".into() },
    ]);
    let next_id = Binding::i32(3);

    vstack((
        text("Contacts").title(),

        // Add button: capture both bindings via State<T>.
        button("Add Contact")
            .action(
                |State(contacts): State<ReactiveList<Contact>>,
                 State(next_id): State<Binding<i32>>| {
                    let id = next_id.get();
                    contacts.push(Contact { id, name: format!("Contact {id}") });
                    next_id.set(id + 1);
                },
            )
            .state(&contacts)
            .state(&next_id),

        // The list itself.
        List::for_each(contacts, |contact| ListItem::new(text(contact.name))),
    ))
}

Tip: Try extending this example by adding a delete button next to each contact. You can either use the on_delete handler shown above, or attach a per-row button that captures the contact’s id and removes it manually.

Performance considerations

  1. Use waterui::reactive::collection::List<T> for mutable collections. It emits fine-grained change notifications. A plain Vec is suitable only for static data.
  2. Keep Identifiable::id() stable. Changing an item’s id forces a removal + insertion instead of an in-place update.
  3. Wrap with a List for backend virtualization. List::for_each produces a native list surface that can lazily realise rows.
  4. Avoid expensive closures in the generator. It is invoked for each visible row. Push expensive computation into async tasks or cached Computed values.
  5. Group with ListSection, not extra stacks. A single List with sections gives the platform full control over chrome and scroll performance; nested stacks defeat virtualization.

You can now display any dynamic data set. But what if you need to show different views depending on a condition — a loading spinner while data loads, or a login prompt when the user is not authenticated? That is the topic of the next chapter.

Conditional rendering

In this chapter, you will:

  • Show and hide views reactively with the when function
  • Chain conditions with .or() and .otherwise() for multi-branch logic
  • Derive boolean conditions from signals using .map() and .equal_to()
  • Pick between when and match + .anyview() for complex branching

Think about the screens in a typical app: a loading spinner while data fetches, a “Welcome back!” message when the user is logged in, a “Please log in” prompt when they are not. Your UI needs to show different things based on conditions that can change at any moment. WaterUI provides the when function for exactly this — unlike Rust’s built-in if/else (which evaluates once at build time), when creates reactive branches that automatically swap views as conditions change.

Basic usage

when takes a reactive boolean condition and a builder closure that returns the view to show when the condition is true:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn maybe_message(show_message: &Binding<bool>) -> impl View {
    when(show_message.clone(), || text("This message is visible!"))
}

When show_message is false, nothing is rendered. The UI updates automatically whenever the binding changes.

Adding a fallback with .otherwise()

Use .otherwise() to provide an alternative view when the condition is false:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn login_state(is_logged_in: &Binding<bool>) -> impl View {
    when(is_logged_in.clone(), || text("Welcome back!"))
        .otherwise(|| text("Please log in"))
}

This is the reactive equivalent of an if/else expression — but it responds to signal changes at runtime.

Chaining conditions with .or()

For multi-branch logic (analogous to if/else if/else), chain .or() calls. Each .or() adds another conditional branch; the chain must end with .otherwise():

use waterui::prelude::*;
use waterui::widget::condition::when;

fn status_text(state: &Binding<i32>) -> impl View {
    when(state.equal_to(0), || text("Loading..."))
        .or(state.equal_to(1), || text("Ready"))
        .or(state.equal_to(2), || text("Error"))
        .otherwise(|| text("Unknown state"))
}

The first matching condition wins — subsequent branches are not evaluated.

Note: Think of this as a reactive match. The conditions are checked in order, and only the first matching branch renders.

Condition types

when accepts any type that implements IntoComputed<bool>. In practice you will use a handful of common patterns.

Binding<bool>

The simplest case — a boolean binding directly:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn visible(show: &Binding<bool>) -> impl View {
    when(show.clone(), || text("Visible"))
}

Negated binding

Binding<bool> implements Not, which produces a new signal:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn hidden(show: &Binding<bool>) -> impl View {
    // Show only when the binding is `false`.
    when(!show.clone(), || text("Hidden content revealed"))
}

Derived Computed<bool>

Any Computed<bool> works as a condition. Build one with SignalExt::map:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn positive_indicator(count: &Binding<i32>) -> impl View {
    let is_positive = count.map(|n| n > 0).computed();
    when(is_positive, || text("Count is positive"))
}

Derived conditions with SignalExt

SignalExt ships with comparison helpers that produce Computed<bool> directly. They are the most readable way to turn a value into a condition:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn name_status(name: &Binding<Str>) -> impl View {
    when(name.is_empty(), || text("Please enter your name"))
        .otherwise(|| text!("Hello, {name}!"))
}

.equal_to() for value comparison

use waterui::prelude::*;
use waterui::widget::condition::when;

fn tab_content(selected_tab: &Binding<i32>) -> impl View {
    when(selected_tab.equal_to(0), || text("Home"))
        .or(selected_tab.equal_to(1), || text("Settings"))
        .otherwise(|| text("Unknown tab"))
}

Static bool

Plain bool values also work. When all conditions in a chain are static booleans, the framework picks the matching branch at construction time, so the unused branches never cost anything at runtime:

use waterui::prelude::*;
use waterui::widget::condition::when;

fn debug_only() -> impl View {
    when(cfg!(debug_assertions), || text("Debug mode"))
        .otherwise(|| text("Release mode"))
}

Tip: Use this pattern for feature flags and debug-only UI.

When to reach for .anyview() instead

when().or().otherwise() chains are great for two or three branches. For richer matching — especially when each arm constructs a different concrete view type — destructure the value with match and erase each arm with .anyview():

use waterui::prelude::*;

#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode { A, B, C }

fn render(mode: Mode) -> AnyView {
    match mode {
        Mode::A => text("Mode A").title().anyview(),
        Mode::B => button("Mode B").action(|| {}).anyview(),
        Mode::C => vstack((text("Header"), text("Body"))).anyview(),
    }
}

Use .anyview() whenever you need uniform view types across branches and the boolean ladder of when is starting to feel like an enum match.

Rendering mechanics

Understanding how when works under the hood helps you write efficient conditional views. Internally, When uses the Dynamic view to swap content:

  1. The combined condition signal re-evaluates.
  2. The framework determines which branch index matched.
  3. The previous view is removed and the matching branch’s builder is called.
  4. The new view is inserted into the tree.

Each branch closure runs every time the condition switches into that branch, so keep them lightweight. State that should survive a branch toggle must live outside the branch — typically in a Binding owned by the parent.

Patterns and examples

Show / hide with a toggle

use waterui::prelude::*;
use waterui::widget::condition::when;

fn settings_panel() -> impl View {
    let show_advanced = Binding::bool(false);
    let value = Binding::f64(0.5);

    vstack((
        toggle("Show Advanced", &show_advanced),
        when(show_advanced.clone(), {
            let value = value.clone();
            move || {
                vstack((
                    text("Advanced Settings").headline(),
                    slider(&value).range(0.0..=1.0),
                ))
            }
        }),
    ))
}

Loading states

use waterui::prelude::*;
use waterui::widget::condition::when;

fn data_view(loading: &Binding<bool>, data: &Binding<Str>) -> impl View {
    let data = data.clone();
    when(loading.clone(), || text("Loading..."))
        .otherwise(move || text!("{data}"))
}

Multi-state status indicator

use waterui::prelude::*;
use waterui::widget::condition::when;

fn status_indicator(status: &Binding<i32>) -> impl View {
    when(status.equal_to(0), || text("Idle").color(Grey))
        .or(status.equal_to(1), || text("Running").color(Green))
        .or(status.equal_to(2), || text("Warning").color(Yellow))
        .otherwise(|| text("Error").color(Red))
}

Best practices

  1. Always end chains with .otherwise(). A bare when() without .otherwise() renders nothing when the condition is false. Multi-branch chains require .otherwise() to close.
  2. Use signal combinators, not .get(). Calling .get() inside a when condition or branch closure breaks reactivity. Prefer .map(), .is_empty(), .equal_to(), and friends.
  3. Keep branch closures pure. Branches return views without side effects. They may run multiple times as conditions toggle.
  4. Prefer when over Rust if/else in view bodies. Rust’s if evaluates once at construction time; when updates as conditions change.
  5. Switch to .anyview() when branches diverge. Once you reach four or more arms, or each arm produces a different concrete view type, a match plus .anyview() is clearer than a long when chain.

Quick reference

PatternPurpose
when(cond, || view)Show view when condition is true
when(cond, || v).otherwise(|| w)If/else
when(a, || v).or(b, || w).otherwise(|| x)If/else-if/else
when(!binding, || view)Show when binding is false
when(sig.equal_to(val), || view)Compare signal to value
when(sig.map(|v| ...), || view)Derived boolean condition
match value { Mode::A => a().anyview(), ... }Multi-branch over an enum

You now have the tools to build dynamic, condition-driven interfaces. The final piece of the UI puzzle is navigation — how do you move between screens, manage a navigation stack, and organize your app with tabs? That is exactly what the next chapter covers.

Navigation

In this chapter, you will:

  • Build hierarchical navigation with NavigationStack and NavigationView
  • Push screens with NavigationLink and route values with NavigationLink::value
  • Drive the stack programmatically with NavigationPath and NavigationPathController
  • Organize your app with tabs using Tabs and Tab

As your app grows beyond a single screen, you need a way to move between them. A user taps a contact to see their details, navigates to settings, or switches between tabs. WaterUI provides a declarative navigation system that lowers to native platform patterns — UINavigationController on Apple platforms, fragment navigation on Android — giving your app a first-class feel on every platform.

WaterUI navigation preview with a titled stack and navigation links

A Hydrolysis preview of WaterUI navigation chrome and links. Example source.

NavigationStack is the container that manages a stack of navigation views. Think of it as a deck of cards: the root screen is at the bottom, and each navigation action pushes a new card on top.

use waterui::prelude::*;

fn home_screen() -> impl View { text("Home") }

fn app_root() -> impl View {
    NavigationStack::new(home_screen())
}

The root view is displayed initially. When navigation occurs (via NavigationLink or programmatically), new screens slide in on top.

Every screen in a stack is a NavigationView. It pairs a navigation bar with your content. The most ergonomic way to build one is the .title(...) modifier from ViewExt, which wraps any view in a NavigationView:

use waterui::prelude::*;

fn detail_screen(name: &str) -> NavigationView {
    vstack((
        text(name.to_string()).title(),
        text("Some detail content"),
    ))
    .title("Detail")
}

You can also call NavigationView::new(title, content) directly when you want full control over the bar.

Title display mode

Control how the title appears in the navigation bar:

use waterui::prelude::*;

fn settings(content: impl View) -> NavigationView {
    content.title("Settings").large_title()
}

fn detail(content: impl View) -> NavigationView {
    content.title("Detail").inline_title()
}

The NavigationTitleDisplayMode enum has three variants:

ModeBehavior
AutomaticSystem decides (large on root, inline when pushed)
InlineAlways small inline title
LargeLarge title that collapses on scroll

Tip: Follow platform conventions — use .large_title() on root screens and .inline_title() on pushed detail screens. This matches what users expect on iOS and macOS.

Bar slots

NavigationView exposes leading and trailing slots so you can place toolbar content beside the title:

use waterui::prelude::*;

fn toolbar_screen(content: impl View) -> NavigationView {
    content
        .title("Inbox")
        .navigation_bar_leading(button("Cancel").action(|| {}))
        .navigation_bar_trailing(button("Done").action(|| {}))
}

For full customisation — bar background color, hidden state, or a search field — set the Bar fields directly via NavigationView::new(...) and friends.

The simplest way to add push navigation is NavigationLink. It renders as a tappable element that pushes a new screen when activated:

use waterui::prelude::*;

fn settings_content() -> impl View { text("Settings") }
fn profile_content() -> impl View { text("Profile") }

fn home_screen() -> impl View {
    vstack((
        text("Home").title(),
        NavigationLink::new(
            "Go to Settings",
            || settings_content().title("Settings"),
        ),
        NavigationLink::new(
            "View Profile",
            || profile_content().title("Profile"),
        ),
    ))
}

The first argument is the label (any IntoLabel), and the second is a closure that returns the destination NavigationView when the link is tapped. The closure is a ViewBuilder, so it only runs when navigation actually occurs.

Note: NavigationLink must live inside a NavigationStack. A debug assertion fires if it is used outside a navigation context.

Programmatic navigation with NavigationPath

NavigationLink is great for simple drill-downs, but real apps need deep links, “back to root” actions, and routing from button handlers. For programmatic control, model navigation with a typed NavigationPath<T>:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)]
enum Route {
    Detail(i32),
    Settings,
}

fn detail_screen(id: i32) -> impl View { text!("Detail {id}") }
fn settings_screen() -> impl View { text("Settings") }
fn home_screen() -> impl View { text("Home") }

fn app() -> impl View {
    let path: NavigationPath<Route> = NavigationPath::new();

    NavigationStack::with(path.clone(), home_screen())
        .destination(|route| match route {
            Route::Detail(id) => detail_screen(id).title("Detail"),
            Route::Settings => settings_screen().title("Settings"),
        })
}

The destination closure maps each route value to a NavigationView. This pattern gives you type-safe routing — the compiler ensures every variant is handled.

Pushing with NavigationLink::value

When the stack is path-backed, prefer NavigationLink::value for declarative pushes. The link reads NavigationPathController<T> from the environment automatically and pushes the value when tapped:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)] enum Route { Detail(i32) }
fn home_with_links() -> impl View {
    vstack((
        text("Home").title(),
        NavigationLink::value("Show item 42", Route::Detail(42)),
    ))
}

Driving the path imperatively

NavigationPath is backed by a reactive list. Mutate it from button handlers via NavigationPathController<T>, which is automatically extracted from the environment:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)] enum Route { Detail(i32) }
fn manual_push() -> impl View {
    button("Open detail")
        .action(|controller: NavigationPathController<Route>| {
            controller.push(Route::Detail(42));
        })
}

fn back_to_root() -> impl View {
    button("Reset")
        .action(|controller: NavigationPathController<Route>| controller.clear())
}

NavigationPathController exposes push, pop, pop_n, and clear. Pre-populating a path is just as easy:

use waterui::prelude::*;
#[derive(Clone, PartialEq, Eq)] enum Route { Detail(i32), Settings }
fn prepopulated_stack() -> impl View {
    let path = NavigationPath::from(vec![Route::Settings, Route::Detail(1)]);
    NavigationStack::with(path, text("Home"))
}

Control the transition animation style on the stack:

use waterui::prelude::*;
use waterui::navigation::NavigationTransition;

#[derive(Clone, PartialEq, Eq)] enum Route { Settings }
fn fade_stack(root: impl View) -> impl View {
    let path: NavigationPath<Route> = NavigationPath::new();
    NavigationStack::with(path, root)
        .destination(|_| text("placeholder").title("placeholder"))
        .transition(NavigationTransition::Fade)
}
TransitionDescription
PushPopPlatform-standard push/pop (default)
FadeFade between screens
NoneNo transition animation

Imperative navigation with NavigationController

For navigation outside a typed path — pushing an arbitrary NavigationView directly — extract NavigationController from the environment:

use waterui::prelude::*;

fn detail_content() -> impl View { text("Detail") }

fn back_button() -> impl View {
    button("Go Back").action(|nav: NavigationController| nav.pop())
}

fn detail_button() -> impl View {
    button("Show Detail").action(|nav: NavigationController| {
        nav.push(detail_content().title("Detail"));
    })
}

NavigationController wraps a CustomNavigationController provided by the backend renderer; you typically never implement it yourself.

Tabs

Most apps organise their top-level screens with tabs. Tabs provides a tabbed container with a tab bar:

use waterui::prelude::*;
use waterui::id::{Mapping, TaggedView};
use waterui::navigation::tab::{Tab, Tabs};

fn home_content() -> impl View { text("Home") }
fn settings_content() -> impl View { text("Settings") }

fn main_app() -> impl View {
    let tabs = Mapping::new();
    let home_id = tabs.register("home");
    let settings_id = tabs.register("settings");
    let selection = Binding::container(home_id);

    Tabs::new(
        selection,
        vec![
            Tab::new(
                TaggedView::new(home_id, AnyView::new(text("Home"))),
                || home_content().title("Home"),
            ),
            Tab::new(
                TaggedView::new(settings_id, AnyView::new(text("Settings"))),
                || settings_content().title("Settings"),
            ),
        ],
    )
}

Tab structure

Each Tab consists of:

  • Label: A TaggedView<Id, AnyView> that provides both the visual tab label and a unique identifier for selection.
  • Content: A ViewBuilder that returns a NavigationView. Each tab gets its own independent navigation experience.

Tab position

Control whether the tab bar appears at the top or bottom:

use waterui::prelude::*;
use waterui::navigation::tab::{TabPosition, Tabs};
use waterui::id::{Id, Mapping};

fn placeholder_tabs() -> Vec<waterui::navigation::tab::Tab<Id>> { Vec::new() }
fn top_tabs() -> impl View {
    let tabs = Mapping::new();
    let selected = tabs.register("home");
    Tabs::new(Binding::container(selected), placeholder_tabs()).position(TabPosition::Top)
}
PositionDescription
BottomTab bar at the bottom (default)
TopTab bar at the top

Selection binding

The selection binding is a Binding<Id> that tracks the currently active tab. You can read and write it programmatically to switch tabs from anywhere in the app.

Convenience constructor

navigation(title, view) is a shortcut equivalent to NavigationView::new(title, view):

use waterui::prelude::*;

fn ad_hoc() -> NavigationView {
    navigation("Inbox", text("Empty"))
}

Putting it all together

Here is a complete app skeleton with tabs, a typed navigation path, and programmatic routing:

use waterui::prelude::*;
use waterui::id::{Mapping, TaggedView};
use waterui::navigation::tab::{Tab, Tabs};

#[derive(Clone, PartialEq, Eq)]
enum BrowseRoute {
    Item(i32),
}

fn browse_root() -> impl View {
    vstack((
        text("Browse Items").title(),
        NavigationLink::value("View item 42", BrowseRoute::Item(42)),
    ))
}

fn item_detail(id: i32) -> impl View {
    vstack((
        text(format!("Item #{id}")).headline(),
        button("Go Back")
            .action(|nav: NavigationController| nav.pop()),
    ))
}

fn profile_view() -> impl View { text("Profile Screen") }

fn app() -> impl View {
    let tabs = Mapping::new();
    let browse_id = tabs.register("browse");
    let profile_id = tabs.register("profile");
    let tab_selection = Binding::container(browse_id);

    Tabs::new(
        tab_selection,
        vec![
            Tab::new(
                TaggedView::new(browse_id, AnyView::new(text("Browse"))),
                || {
                    let path: NavigationPath<BrowseRoute> = NavigationPath::new();
                    NavigationStack::with(path, browse_root())
                        .destination(|route| match route {
                            BrowseRoute::Item(id) => item_detail(id).title("Item"),
                        })
                        .title("Browse")
                },
            ),
            Tab::new(
                TaggedView::new(profile_id, AnyView::new(text("Profile"))),
                || profile_view().title("Profile"),
            ),
        ],
    )
}
  1. Use NavigationLink for simple push navigation. It hides the NavigationController extraction for you.
  2. Use NavigationPath<T> plus NavigationLink::value for typed routing. The compiler keeps every destination in sync with every push site.
  3. Each tab gets its own navigation stack. Wrap each tab’s content in a NavigationStack (or use NavigationView directly) to give each tab an independent stack of pushed screens.
  4. Keep route types small. The type parameter T in NavigationPath<T> must be Clone + 'static. Use enums with associated data for the cleanest destination match.
  5. Use .large_title() on root screens. Following platform conventions, root screens typically use large titles that collapse on scroll, while pushed screens use inline titles.

Congratulations — you have now covered the complete Building UIs section. You know how to display text, lay out views, handle user input, build forms, render lists, conditionally show content, and navigate between screens. With these tools, you can build fully functional app interfaces. In Part IV: Rich Content, you will learn how to add media, maps, web views, and more to your apps.

Media: photos, video, and audio

In this chapter, you will:

  • Display images from the network with progressive streaming
  • Play video with native controls or build your own player UI
  • Work with Live Photos and the unified Media enum
  • Let users pick media with the platform-native MediaPicker
  • Apply GPU-accelerated image filters

Every app eventually needs to show a photo, play a video, or let the user pick something from their library. WaterUI’s media stack handles the hard parts for you: async fetching, progressive decoding, and GPU texture management.

Feature flags and crate layout

The media types live behind cargo features in the waterui crate:

[dependencies]
waterui = { version = "*", features = ["media"] }
# Or for the raw video surface only:
# waterui = { version = "*", features = ["video"] }

media enables both waterui-media and waterui-video; the picker pulls in platform dialogs through the std feature, which is on by default.

CratePurpose
waterui-mediaPhotos, Live Photos, media picker, unified Media enum, GPU Image view
waterui-videoVideo (raw) and VideoPlayer (with controls), aspect ratio, volume

waterui-media re-exports the video types, so a single import covers most apps:

use waterui::prelude::*;
use waterui::media::{Photo, Video, VideoPlayer, LivePhoto, Media};

Displaying Images with Photo

Photo is the primary component for showing images from a URL. It fetches the image asynchronously, decodes it through a streaming pipeline, and hands the pixel data to the GPU-backed Image view.

Basic Usage

use waterui::media::Photo;

fn avatar() -> impl View {
    Photo::new("https://static.rust-lang.org/logos/rust-logo-512x512.png")
}

Photo::new accepts anything that implements Into<Url>, including string literals.

Listening for Events

You can observe loading progress through the on_event callback. Because waterui::media re-exports a video Event type, alias the photo event to keep the two clearly separate:

use waterui::media::Photo;
use waterui::media::photo::Event as PhotoEvent;

fn profile_photo() -> impl View {
    Photo::new("https://static.rust-lang.org/logos/rust-logo-512x512.png")
        .on_event(|event| match event {
            PhotoEvent::Loaded => tracing::info!("Image loaded successfully"),
            PhotoEvent::Error(msg) => tracing::error!("Failed to load: {msg}"),
        })
}

photo::Event has two variants:

VariantDescription
LoadedThe image finished loading and is being displayed.
Error(String)The fetch or decode failed, with a human-readable message.

Reactive filters

Filter modifiers from ViewExt accept signals, so a Binding lets the user adjust filter values in real time without rebuilding the photo:

use waterui::prelude::*;
use waterui::media::Photo;

fn blurry_photo() -> impl View {
    let blur = Binding::f64(0.0);
    let saturation = Binding::f64(1.0);

    vstack((
        Photo::new("https://static.rust-lang.org/logos/rust-logo-512x512.png")
            .blur(blur.clone())
            .saturation(saturation.clone()),
        Slider::new(&blur).range(0.0..=20.0),
        Slider::new(&saturation).range(0.0..=2.0),
    ))
}

See Filters and Visual Effects for the full filter catalog.

Streaming / Progressive Decoding

Photo uses an ImageStreamDecoder internally. As HTTP response chunks arrive, the decoder attempts intermediate decodes at increasing byte thresholds (starting at 24 KB, stepping by 96 KB). For formats that support progressive rendering – JPEG, PNG, GIF, WebP, BMP, ICO, TIFF – you may see a lower-quality preview appear before the final image lands. This is automatic and requires no configuration.

Tip: Progressive decoding is especially valuable on slower connections. Your users see something almost immediately, which makes the app feel faster even before the full image arrives.

How Image Works Under the Hood

The Image struct holds raw pixel data (RGBA8 or RGBA16F) that gets uploaded to a GPU texture on first render. After the texture is created, the CPU-side pixel buffer is dropped, keeping memory usage lean.

use waterui::media::Image;

// Construct directly from pixel data (4 bytes per pixel, RGBA)
let pixels: Vec<u8> = vec![255, 0, 0, 255]; // 1x1 red pixel
let red_dot = Image::new(pixels, 1, 1);

For HDR content on Apple and Android platforms, WaterUI automatically selects the platform image decoder and produces RGBA16F textures. When the output surface does not support HDR, a tone-mapping shader converts the content to SDR transparently.


Video Playback

Whether you are building a media gallery, an onboarding flow with background video, or a full-featured player, WaterUI has you covered with two components:

ComponentControlsUse Case
VideoNone (raw surface)Custom player UI, background videos, decorative clips
VideoPlayerNative platform controlsStandard playback with play/pause, seek, fullscreen

The quickest way to get video playing is VideoPlayer, which comes with platform-native controls out of the box:

use waterui::media::{AspectRatio, VideoPlayer};

fn trailer() -> impl View {
    VideoPlayer::new("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
        .show_controls(true)
        .aspect_ratio(AspectRatio::Fit)
}

Configuration Methods

MethodTypeDescription
aspect_ratioAspectRatioFit (letterbox), Fill (crop), or Stretch
show_controlsboolWhether to display native playback controls
volume&Binding<Volume>Reactive volume binding; positive values are audible, negative values mute while preserving level
muted&Binding<bool>Reactive mute toggle layered on top of volume
playback_rate&Binding<f32>Reactive playback speed (1.0 = normal)
preserve_pitch&Binding<bool>Keep audio pitch constant when speed is not 1x
playback_policyPlaybackPolicyBuffering and realtime tuning
on_eventimpl Fn(Event)Callback for playback events

Video – Raw View

When you need full control over the playback UI – a custom scrubber, gesture-based controls, or a looping background – use Video:

use waterui::prelude::*;
use waterui::media::{AspectRatio, Video};

fn background_video() -> impl View {
    Video::new("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4")
        .aspect_ratio(AspectRatio::Fill)
        .loops(true)
}

Volume and Mute System

Both video components encode mute state into the volume value itself:

  • Positive values represent audible volume (e.g. 0.7 = 70%).
  • Negative values represent muted state while preserving the original level (e.g. -0.7 means “muted, but restore to 70% on unmute”).

In practice, use the muted method with a Binding<bool> and let the framework handle the encoding:

use waterui::prelude::*;
use waterui::media::VideoPlayer;

fn mutable_player() -> impl View {
    let muted = Binding::bool(false);
    VideoPlayer::new("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4")
        .muted(&muted)
}

Video Events

Subscribe to playback lifecycle events to keep your UI in sync. The video Event is re-exported from waterui::media as Event; the example aliases it to make the variant matches read clearly:

use waterui::media::VideoPlayer;
use waterui::media::Event as VideoEvent;

fn player_with_events() -> impl View {
    VideoPlayer::new("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4")
        .on_event(|event| match event {
            VideoEvent::ReadyToPlay => tracing::info!("ready"),
            VideoEvent::Ended => tracing::info!("playback ended"),
            VideoEvent::Buffering => tracing::info!("buffering"),
            VideoEvent::BufferingEnded => tracing::info!("resumed"),
            VideoEvent::Error { message } => tracing::error!("error: {message}"),
            _ => {}
        })
}
EventDescription
ReadyToPlayThe video is ready to begin playback.
EndedPlayback reached the end of the video.
BufferingPlayback stalled while waiting for more data.
BufferingEndedEnough data is available to resume playback.
BufferLevel { buffered_ms }Reports buffered duration ahead of the playhead.
PlaybackMetrics { av_drift_ms, dropped_video_frames }Periodic playback diagnostics.
PictureInPictureChanged { active }PiP entered or exited.
NextRequested / PreviousRequestedThe system or player UI asked for the next or previous queue item.
Error { message }A load or playback error occurred.

Live Photos (Apple)

Live Photos combine a still image with a short video clip: press and hold on an iPhone and the photo comes alive. WaterUI models this through LivePhoto and LivePhotoSource:

use waterui::media::{LivePhoto, Url};
use waterui::media::live::LivePhotoSource;

fn my_live_photo() -> impl View {
    let source = LivePhotoSource::new(
        Url::parse("https://static.rust-lang.org/logos/rust-logo-512x512.png").unwrap(),
        Url::parse("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4").unwrap(),
    );
    LivePhoto::new(source)
}

LivePhoto::new accepts any IntoComputed<LivePhotoSource>, so you can drive it with a reactive signal that changes the photo dynamically.

Note: Live Photos are an Apple-specific feature. On non-Apple platforms, the native backend may fall back to displaying just the still image.


The Unified Media Enum

When your data model may contain images, videos, or Live Photos – imagine a social feed or a chat thread – use the Media enum. It implements View and automatically selects the right component:

use waterui::media::{Media, Url};
use waterui::media::live::LivePhotoSource;

let items: Vec<Media> = vec![
    Media::Image(Url::parse("https://static.rust-lang.org/logos/rust-logo-512x512.png").unwrap()),
    Media::Video(Url::parse("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4").unwrap()),
    Media::LivePhoto(LivePhotoSource::new(
        Url::parse("https://static.rust-lang.org/logos/rust-logo-512x512.png").unwrap(),
        Url::parse("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4").unwrap(),
    )),
];

Each variant renders via its corresponding component:

VariantRenders As
Media::Image(url)Photo
Media::Video(url)VideoPlayer
Media::LivePhoto(source)LivePhoto

Media Picker

Want to let users choose a photo or video from their device? The MediaPicker component presents the platform’s native media selection dialog. It requires the std feature, which is enabled by default.

Basic Selection

use waterui::prelude::*;
use waterui::media::media_picker::{MediaFilter, MediaPicker, Selected};

fn picker_demo() -> impl View {
    let selection: Binding<Option<Selected>> = Binding::container(None);

    MediaPicker::new(&selection)
        .filter(MediaFilter::Image)
}

Media Filters

Control what type of media the user can select:

FilterDescription
MediaFilter::ImageOnly images
MediaFilter::VideoOnly videos
MediaFilter::LivePhotoOnly Live Photos
MediaFilter::Any(vec)Any of the listed filters
MediaFilter::All(vec)All conditions must match
MediaFilter::Not(vec)Exclude the listed filters

Custom Label

Replace the default “Select Media” button text:

use waterui::prelude::*;
use waterui::media::media_picker::{MediaPicker, Selected};

fn custom_picker() -> impl View {
    let selection: Binding<Option<Selected>> = Binding::container(None);

    MediaPicker::new(&selection)
        .label(text("Choose a photo"))
}

Accessing the Result

Once the user selects media, inspect the Selected value through its media() accessor:

use waterui::media::Media;
use waterui::media::media_picker::Selected;

fn handle_selection(selected: &Selected) {
    match selected.media() {
        Media::Image(url) => tracing::info!("selected image: {url}"),
        Media::Video(url) => tracing::info!("selected video: {url}"),
        Media::LivePhoto(source) => tracing::info!(?source, "selected live photo"),
    }
}

Image Filters

Filter modifiers come from ViewExt and accept any IntoSignalF32, so you can hand them a literal value or a Binding<f64> for live updates:

use waterui::prelude::*;
use waterui::media::Photo;

fn vintage_photo() -> impl View {
    Photo::new("https://static.rust-lang.org/logos/rust-logo-512x512.png")
        .saturation(0.6)
        .brightness(-0.05)
        .blur(1.5)
}

Common modifiers: .blur(radius), .brightness(amount), .contrast(amount), .saturation(amount). The full list (and how filters compose into a single GPU pass) lives in Filters and Visual Effects.

WaterUI also re-exports filtrate::Filter as waterui::media::Filter if you need to construct filter pipelines manually.


Platform Considerations

FeatureAppleAndroidDesktop (Hydrolysis)
Photo (network images)Full supportFull supportFull support
HDR (AVIF/HEIC)Platform decoder, RGBA16FPlatform decoder, RGBA16FSoftware fallback (SDR)
VideoPlayer controlsNative (AVPlayerViewController)WaterUI/Rust controlsWIP
Live PhotosNative supportImage-only fallbackImage-only fallback
Media PickerNative photo pickerNative photo pickerFile dialog fallback
Streaming decodeJPEG, PNG, GIF, WebP, BMP, TIFFSameSame

Supported Image Formats

The decoding pipeline automatically selects between a platform-native decoder (Apple/Android) and a software fallback depending on the format and platform:

  • Software path: JPEG, PNG, GIF, WebP, BMP, ICO, TIFF
  • Platform path: AVIF, HEIC/HEIF (Apple & Android only), images with embedded ICC/cICP color profiles

What’s Next

Now that you can display images and play video, the next chapter explores Maps and Location – embedding interactive maps, dropping pins, and tracking the user’s real-time position.

Maps and Location

In this chapter, you will:

  • Embed interactive maps with annotations and styles
  • Track and display the user’s real-time location
  • Build a complete location-aware feature with reactive state
  • Understand cross-platform differences in map rendering

Picture a ride-sharing app that follows your car in real time, or a travel guide that drops pins on every restaurant nearby. Maps are one of those features that instantly make an app feel polished and professional. WaterUI gives you native map rendering through the waterui-map crate and cross-platform location access via waterkit-location, all with a declarative, reactive API.

Feature flag: Maps live behind the map feature on waterui. Enable it in Cargo.toml (waterui = { version = "...", features = ["map"] }) so the prelude re-export waterui::map is available.

Crate Overview

CratePurpose
waterui-mapThe Map view component, Coordinate, Region, Annotation, map styles
waterkit-locationCross-platform Location::get(), permission handling, coordinate data

The map crate re-exports the location crate for convenience:

use waterui::map::location; // waterkit-location
use waterui::map::Location; // waterkit_location::Location

Coordinates and Regions

Before you can display a map, you need to tell it where to look. That starts with two types: Coordinate and Region.

Coordinate

A geographic point on the globe:

use waterui::map::Coordinate;

let san_francisco = Coordinate::new(37.7749, -122.4194);
let tokyo = Coordinate::new(35.6762, 139.6503);

Coordinate has two fields:

FieldTypeRange
latitudef64-90.0 to 90.0
longitudef64-180.0 to 180.0

You can convert from a waterkit_location::Location directly:

use waterui::map::Coordinate;
use waterkit_location::Location;

// From a Location reference
let coord = Coordinate::from_location(&location);

// Or via Into
let coord: Coordinate = location.into();

Region

A Region describes the visible area of the map – a center coordinate plus a span in degrees:

use waterui::map::{Coordinate, Region};

// Explicit span
let bay_area = Region::new(
    Coordinate::new(37.7749, -122.4194),
    0.5,  // latitude span in degrees
    0.5,  // longitude span in degrees
);

// Default zoom from a coordinate
let zoomed_in = Region::from_coordinate(Coordinate::new(37.7749, -122.4194));
// Uses 0.05 degree span in both directions
FieldDescription
centerThe Coordinate at the center of the visible region
latitude_deltaNorth-to-south span in degrees (smaller = more zoomed in)
longitude_deltaEast-to-west span in degrees

Region implements From<Coordinate>, so you can pass a bare coordinate anywhere a region is expected and get a sensible default zoom.


Displaying a Map

Now let’s put a map on screen. You will find it is just as straightforward as placing any other view.

Basic Map

use waterui::map::{Map, Coordinate, Region};

fn city_map() -> impl View {
    let region = Region::new(
        Coordinate::new(48.8566, 2.3522), // Paris
        0.1,
        0.1,
    );
    Map::new(region)
}

Map::new accepts any Into<Computed<Region>>, meaning you can pass:

  • A static Region value
  • A reactive Computed<Region> signal that updates the visible area over time

Centering on a Coordinate

If you only have a coordinate and want the default zoom:

use waterui::map::{Map, Coordinate};

fn pin_map() -> impl View {
    Map::centered_on(Coordinate::new(35.6762, 139.6503))
}

Map::centered_on also accepts Computed<Coordinate> for reactive updates.

Centering on a Location

When working directly with the waterkit-location crate:

use waterui::map::Map;
use waterkit_location::Location;

fn location_map(location: Computed<Location>) -> impl View {
    Map::centered_on_location(location)
}

Annotations (Map Markers)

A map without markers is just a pretty picture. Add pins with Annotation:

use waterui::map::{Map, Coordinate, Region, Annotation};

fn annotated_map() -> impl View {
    let sf = Coordinate::new(37.7749, -122.4194);
    let la = Coordinate::new(34.0522, -118.2437);

    Map::new(Region::new(Coordinate::new(36.0, -120.0), 5.0, 5.0))
        .annotations(vec![
            Annotation::new(sf, "San Francisco"),
            Annotation::new(la, "Los Angeles")
                .subtitle("City of Angels"),
        ])
}

Annotation API

MethodDescription
Annotation::new(coordinate, title)Create with a position and title
.subtitle(text)Add optional subtitle text

Each annotation has these fields:

FieldTypeDescription
coordinateCoordinateWhere the pin is placed
titleStrPrimary label shown on the annotation
subtitleOption<Str>Secondary label (optional)

Reactive Annotations

Since annotations accepts Into<Computed<Vec<Annotation>>>, you can drive the marker list from a reactive signal. This is perfect for search results, live tracking, or any data that changes over time:

use waterui::prelude::*;
use waterui::map::{Map, Coordinate, Region, Annotation};

fn dynamic_markers() -> impl View {
    let markers = Binding::container(vec![
        Annotation::new(Coordinate::new(37.7749, -122.4194), "Start"),
    ]);

    Map::new(Region::default())
        .annotations(markers.into_computed())
}

Map Styles

Choose between three display modes to match the feel of your app:

use waterui::map::{Map, Region, MapStyle};

fn satellite_view() -> impl View {
    Map::new(Region::default())
        .style(MapStyle::Satellite)
}
StyleDescription
MapStyle::StandardRoad map with labels (default)
MapStyle::SatelliteSatellite imagery
MapStyle::HybridSatellite imagery with road overlays

User Location

Showing the User’s Position

Display the familiar blue dot indicating the user’s current location:

use waterui::map::{Map, Region};

fn location_enabled_map() -> impl View {
    Map::new(Region::default())
        .shows_user_location(true)
}

Note: This requires location permission. Use Location::ask_permission() or let the platform prompt the user automatically.

Following the User

follows_location both centers the map on a reactive Location stream and enables the user-location indicator. The map moves as the user moves – great for navigation or fitness tracking:

use waterui::map::Map;
use waterkit_location::Location;

fn tracking_map(location: Computed<Location>) -> impl View {
    Map::new(Region::default())
        .follows_location(location)
}

This is equivalent to calling shows_user_location(true) plus binding the region to the location signal.


Map Interaction Controls

Fine-tune the map’s interactive behavior. For example, you might want a non-interactive overview map in a list cell:

use waterui::map::{Map, Region};

fn static_overview() -> impl View {
    Map::new(Region::default())
        .is_interactive(false)  // Disable pan and zoom
        .shows_compass(false)   // Hide the compass
        .shows_scale(true)      // Show the scale bar
}
MethodDefaultDescription
is_interactive(bool)trueEnable or disable pan/zoom gestures
shows_compass(bool)trueShow the compass indicator
shows_scale(bool)trueShow the distance scale bar

Getting the User’s Location

The waterkit-location crate provides a cross-platform API for accessing device location:

use waterkit_location::{Location, LocationError};

async fn where_am_i() -> Result<(), LocationError> {
    let location = Location::get().await?;

    tracing::info!("Lat: {}", location.latitude());
    tracing::info!("Lon: {}", location.longitude());

    if let Some(alt) = location.altitude() {
        tracing::info!("Altitude: {} meters", alt);
    }

    tracing::info!("Accuracy: {:?} meters", location.horizontal_accuracy());
    tracing::info!("Time: {:?}", location.timestamp());

    Ok(())
}

Location Accessors

MethodReturn TypeDescription
latitude()f64Latitude in degrees
longitude()f64Longitude in degrees
altitude()Option<f64>Altitude in meters above sea level
horizontal_accuracy()Option<f64>Horizontal accuracy in meters
vertical_accuracy()Option<f64>Vertical accuracy in meters
timestamp()TimestampWhen the location was recorded

Error Handling

Location::get() returns Result<Location, LocationError>:

ErrorDescription
PermissionDeniedThe user denied location access
ServiceDisabledLocation services are turned off on the device
TimeoutThe location request timed out
NotAvailableLocation data could not be determined
Unknown(String)An unexpected platform error

Permissions

Location::get() calls Location::ask_permission() automatically. If you want to check permission status before presenting the map, call it explicitly:

use waterkit_location::Location;

async fn ensure_location_access() {
    if let Err(e) = Location::ask_permission().await {
        tracing::warn!("Location permission not granted: {e}");
    }
}

Convenience Function

A free function map() is available as a shorthand for Map::new():

use waterui::map::{map, Region};

fn quick_map() -> impl View {
    map(Region::default())
}

Complete Example

Here is a complete example that fetches the user’s location and displays a map centered on it with an annotation:

use waterui::prelude::*;
use waterui::map::{Annotation, Coordinate, Map, MapStyle, Region};
use waterkit_location::Location;

fn location_app() -> impl View {
    let location_binding: Binding<Option<Location>> = Binding::container(None);

    // Reactive map region and annotations derived from the same source signal.
    let region = location_binding.map(|opt| {
        opt.map(|loc| Region::from_coordinate(Coordinate::from(loc)))
            .unwrap_or_default()
    });

    let annotations = location_binding.map(|opt| {
        opt.map(|loc| vec![
            Annotation::new(Coordinate::from(loc), "You are here"),
        ])
        .unwrap_or_default()
    });

    // Fetch the location once when the map appears; the task is cancelled with the view.
    let loc = location_binding.clone();
    Map::new(region)
        .annotations(annotations)
        .style(MapStyle::Standard)
        .shows_user_location(true)
        .shows_compass(true)
        .task(async move {
            match Location::get().await {
                Ok(location) => loc.set(Some(location)),
                Err(e) => tracing::error!("Location error: {e}"),
            }
        })
}

Platform Considerations

FeatureAppleAndroidDesktop
Map renderingMKMapView (MapKit)Platform map viewWIP
Standard/Satellite/HybridAll supportedAll supported
User location dotNativeNative
AnnotationsNative pinsNative markers
Location accessCoreLocationFusedLocationProviderGeoClue (Linux), WinRT (Windows)

The Map component uses the configurable! macro with StretchAxis::Both, meaning it expands to fill available space in both directions by default. Use layout modifiers like .size(width, height), .width(...), or .height(...) to constrain its size when needed.


What’s Next

You have maps and location covered. Next up: WebView, where you will embed web content directly into your app – complete with JavaScript bridges, cookie management, and navigation controls.

WebView

In this chapter, you will:

  • Embed web content directly inside your WaterUI application
  • Navigate, inject scripts, and execute JavaScript from Rust
  • Set up bidirectional communication between Rust and web code
  • Manage cookies and redirect behavior programmatically
  • Build a minimal in-app browser with back/forward controls

Sometimes the best tool for the job is the web. Maybe you need to display documentation, embed an OAuth login flow, or wrap an existing web app inside your native shell. The waterui-webview crate makes this seamless – you get a full-featured embedded browser with navigation, JavaScript execution, cookie management, and a Rust-to-JS bridge, all from Rust.

Feature flag: WebView lives behind the webview feature on waterui. Enable it in Cargo.toml (waterui = { version = "...", features = ["webview"] }) before importing waterui::webview.

Architecture

The WebView system follows a layered design:

LayerTypeRole
TraitWebViewHandleImperative API that native backends implement
Type-erased wrapperAnyWebViewHandleWraps any WebViewHandle with downcast support
FactoryWebViewControllerEnvironment-injected factory for creating web views
Reactive viewWebViewCombines AnyWebViewHandle with Binding state

Native backends (Apple, Android) implement CustomWebViewController and inject a WebViewController into the Environment at startup. Your application code then obtains the controller and creates web views through it.


Quick Start

The simplest way to embed web content is with WebView::open:

use waterui::webview::WebView;

fn docs_page() -> impl View {
    WebView::open("https://waterui.dev/docs")
}

WebView::open pulls the WebViewController from the environment automatically, creates a new web view handle, navigates to the URL, and returns a View that renders the embedded browser.

That is all it takes – one line to go from URL to rendered web content.


Creating a WebView Manually

For more control, obtain the controller from the environment, open a fresh WebView, and configure it before placing it in the view hierarchy:

use waterui::prelude::*;
use waterui::webview::{WebView, WebViewController};

fn custom_browser() -> impl View {
    use_env(|controller: WebViewController| {
        let webview = controller.open();
        webview.go_to("https://book.waterui.dev");
        webview.set_user_agent("WaterUIBook/1.0");
        webview
    })
}

WebViewController::open() returns a fresh WebView already wrapped with reactive event state. Use the WebView itself as the imperative handle.

open_then for Post-Creation Configuration

When you want to configure the underlying handle immediately after creation but still build the view in a single expression, use WebView::open_then:

use waterui::webview::WebView;

fn configured_webview() -> impl View {
    WebView::open_then("https://book.waterui.dev", |handle| {
        handle.set_user_agent("WaterUIBook/1.0");
        handle.set_redirects_enabled(false);
    })
}

The closure receives an AnyWebViewHandle, which exposes the same imperative API as WebView (navigation, user agent, cookie store, script injection).


Once you have a WebView instance, control navigation imperatively:

// Navigate to a URL
webview.go_to("https://book.waterui.dev");

// Refresh the current page
webview.refresh();

// Stop loading
webview.stop();

// History navigation
webview.go_back();
webview.go_forward();

Reactive Navigation State

WebView exposes reactive signals for history state:

// Returns Computed<bool>
let can_back = webview.can_go_back();
let can_forward = webview.can_go_forward();

These update automatically as the user navigates. Use them to enable/disable back and forward buttons in your custom browser chrome.


Events

Subscribe to navigation lifecycle events through the reactive event() signal:

use waterui::prelude::*;
use waterui::webview::{WebViewController, WebViewEvent};

fn eventful_webview() -> impl View {
    use_env(|controller: WebViewController| {
        let webview = controller.open();
        let events = webview.event(); // impl Signal<Output = WebViewEvent>
        webview
    })
}

WebViewEvent Variants

EventFieldsDescription
NoneInitial state before any event fires
WillNavigateurl: UrlNavigation is about to begin
Loadingprogress: f32Page load progress (0.0 to 1.0)
LoadedPage finished loading
Redirectfrom: Url, to: UrlA redirect occurred during navigation
Error(WebViewError)An error occurred

Error Types

use waterui::webview::WebViewError;
ErrorDescription
WebViewError::Network(msg)A network error occurred
WebViewError::Ssl { url, message }An SSL/TLS verification failure
WebViewError::LoadFailed(msg)The page failed to load

JavaScript Execution

One of the most powerful features of the WebView is the ability to run JavaScript from Rust and get results back.

Running Scripts

Execute JavaScript in the context of the loaded page:

let result = webview.run_javascript("document.title").await;
match result {
    Ok(title) => tracing::info!("Page title: {title}"),
    Err(err) => tracing::error!("JS error: {err}"),
}

run_javascript is async and returns Result<Str, Str>. It executes after the page has loaded. For scripts that must run before the DOM is constructed, use script injection instead.

Script Injection

Inject scripts that run automatically on every page load:

use waterui::webview::ScriptInjectionTime;

// Run before DOM construction
webview.inject_script(
    r#"window.APP_VERSION = "1.0.0";"#,
    ScriptInjectionTime::DocumentStart,
);

// Run after DOM is ready
webview.inject_script(
    r#"document.body.style.backgroundColor = "#f0f0f0";"#,
    ScriptInjectionTime::DocumentEnd,
);
Injection TimeDescriptionUse Cases
DocumentStartBefore the DOM is constructedNative bridges, global object setup, request interception
DocumentEndAfter the document finishes loadingDOM manipulation, event listeners

Rust-to-JavaScript Bridge

The WebView supports bidirectional communication through message handlers. This is how you connect your Rust business logic to your web UI.

Setting Up a Handler

Register a Rust function that JavaScript can call:

webview.handle().add_handler("greet", Box::new(|data: &[u8]| {
    let name = String::from_utf8_lossy(data);
    tracing::info!("Greeting requested for: {name}");
    format!("Hello, {name}!").into_bytes()
}));

Calling from JavaScript

The JavaScript API depends on the platform:

// Apple (WKWebView)
window.webkit.messageHandlers.greet.postMessage("World");

// Android
window.greet.postMessage("World");

Setting Up a Convenient Bridge

Combine inject_script and add_handler for a clean API that hides platform differences from your web code:

use waterui::webview::ScriptInjectionTime;

// Inject a friendly JavaScript API
webview.inject_script(r#"
    window.myApp = {
        greet: function(name) {
            window.webkit.messageHandlers.greet.postMessage(name);
        }
    };
"#, ScriptInjectionTime::DocumentStart);

// Register the native handler
webview.handle().add_handler("greet", Box::new(|data: &[u8]| {
    let name = String::from_utf8_lossy(data);
    tracing::info!("JS called greet({name})");
    Vec::new()
}));

Removing a Handler

webview.handle().remove_handler("greet");

Cookies

Manage cookies programmatically – useful for authentication flows or session management:

use waterui::webview::{WebView, cookie::Cookie};

// Set a cookie
fn set_session_cookie(webview: &WebView, session_token: &str) {
    let cookie = Cookie::build(("session", session_token))
        .domain("book.waterui.dev")
        .path("/")
        .secure(true)
        .build()
        .unwrap();

    webview.handle().set_cookie(cookie);
}

// Retrieve all cookies
let cookies = webview.handle().get_cookies();
for c in &cookies {
    tracing::info!("Cookie: {} = {}", c.name(), c.value());
}

The cookie crate (re-exported as waterui::webview::cookie) provides the Cookie type.


Redirect Control

Enable or disable HTTP redirect following:

// Imperatively
webview.set_redirects_enabled(false);

// Reactively via a binding
let allow_redirects = Binding::bool(true);
let webview = controller
    .open()
    .redirects_enabled(allow_redirects);

The redirects_enabled builder method watches the signal and syncs the setting automatically when the value changes.


User Agent

Customize the user agent string sent with requests:

webview.set_user_agent("WaterUIBook/1.0");

Complete Example

Let’s put it all together. Here is a minimal in-app browser with back/forward buttons:

use waterui::prelude::*;
use waterui::webview::{WebView, WebViewController};

fn mini_browser() -> impl View {
    use_env(|controller: WebViewController| {
        let webview = controller.open();
        webview.go_to("https://waterui.dev");

        let can_back = webview.can_go_back();
        let can_forward = webview.can_go_forward();

        let back = webview.clone();
        let forward = webview.clone();
        let reload = webview.clone();

        vstack((
            hstack((
                button("Back")
                    .action(move || back.go_back())
                    .disabled(can_back.map(|ok| !ok)),
                button("Forward")
                    .action(move || forward.go_forward())
                    .disabled(can_forward.map(|ok| !ok)),
                button("Refresh").action(move || reload.refresh()),
            )),
            webview,
        ))
    })
}

The disabled modifier accepts the inverted reactive signal, so the back and forward buttons grey out automatically as the navigation history changes.


Platform Considerations

FeatureAppleAndroidDesktop
EngineWKWebView (WebKit)Platform WebViewWIP
JavaScript executionFull supportFull support
Script injectionDocumentStart / DocumentEndDocumentStart / DocumentEnd
Message handlerswebkit.messageHandlerswindow.<name>
CookiesFull supportFull support
Redirect controlFull supportBackend-dependent

WebView is declared as a raw view with StretchAxis::Both, so it expands to fill all available space by default. Use .size(width, height), .width(...), .height(...), or a parent layout to control its size.

Downcasting the Handle

When you need access to the platform-specific handle (for example, to configure WKWebView preferences on Apple), you can downcast:

if let Some(native) = webview.handle().downcast_ref::<MyNativeHandle>() {
    // Access platform-specific APIs
}

This is primarily useful for backend authors and advanced platform integration.


What’s Next

You have seen how to embed the entire web inside your app. Next, let’s look at something more focused: Barcodes and QR Codes, where you will generate scannable codes entirely on the GPU.

Barcodes and QR Codes

In this chapter, you will:

  • Generate QR codes and Code 128 barcodes from any string
  • Customize colors with solid fills, gradients, and GPU content
  • Understand how the GPU-based rendering pipeline keeps codes crisp at any size
  • Build a shareable QR code component for your app

Need to let users share a link by scanning their phone, or display a product barcode in a retail app? The waterui-barcode crate renders barcodes and QR codes entirely on the GPU. Module data is encoded on the CPU, packed into a bit buffer, and rasterized by a fragment shader – producing crisp output at any resolution with no CPU rasterization overhead.

Feature flag: Barcodes live behind the barcode feature on waterui. Enable it in Cargo.toml (waterui = { version = "...", features = ["barcode"] }) before importing waterui::barcode.

QR code rendered by WaterUI for https://book.waterui.dev

A QR code rendered from the pinned WaterUI barcode component. Example source.

Quick Start

use waterui::barcode::Barcode;

// QR code
fn my_qr() -> impl View {
    Barcode::qr("https://waterui.dev")
}

// 1D barcode
fn my_barcode() -> impl View {
    Barcode::code128("HELLO-WATERUI")
}

Both Barcode::qr and Barcode::code128 return a Barcode struct that implements View and can be placed directly in your view hierarchy.


Supported Symbologies

SymbologyConstructorDimensionsDescription
QR CodeBarcode::qr(content)2DSquare matrix, widely used for URLs, text, and data
Code 128Barcode::code128(content)1DHigh-density linear barcode for alphanumeric data

The BarcodeSymbology enum represents these:

pub enum BarcodeSymbology {
    Qr,
    Code128,
}

Barcode::new(content) is an alias for Barcode::qr(content).


Customizing Colors

The default black-and-white look works fine, but you can match your app’s branding with custom colors.

WaterUI barcode preview with a gradient QR code and green Code128 barcode

A Hydrolysis preview of custom barcode colors and gradient fills. Example source.

Dark Module Color

By default, dark modules are black. Change them with dark_color. Srgb::new takes three linear-light channels in 0.0..=1.0:

use waterui::barcode::Barcode;
use waterui::graphics::color::Srgb;

fn blue_qr() -> impl View {
    Barcode::qr("https://waterui.dev")
        .dark_color(Srgb::new(0.0, 0.3, 0.8))
}

Light Module / Background Color

Change the background (light modules and quiet zone):

fn dark_mode_qr() -> impl View {
    Barcode::qr("https://waterui.dev")
        .dark_color(Srgb::WHITE)
        .light_color(Srgb::new(0.1, 0.1, 0.1))
}

Gradient Fill

Apply a linear gradient to the dark modules for a more eye-catching look:

use waterui::barcode::Barcode;
use waterui::graphics::color::Srgb;

fn gradient_qr() -> impl View {
    Barcode::qr("https://waterui.dev")
        .linear_gradient(
            Srgb::new(0.0, 0.5, 1.0), // start color (blue)
            Srgb::new(1.0, 0.0, 0.5), // end color (pink)
            [0.0, 0.0],               // start point (top-left)
            [1.0, 1.0],               // end point (bottom-right)
        )
}

Gradient coordinates are normalized to the barcode’s bounding square:

  • [0.0, 0.0] is the top-left corner
  • [1.0, 1.0] is the bottom-right corner

GPU-Content Fill

For advanced effects – imagine a QR code filled with an animated gradient, or modules that shimmer with a particle effect – use fill_gpu. Any type implementing GpuView can serve as the fill source:

use waterui::barcode::Barcode;

fn artistic_qr(animated_renderer: impl GpuView) -> impl View {
    Barcode::qr("https://waterui.dev")
        .fill_gpu(animated_renderer)
        .light_color(Srgb::WHITE)
}

This creates a BarcodeGpuFill<V> view that:

  1. Renders the fill content to an offscreen texture.
  2. Applies a barcode mask effect where dark modules sample from the fill texture and light modules use the configured light color.

The mask is applied via the BarcodeMaskEffect shader, which runs the same packed-matrix lookup as the standard renderer but composites the fill texture instead of a flat color.


The BarcodeFill Enum

Under the hood, the fill style for solid colors and gradients is represented as BarcodeFill:

pub enum BarcodeFill {
    Solid(Srgb),
    LinearGradient {
        start_color: Srgb,
        end_color: Srgb,
        start_point: [f32; 2],
        end_point: [f32; 2],
    },
}

You do not need to construct this directly – dark_color and linear_gradient produce the appropriate variant for you.


How It Works

Understanding the rendering pipeline helps you appreciate why QR codes stay perfectly sharp at any zoom level.

Matrix Generation

When a Barcode view renders, it first generates the module matrix:

  • QR codes use the fast_qr crate. The QR matrix dimension depends on the content length and error correction level.
  • Code 128 uses the barcoders crate. The 1D bar pattern is repeated on every row to produce a square matrix compatible with the same GPU shader.

Bit Packing

The matrix is packed into a Vec<u32> where each u32 holds 32 modules as individual bits:

  • Bit value 1 = dark module
  • Bit value 0 = light module

For a 25x25 QR code, the total is 625 modules requiring 20 u32 words. This compact representation is uploaded as a GPU storage buffer.

Fragment Shader Rasterization

The shader (qr_render.wgsl) receives:

  • A uniform buffer with the matrix dimension, quiet zone size, output resolution, and color/gradient parameters
  • A read-only storage buffer with the packed matrix bits

For each fragment, the shader:

  1. Maps the pixel position to a module coordinate (accounting for quiet zone)
  2. Looks up the corresponding bit in the packed buffer
  3. Outputs the dark or light color (or gradient-interpolated color)

This approach renders at any resolution without aliasing artifacts because the module lookup is resolution-independent.

Quiet Zones

Each symbology includes an appropriate quiet zone (margin):

SymbologyQuiet Zone (modules)
QR Code4
Code 12810

The quiet zone is rendered in the light color and is included automatically.


API Reference Summary

Barcode

MethodDescription
Barcode::new(content)Create a QR code (alias for qr)
Barcode::qr(content)Create a QR code
Barcode::code128(content)Create a Code 128 barcode
.dark_color(color)Set solid dark module color
.light_color(color)Set light module / background color
.linear_gradient(start, end, from, to)Apply gradient to dark modules
.fill_gpu(gpu_view)Fill dark modules with GPU-rendered content

BarcodeGpuFill<V>

MethodDescription
.light_color(color)Set light module / background color

BarcodeRenderer

For direct GPU pipeline integration:

MethodDescription
BarcodeRenderer::new(source)Create with default black/white colors
BarcodeRenderer::new_with_colors(source, dark, light)Create with custom solid colors
BarcodeRenderer::new_with_fill(source, fill, light)Create with a BarcodeFill

The renderer implements GpuRenderer, so it can be used with GpuSurface and the rest of the graphics pipeline.

BarcodeSource

MethodDescription
BarcodeSource::qr(content)Create a QR source
BarcodeSource::code128(content)Create a Code 128 source
.set_size(pixels)Set the output size (default: 256)
.size()Get the current output size
.symbology()Get the symbology type
.quiet_zone()Get the quiet zone width in modules
.matrix()Get or generate the packed BarcodeMatrix

Complete Example

A settings page with a shareable QR code:

use waterui::prelude::*;
use waterui::barcode::Barcode;
use waterui::graphics::color::Srgb;

fn share_page() -> impl View {
    let url = "https://book.waterui.dev";

    vstack((
        text("Scan to join"),
        Barcode::qr(url)
            .dark_color(Srgb::new(0.15, 0.15, 0.15))
            .light_color(Srgb::WHITE),
        text(url),
        Spacer::flexible(),
    ))
}

A branded QR code with a gradient:

use waterui::prelude::*;
use waterui::barcode::Barcode;
use waterui::graphics::color::Srgb;

fn branded_qr() -> impl View {
    Barcode::qr("https://waterui.dev")
        .linear_gradient(
            Srgb::new(0.0, 0.4, 0.9), // brand blue
            Srgb::new(0.0, 0.8, 0.6), // brand green
            [0.0, 0.0],
            [1.0, 1.0],
        )
        .light_color(Srgb::new(0.98, 0.98, 0.98))
}

Platform Considerations

The barcode component is fully cross-platform because it renders entirely through WaterUI’s GPU pipeline (wgpu). There is no dependency on platform- specific barcode libraries.

FeatureAll Platforms
QR code generationfast_qr crate (pure Rust)
Code 128 generationbarcoders crate (pure Rust)
RenderingFragment shader via wgpu
Gradient fillsGPU shader
GPU content fillBarcodeMaskEffect post-processing shader
Scanning / camera decodeNot yet available (planned)

Note: Barcode scanning (using the device camera to decode barcodes) is not currently part of this crate. It is planned for a future release as part of the WaterKit camera integration.


What’s Next

That wraps up the Rich Content section. You have learned how to display media, embed maps, render web content, and generate barcodes – all from Rust. In the next section, Graphics, you will dive into the GPU and start drawing custom shapes, shaders, and visual effects.

Canvas Drawing

In this chapter, you will:

  • Draw shapes, paths, text, and images on a GPU-accelerated 2D canvas
  • Use gradients, transforms, clipping, and shadows
  • Drive redraws from reactive signals
  • Build a custom visualization like an animated clock

Canvas is WaterUI’s 2D vector graphics view, powered by Vello. If you have used the HTML5 Canvas API, the drawing interface will feel familiar – but every command runs on the GPU through wgpu.

Checkpoint status: in the pinned WaterUI commit, waterui-canvas exists at components/visual/canvas as a workspace crate but is not re-exported by the top-level waterui facade. The examples in this chapter describe that crate-level API and are marked rust,ignore until the facade exposes a public waterui::canvas module.

Overview

Canvas is a callback-based drawing surface. You provide a closure that receives a DrawingContext, and WaterUI invokes it whenever the scene needs to repaint. Internally, the canvas drives a Vello scene that is built into a GpuSurface, so your drawing commands compile into GPU-friendly scene data.

Canvas drawing with rectangles circles curves and text

A WaterUI Canvas preview showing vector drawing primitives. Example source.

use waterui::prelude::*;
use waterui_canvas::{Canvas, DrawingContext};
use waterui::layout::{Rect, Size};
use waterui::graphics::color::Srgb;

fn my_canvas() -> impl View {
    Canvas::new(|ctx: &mut DrawingContext| {
        ctx.set_fill_style(Srgb::new(0.2, 0.5, 1.0));
        let rect = Rect::from_size(Size::new(200.0, 100.0));
        ctx.fill_rect(rect);
    })
}

Canvas stretches to fill its parent in both axes by default. Use .size(w, h) (from ViewExt) to give it a fixed footprint.

Drawing Context

The DrawingContext is the primary interface for all drawing operations. It provides access to the canvas dimensions and a rich set of drawing methods.

Canvas::new(|ctx: &mut DrawingContext| {
    // Canvas dimensions are available as fields
    let width = ctx.width;
    let height = ctx.height;
    let center = ctx.center(); // Convenience method
    let size = ctx.size();     // Returns Size { width, height }
})

Drawing Shapes

Let’s start with the basics. Every shape begins with setting a fill or stroke style, then calling the corresponding draw method.

Rectangles

The most common primitive. Fill, stroke, or clear rectangles with a single call.

Canvas::new(|ctx: &mut DrawingContext| {
    let rect = Rect::new(Point::new(10.0, 10.0), Size::new(200.0, 100.0));

    // Solid fill
    ctx.set_fill_style(Srgb::new(0.2, 0.6, 1.0));
    ctx.fill_rect(rect);

    // Outlined stroke
    ctx.set_stroke_style(Srgb::new(1.0, 0.0, 0.0));
    ctx.set_line_width(3.0);
    ctx.stroke_rect(rect);

    // Clear a region to transparent
    ctx.clear_rect(Rect::new(Point::new(50.0, 30.0), Size::new(40.0, 40.0)));
})

Circles

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.set_fill_style(Srgb::new_u8(242, 140, 168));
    ctx.fill_circle(Point::new(100.0, 100.0), 50.0);

    ctx.set_stroke_style(Srgb::new(0.0, 0.0, 0.0));
    ctx.set_line_width(2.0);
    ctx.stroke_circle(Point::new(100.0, 100.0), 50.0);
})

Lines

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.set_stroke_style(Srgb::new(1.0, 1.0, 1.0));
    ctx.set_line_width(2.0);
    ctx.stroke_line(
        Point::new(10.0, 10.0),
        Point::new(200.0, 150.0),
    );
})

Path API

For complex shapes, use the Path builder. It follows the HTML5 Canvas path API closely, so you can construct anything from triangles to intricate curves.

Canvas::new(|ctx: &mut DrawingContext| {
    let mut path = ctx.begin_path();

    // Triangle
    path.move_to(Point::new(100.0, 10.0));
    path.line_to(Point::new(190.0, 170.0));
    path.line_to(Point::new(10.0, 170.0));
    path.close();

    ctx.set_fill_style(Srgb::new(0.0, 0.8, 0.4));
    ctx.fill_path(&path);
})

Bezier Curves

Canvas::new(|ctx: &mut DrawingContext| {
    let mut path = ctx.begin_path();
    path.move_to(Point::new(10.0, 100.0));

    // Quadratic curve
    path.quadratic_to(
        Point::new(100.0, 10.0),   // control point
        Point::new(200.0, 100.0),  // end point
    );

    // Cubic curve
    path.bezier_to(
        Point::new(250.0, 10.0),   // control point 1
        Point::new(350.0, 190.0),  // control point 2
        Point::new(400.0, 100.0),  // end point
    );

    ctx.set_stroke_style(Srgb::new(1.0, 0.5, 0.0));
    ctx.set_line_width(3.0);
    ctx.stroke_path(&path);
})

Arcs and Ellipses

Canvas::new(|ctx: &mut DrawingContext| {
    let mut path = ctx.begin_path();

    // Circular arc: center, radius, start_angle, end_angle, anticlockwise
    path.arc(
        Point::new(100.0, 100.0),
        50.0,
        0.0,
        std::f32::consts::PI,
        false,
    );

    // Elliptical arc: center, radii, rotation, start, end, anticlockwise
    path.ellipse(
        Point::new(250.0, 100.0),
        Size::new(80.0, 40.0), // radii
        0.3,                    // rotation in radians
        0.0,                    // start angle
        std::f32::consts::TAU,  // end angle (full ellipse)
        false,
    );

    ctx.set_stroke_style(Srgb::new(0.8, 0.2, 0.8));
    ctx.set_line_width(2.0);
    ctx.stroke_path(&path);
})

Gradients

Canvas supports three types of gradients for richer fills: linear, radial, and conic. Each is created through a builder returned by the drawing context.

Linear Gradient

Canvas::new(|ctx: &mut DrawingContext| {
    let mut gradient = ctx.create_linear_gradient(0.0, 0.0, 200.0, 200.0);
    gradient.add_color_stop(0.0, Srgb::new(1.0, 0.0, 0.0));
    gradient.add_color_stop(0.5, Srgb::new(0.0, 1.0, 0.0));
    gradient.add_color_stop(1.0, Srgb::new(0.0, 0.0, 1.0));

    ctx.set_fill_style(gradient);
    ctx.fill_rect(Rect::from_size(Size::new(200.0, 200.0)));
})

Radial Gradient

The radial gradient interpolates between two circles defined by center and radius.

Canvas::new(|ctx: &mut DrawingContext| {
    let mut gradient = ctx.create_radial_gradient(
        100.0, 100.0, 10.0,  // inner circle: center (100,100), radius 10
        100.0, 100.0, 80.0,  // outer circle: center (100,100), radius 80
    );
    gradient.add_color_stop(0.0, Srgb::new(1.0, 1.0, 1.0));
    gradient.add_color_stop(1.0, Srgb::new(0.0, 0.0, 0.4));

    ctx.set_fill_style(gradient);
    ctx.fill_circle(Point::new(100.0, 100.0), 80.0);
})

Conic (Sweep) Gradient

Canvas::new(|ctx: &mut DrawingContext| {
    let mut gradient = ctx.create_conic_gradient(
        0.0,    // start angle in radians
        100.0,  // center x
        100.0,  // center y
    );
    gradient.add_color_stop(0.0, Srgb::new(1.0, 0.0, 0.0));
    gradient.add_color_stop(0.33, Srgb::new(0.0, 1.0, 0.0));
    gradient.add_color_stop(0.66, Srgb::new(0.0, 0.0, 1.0));
    gradient.add_color_stop(1.0, Srgb::new(1.0, 0.0, 0.0));

    ctx.set_fill_style(gradient);
    ctx.fill_circle(Point::new(100.0, 100.0), 80.0);
})

Image Rendering

Load images from raw pixels or encoded bytes (PNG, JPEG, AVIF) and draw them on the canvas. This is useful for sprite sheets, photo manipulation, or compositing images with custom overlays.

Loading Images

use waterui_canvas::CanvasImage;

// From encoded bytes (PNG, JPEG, AVIF)
let image = CanvasImage::from_bytes(include_bytes!("assets/photo.png"))
    .expect("failed to decode image");

// From raw RGBA pixels
let image = CanvasImage::from_rgba_pixels(width, height, &pixel_data)
    .expect("invalid pixel data");

// Query dimensions
let w = image.width();
let h = image.height();
let size = image.size(); // Returns Size

Drawing Images

Canvas::new(move |ctx: &mut DrawingContext| {
    // Draw at natural size
    ctx.draw_image(&image, Point::new(10.0, 10.0));

    // Draw scaled to a destination rectangle
    let dest = Rect::new(Point::ZERO, Size::new(300.0, 200.0));
    ctx.draw_image_scaled(&image, dest);

    // Draw a sub-region (sprite sheet support)
    let src = Rect::new(Point::ZERO, Size::new(32.0, 32.0));
    let dest = Rect::new(Point::new(50.0, 50.0), Size::new(64.0, 64.0));
    ctx.draw_image_sub(&image, src, dest);
})

Transforms

Canvas maintains a transform stack. Transforms affect all subsequent drawing operations until restored. This is how you create rotated labels, zoomed views, or any kind of coordinate space manipulation.

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.save();

    // Translate to center
    ctx.translate(ctx.width / 2.0, ctx.height / 2.0);
    // Rotate 45 degrees
    ctx.rotate(std::f32::consts::FRAC_PI_4);
    // Scale up
    ctx.scale(2.0, 2.0);

    ctx.set_fill_style(Srgb::new(0.4, 0.8, 0.2));
    let rect = Rect::new(Point::new(-25.0, -25.0), Size::new(50.0, 50.0));
    ctx.fill_rect(rect);

    ctx.restore(); // Back to original transform
})

Transform Methods

MethodDescription
translate(x, y)Shift the origin by (x, y)
rotate(radians)Rotate clockwise by the given angle
scale(x, y)Scale drawing by (x, y) factors
transform(a, b, c, d, e, f)Apply an arbitrary affine matrix
set_transform(a, b, c, d, e, f)Replace the current transform
reset_transform()Reset to the identity matrix

Stroke Properties

Fine-grained control over how strokes are rendered.

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.set_stroke_style(Srgb::new(1.0, 1.0, 1.0));

    // Line width
    ctx.set_line_width(4.0);

    // Line cap: how endpoints are drawn
    ctx.set_line_cap(LineCap::Round); // Butt, Round, or Square

    // Line join: how corners are drawn
    ctx.set_line_join(LineJoin::Round); // Miter, Round, or Bevel
    ctx.set_miter_limit(10.0);

    // Dashed lines
    ctx.set_line_dash(vec![10.0, 5.0, 2.0, 5.0]);
    ctx.set_line_dash_offset(3.0);

    ctx.stroke_rect(Rect::from_size(Size::new(200.0, 100.0)));
})

Clipping and Layers

Push clip or alpha layers to constrain or blend drawing operations. Clipping is especially useful for creating shaped windows into your content.

Canvas::new(|ctx: &mut DrawingContext| {
    // Clip to a rectangle
    let clip = Rect::new(Point::new(20.0, 20.0), Size::new(160.0, 160.0));
    ctx.push_clip_rect(clip);

    // Everything drawn here is clipped to the rectangle
    ctx.set_fill_style(Srgb::new(1.0, 0.0, 0.0));
    ctx.fill_circle(Point::new(100.0, 100.0), 120.0);

    ctx.pop_layer();

    // Alpha layer (transparency)
    ctx.push_alpha_rect(0.5, Rect::from_size(ctx.size()));
    ctx.set_fill_style(Srgb::new(0.0, 0.0, 1.0));
    ctx.fill_rect(Rect::from_size(ctx.size()));
    ctx.pop_layer();
})

You can also clip to arbitrary paths using push_clip_path and apply alpha with push_alpha_path.

Fill Rules

Control how complex self-intersecting paths determine their interior.

use waterui_canvas::FillRule;

Canvas::new(|ctx: &mut DrawingContext| {
    // NonZero (default): a point is inside if a ray crosses a non-zero
    // net number of path segments
    ctx.set_fill_rule(FillRule::NonZero);

    // EvenOdd: a point is inside if a ray crosses an odd number of segments
    ctx.set_fill_rule(FillRule::EvenOdd);
})

Shadows

Add shadows to shapes and paths for depth and visual hierarchy.

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.set_shadow_color(Srgb::new(0.0, 0.0, 0.0));
    ctx.set_shadow_blur(10.0);
    ctx.set_shadow_offset(4.0, 4.0);

    ctx.set_fill_style(Srgb::new(1.0, 1.0, 1.0));
    ctx.fill_rect(Rect::new(Point::new(50.0, 50.0), Size::new(100.0, 100.0)));
})

Global Alpha

Set a global opacity that affects all drawing operations.

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.set_global_alpha(0.5); // 50% transparent

    ctx.set_fill_style(Srgb::new(1.0, 0.0, 0.0));
    ctx.fill_rect(Rect::from_size(Size::new(200.0, 200.0)));

    ctx.set_global_alpha(1.0); // Reset to fully opaque
})

Text Rendering

DrawingContext lays out text with Parley and rasterizes glyphs through Vello. Use set_font to choose a typeface, then fill_text or stroke_text to draw. For body content with localization, prefer the text!/Text view – canvas text is best for charts, annotations, and freeform graphics.

use waterui_canvas::{FontSpec, FontWeight, TextMetrics};

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.set_font(FontSpec::new("Arial", 24.0).with_weight(FontWeight::Bold));

    let metrics: TextMetrics = ctx.measure_text("Hello World");
    // metrics.width, metrics.height

    ctx.set_fill_style(Srgb::new(1.0, 1.0, 1.0));
    ctx.fill_text("Hello World", Point::new(50.0, 50.0));
    ctx.stroke_text("Hello World", Point::new(50.0, 100.0));
})

To wrap text within a rectangle, call draw_text_in_rect, which width-constrains the layout and clips overflow.

Reactive Redraws

Canvas redraws on its own whenever a signal you read inside the closure changes. The simplest path is Canvas::with_signal, which threads the current value into your draw callback and tracks it for you:

use waterui::prelude::*;
use waterui_canvas::{Canvas, DrawingContext};
use waterui::layout::Point;
use waterui::graphics::color::Srgb;

fn pulsing_dot(angle: Binding<f32>) -> impl View {
    Canvas::with_signal(angle, |ctx: &mut DrawingContext, angle| {
        let cx = ctx.width / 2.0;
        let cy = ctx.height / 2.0;
        let r = 20.0 + 10.0 * angle.sin();
        ctx.set_fill_style(Srgb::new(0.4, 0.8, 1.0));
        ctx.fill_circle(Point::new(cx, cy), r);
    })
}

Inside Canvas::new, every drawing setter that takes impl IntoSignalF32 or impl IntoSignal<T> registers the signal automatically. Pass bindings directly – never call .get() to feed them in.

If you have to drive an animation that does not depend on a signal, call ctx.request_next_frame() to schedule one more redraw after the current one.

Performance considerations

  • GPU-accelerated: every command lands in Vello and renders on the GPU.
  • Scene rebuilds: the closure runs whenever a tracked signal changes (or the surface resizes). Keep the work proportional to that change.
  • Intermediate texture: Vello renders into Rgba8Unorm, then a blit copies to the final surface (which may be HDR Rgba16Float).
  • State stack: save()/restore() is cheap (clone-based). Wrap transform-heavy sections instead of resetting state by hand.
  • Image caching: build CanvasImage once and reuse the handle. Decoding inside the draw closure stalls the render thread.

Complete Example: Animated Clock

Here is a clock face that draws hour markers radiating from the center. Because Canvas redraws every frame, the hands could easily be animated with time-based logic.

fn clock_canvas() -> impl View {
    Canvas::new(|ctx: &mut DrawingContext| {
        let cx = ctx.width / 2.0;
        let cy = ctx.height / 2.0;
        let radius = cx.min(cy) - 20.0;

        // Clock face
        ctx.set_fill_style(Srgb::new(0.1, 0.1, 0.15));
        ctx.fill_circle(Point::new(cx, cy), radius);
        ctx.set_stroke_style(Srgb::new(0.8, 0.8, 0.8));
        ctx.set_line_width(2.0);
        ctx.stroke_circle(Point::new(cx, cy), radius);

        // Hour markers
        for i in 0..12 {
            let angle = (i as f32) * std::f32::consts::TAU / 12.0 - std::f32::consts::FRAC_PI_2;
            let inner = radius * 0.85;
            let outer = radius * 0.95;
            ctx.stroke_line(
                Point::new(cx + inner * angle.cos(), cy + inner * angle.sin()),
                Point::new(cx + outer * angle.cos(), cy + outer * angle.sin()),
            );
        }
    })
}

Next

Canvas covers most 2D drawing needs. When you want full wgpu access – custom render pipelines, compute shaders, instanced draws – continue to GPU Rendering with GpuSurface.

GPU rendering with GpuSurface

In this chapter, you will:

  • Implement the GpuView trait for custom GPU rendering
  • Understand the setup, resize, and render lifecycle
  • Handle pointer and gesture input inside GPU surfaces
  • Use offscreen rendering for visual testing
  • Configure HDR and MSAA for production-quality output

GpuSurface is the foundation of every GPU-rendered view in WaterUI. It hands you a wgpu device, queue, and a per-frame texture, and renders whatever you draw straight onto the platform’s swapchain. Canvas, ShaderSurface, AnimatedMeshGradient, Gradient, and ParticleSystem are all built on top of it.

If Canvas is a paintbrush, GpuSurface is the bare canvas frame and a tube of pigment.

GPU surface preview rendering a colored triangle

A custom GpuView rendered through water preview. Example source.

Architecture

GpuSurface is a raw view. The native backend allocates the wgpu surface and swapchain for it, and calls your renderer for every frame.

your code (impl GpuView) <-> GpuSurface <-> Native backend (Swift / Kotlin / Hydrolysis)
                                                    |
                                              wgpu device + queue
                                                    |
                                              Metal / Vulkan / GL

A GpuSurface owns exactly one GpuView instance for its lifetime. GpuView::setup is the only place persistent GPU resources for that instance live. Do not move that state into shared caches to survive teardown – when the surface is dropped, the renderer should drop with it.

Layout behaviour

GpuSurface stretches to fill its parent on both axes (the same default as Color). Use .size(w, h) (from ViewExt) when you want a fixed footprint:

use waterui::prelude::*;
use waterui::graphics::GpuSurface;

GpuSurface::new(MyRenderer::default())             // fills available space
GpuSurface::new(MyRenderer::default()).size(400.0, 300.0) // fixed

The GpuView trait

use waterui::Environment;
use waterui::graphics::{GpuContext, GpuFrame};

pub trait GpuView: 'static {
    async fn setup(
        &mut self,
        ctx: &GpuContext<'_>,
        env: &mut Environment,
    );

    fn render(&mut self, frame: &mut GpuFrame);
}

A few details that the type signature does not show:

  • setup is async. Awaitable work (asset loading, shader compilation queues) is allowed; for synchronous setup, the body is just straight-line Rust.
  • render receives the frame by &mut so you can call frame.request_redraw() to schedule another frame for animation.
  • GpuView requires a SubView impl for layout. Use the impl_gpu_subview! macro at the concrete impl site – it wires the default StretchAxis::Both layout for you.

Lifecycle

  1. Setup runs once after the wgpu device is ready. Build pipelines, buffers, bind groups, and any owned textures here. Clone ctx.redraw_handle if you need to wake the surface from outside the render loop (for example, when a WaterUI signal changes, a timer fires, or a network response arrives).
  2. Resize is implicit: each call to render carries the current frame.width/frame.height. Recreate size-dependent resources by detecting a size change inside render.
  3. Render runs whenever the surface is dirty. Submit your wgpu commands into frame.queue. Call frame.request_redraw() to ask for the next frame.

There is no separate needs_redraw callback. Frames advance because either the surface dirtied (size, input, theme), the renderer requested another frame, or a RedrawHandle was poked.

GpuContext

GpuContext is the setup-time payload:

pub struct GpuContext<'a> {
    pub adapter: Option<&'a wgpu::Adapter>,
    pub device: &'a wgpu::Device,
    pub queue: &'a wgpu::Queue,
    pub surface_format: wgpu::TextureFormat,
    pub msaa_samples: u32,
    pub pipeline_cache: Option<&'a wgpu::PipelineCache>,
    pub redraw_handle: RedrawHandle,
}
  • surface_format may be Rgba16Float when the platform supports HDR. Call ctx.is_hdr() and gate your blend state on that result – when HDR is active, use blend: None rather than BlendState::REPLACE.
  • msaa_samples reflects the backend’s supported sample count, capped by WATERUI_GPU_MSAA (default 4). Use it for both pipeline configuration and any MSAA attachments you create.
  • pipeline_cache, when present, should be threaded into every RenderPipelineDescriptor. It dramatically reduces pipeline compile time on subsequent launches.
  • redraw_handle is a cheap, thread-safe handle. Clone and stash it; call request_redraw() whenever new state should drive a frame.

GpuFrame

pub struct GpuFrame<'a> {
    pub device: &'a wgpu::Device,
    pub queue: &'a wgpu::Queue,
    pub texture: &'a wgpu::Texture,
    pub view: wgpu::TextureView,
    pub format: wgpu::TextureFormat,
    pub width: u32,
    pub height: u32,
    pub pointer: PointerState,
    pub gesture: GestureState,
    // ...
}
  • frame.elapsed() returns the accumulated animation time since the surface was first presented; frame.delta() is the gap since the previous frame.
  • frame.is_hovering(), frame.pointer_normalized() give you quick access to pointer interaction.
  • frame.gesture exposes pinch/pan/double-tap state forwarded by the backend.
  • frame.request_redraw() schedules another frame. frame.was_redraw_requested() lets nested helpers check the flag.

Triangle example

Here is a complete “hello triangle” implementation. The shader lives in its own file, as required for anything beyond a couple of lines.

// triangle.rs
use waterui::{Environment, prelude::*};
use waterui::graphics::{GpuContext, GpuFrame, GpuSurface, GpuView, impl_gpu_subview, wgpu};

#[derive(Default)]
struct TriangleRenderer {
    pipeline: Option<wgpu::RenderPipeline>,
}

impl GpuView for TriangleRenderer {
    async fn setup(
        &mut self,
        ctx: &GpuContext<'_>,
        env: &mut Environment,
    ) {
        let shader = ctx.device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("triangle"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/triangle.wgsl").into()),
        });

        let layout = ctx.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("triangle-layout"),
            bind_group_layouts: &[],
            push_constant_ranges: &[],
        });

        let blend = if ctx.is_hdr() { None } else { Some(wgpu::BlendState::REPLACE) };

        self.pipeline = Some(ctx.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("triangle-pipeline"),
            layout: Some(&layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                buffers: &[],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                targets: &[Some(wgpu::ColorTargetState {
                    format: ctx.surface_format,
                    blend,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            }),
            primitive: wgpu::PrimitiveState::default(),
            depth_stencil: None,
            multisample: wgpu::MultisampleState::default(),
            multiview: None,
            cache: ctx.pipeline_cache,
        }));
    }

    fn render(&mut self, frame: &mut GpuFrame) {
        let Some(pipeline) = &self.pipeline else { return };

        let mut encoder = frame.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("triangle-encoder"),
        });

        {
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("triangle-pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &frame.view,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                        store: wgpu::StoreOp::Store,
                    },
                    depth_slice: None,
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
            });
            pass.set_pipeline(pipeline);
            pass.draw(0..3, 0..1);
        }

        frame.queue.submit(std::iter::once(encoder.finish()));
    }
}

impl_gpu_subview!(TriangleRenderer);

pub fn triangle_view() -> impl View {
    GpuSurface::new(TriangleRenderer::default())
}

The macro impl_gpu_subview! provides the layout hooks (StretchAxis::Both, default priority) so GpuSurface can wrap your renderer as a view.

Interactive rendering

Pointer and gesture state arrive on every frame. There is nothing to subscribe to – just read it.

fn render(&mut self, frame: &mut GpuFrame) {
    if let Some((nx, ny)) = frame.pointer_normalized() {
        self.update_hover(nx, ny);
        frame.request_redraw(); // keep animating while the pointer is over us
    }

    if frame.gesture.is_pinching() {
        self.zoom *= frame.gesture.pinch_scale;
    }

    if frame.gesture.double_tap {
        self.reset_view();
    }
}

Driving redraws from outside

For animations that depend on something other than the frame loop – a timer, a Binding, an incoming network message – clone ctx.redraw_handle during setup and call request_redraw() from anywhere:

async fn setup(
    &mut self,
    ctx: &GpuContext<'_>,
    env: &mut waterui::Environment,
) {
    let redraw = ctx.redraw_handle.clone();
    self.guard = Some(self.signal.watch(move |_| redraw.request_redraw()));
    // ...
}

This pattern is exactly how MeshGradient reacts to its color signal without the surface being torn down.

Offscreen rendering

GpuSurface ships with offscreen rendering for visual tests and CI snapshots. Always render to GPU here – never read back the swapchain texture in a runtime path.

use std::num::NonZeroU32;
use waterui::graphics::{GpuSurface, OffscreenRenderConfig, OffscreenSize, wgpu};

let size = OffscreenSize::try_from_pixels(1024, 768)?;
let config = OffscreenRenderConfig::new(size)
    .format(wgpu::TextureFormat::Rgba8UnormSrgb);

let output = GpuSurface::new(MyRenderer::default())
    .render_offscreen(config, &mut env)?;

assert_eq!(output.rgba8.len(), 1024 * 768 * 4);
output.save_png("snapshot.png")?;

For HDR snapshots, swap the format and the entry point:

let config = OffscreenRenderConfig::new(size)
    .format(wgpu::TextureFormat::Rgba16Float);

let output = GpuSurface::new(MyHdrRenderer::default())
    .render_offscreen_hdr(config, &mut env)?;

let max_luminance = output.max_rgb_linear();
let hdr_ratio = output.hdr_pixel_ratio();

output.save_png("hdr_snapshot.png")?;     // PQ-coded HDR PNG with cICP metadata
output.save_sdr_png("sdr_snapshot.png")?; // tone-mapped SDR fallback

OffscreenRenderConfig lets you simulate input for hover/gesture tests:

use waterui::layout::Point;
use waterui::graphics::{GestureState, PointerState};

let config = OffscreenRenderConfig::new(size)
    .format(wgpu::TextureFormat::Rgba8UnormSrgb)
    .msaa_samples(NonZeroU32::new(4).unwrap())
    .pointer(PointerState {
        position: Some(Point::new(512.0, 384.0)),
        hit: None,
    })
    .gesture(GestureState::new());

MSAA configuration

use std::num::NonZeroU32;

GpuSurface::new(MyRenderer::default())
    .msaa_max_samples(NonZeroU32::new(8).unwrap())

Globally: set WATERUI_GPU_MSAA=4 (accepts 1, 2, 4, 8, or 16). The backend clamps the request to what the adapter and format actually support.

HDR preference

WaterUI defaults to HDR (Rgba16Float) when the platform offers it. To opt out for a single surface:

GpuSurface::new(MyRenderer::default()).prefer_sdr_surface();

Globally: WATERUI_GPU_PREFER_HDR=0 forces SDR. In your renderer, gate blend state on ctx.is_hdr() so the same code compiles into either pipeline.

Reference

ItemRole
GpuViewTrait you implement for custom GPU rendering
GpuContextSetup-time wgpu handles + redraw handle
GpuFramePer-frame texture, pointer, gesture, timing
GpuSurfaceRaw view that owns a single GpuView instance
RedrawHandleWakes the surface from outside the render loop
OffscreenRenderConfigHeadless render configuration
OffscreenRenderOutputSDR pixel output with PNG encoding
OffscreenRenderOutputHdrHDR pixel output with PQ + tone-mapped PNG
impl_gpu_subview!Layout glue at the impl site

Next

For the most common GPU use case – a single fragment shader full-screen quad – ShaderSurface skips most of this boilerplate. Continue to Shaders.

Shaders

In this chapter, you will:

  • Write WGSL fragment shaders and display them with ShaderSurface
  • Use built-in uniforms for time, resolution, and aspect-ratio correction
  • Load shaders at compile time with the shader! macro
  • Build animated effects like plasma, noise, and pulsing shapes
  • Know when to graduate from ShaderSurface to GpuView

ShaderSurface is the shortest path from “I have a WGSL fragment shader” to “it is on screen.” You supply the fragment, and WaterUI handles pipeline creation, the uniform buffer, and the render loop.

Quick start

The fastest way to get a shader on screen is the shader! macro:

use waterui::prelude::*;
use waterui::graphics::shader;

fn my_effect() -> impl View {
    shader!("shaders/plasma.wgsl")
}

shader! loads the WGSL source at compile time, registers it for pre-warming, and creates a ShaderSurface with the file path as a label so the GPU pipeline cache can deduplicate.

ShaderSurface preview with a plasma fragment shader

A WGSL fragment shader rendered through ShaderSurface. Example source.

Creating a ShaderSurface manually

shader! is sugar over two more explicit constructors. Reach for them when you need to wire something the macro does not cover (computed paths, generated shader source, and so on).

use waterui::graphics::ShaderSurface;

// from a static string -- no cache key
fn gradient_effect() -> impl View {
    ShaderSurface::new(include_str!("shaders/gradient.wgsl"))
}

// with a label for the pipeline cache
fn labeled_effect() -> impl View {
    ShaderSurface::with_label(
        "shaders/gradient.wgsl",
        include_str!("shaders/gradient.wgsl"),
    )
}

WaterUI keeps a long shader inline in a string literal off-limits in production code – always pull from a .wgsl file with include_str! (or include_fragment_shader!).

Built-in uniforms

Every ShaderSurface shader receives a standard uniform buffer automatically. You do not declare this struct yourself – it is prepended by the ShaderSurface prelude:

struct Uniforms {
    time: f32,           // Elapsed time in seconds since creation
    resolution: vec2<f32>, // Surface size in pixels (width, height)
    padding: f32,
}

@group(0) @binding(0)
var<uniform> uniforms: Uniforms;

A full-screen quad vertex shader is also provided automatically. Your shader only needs to define a fragment function named main:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    // uv: normalized coordinates (0,0) at bottom-left, (1,1) at top-right
    let t = uniforms.time;
    let res = uniforms.resolution;
    return vec4<f32>(uv.x, uv.y, sin(t) * 0.5 + 0.5, 1.0);
}

The prelude

The ShaderSurface prelude that is auto-prepended to your shader includes:

  1. The Uniforms struct and binding declaration
  2. A VertexOutput struct with position and uv fields
  3. A vs_main vertex shader that draws a full-screen quad (6 vertices, 2 triangles)

Your fragment function should be named main (not fs_main) and accept @location(0) uv: vec2<f32>.

Writing WGSL shaders

Now for the fun part. The patterns below progress from a static gradient to time-warped procedural noise.

Basic color pattern

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    // Horizontal gradient from red to blue
    let r = uv.x;
    let b = 1.0 - uv.x;
    return vec4<f32>(r, 0.0, b, 1.0);
}

Time-based animation

This is where shaders start to feel alive. The uniforms.time value ticks up continuously, letting you create pulsing, rotating, and morphing effects:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let t = uniforms.time;

    // Pulsing circle
    let center = vec2<f32>(0.5, 0.5);
    let dist = distance(uv, center);
    let radius = 0.3 + 0.1 * sin(t * 2.0);
    let circle = smoothstep(radius + 0.01, radius - 0.01, dist);

    return vec4<f32>(circle, circle * 0.5, 1.0 - circle, 1.0);
}

Resolution-aware rendering

When your effect needs correct aspect ratio:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let res = max(uniforms.resolution, vec2<f32>(1.0));
    let aspect = res.x / res.y;

    // Correct for aspect ratio
    var p = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);

    let dist = length(p);
    let ring = smoothstep(0.01, 0.0, abs(dist - 0.3));

    return vec4<f32>(ring, ring, ring, 1.0);
}

Noise and procedural patterns

Here is a simple hash-based noise pattern – the building block for fire, clouds, terrain, and countless other effects:

fn hash21(p: vec2<f32>) -> f32 {
    return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453123);
}

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let t = uniforms.time;
    let scale = 10.0;
    let cell = floor(uv * scale);
    let n = hash21(cell + vec2<f32>(t * 0.1, 0.0));

    return vec4<f32>(n, n, n, 1.0);
}

Shader loading macros

WaterUI provides two compile-time macros, both returning a ShaderSource (alias PrewarmedShader):

include_shader!

Loads a complete WGSL shader with both vertex and fragment stages. Use this when you write your own vertex stage:

use waterui::graphics::{include_shader, prewarm::ShaderSource};

static MY_SHADER: ShaderSource = include_shader!("shaders/my_effect.wgsl");

include_fragment_shader!

Loads a fragment-only shader. The ShaderSurface prelude (uniforms + full-screen quad vertex shader) is prepended at runtime:

use waterui::graphics::{include_fragment_shader, prewarm::ShaderSource, ShaderSurface};

static MY_FRAGMENT: ShaderSource = include_fragment_shader!("shaders/my_fragment.wgsl");

ShaderSurface::with_label(MY_FRAGMENT.label, MY_FRAGMENT.source)

The shader! convenience macro

shader!("path.wgsl") expands to roughly:

{
    static SHADER: ShaderSource = include_fragment_shader!("path.wgsl");
    ShaderSurface::with_label(SHADER.label, SHADER.source)
}

Reach for it whenever you would otherwise inline a shader path twice.

How ShaderSurface works internally

Under the hood, ShaderSurface wraps a GpuSurface with an internal ShaderRenderer (a GpuView):

  1. Setup: the full WGSL source (prelude + your fragment) is compiled into a wgpu::ShaderModule. A 24-byte uniform buffer, bind group, and render pipeline are created against the current surface format.
  2. Render: each frame the uniform buffer is rewritten with the latest time and resolution, then a 6-vertex full-screen quad is drawn with your shader.
  3. Continuous animation: ShaderRenderer calls frame.request_redraw() so time-based animations advance every frame.
  4. Format safety: if the surface format changes between setup and render (HDR toggle, for instance) the pipeline is invalidated and rebuilt.

Accessing the inner GpuSurface

If you need the underlying GpuSurface (to apply a per-surface MSAA cap, or stack with other GPU views), unwrap it:

let surface = ShaderSurface::new(my_shader).into_inner();

Going beyond: custom uniforms

ShaderSurface provides only the standard uniforms (time, resolution). If you need extra uniforms, samplers, textures, or storage buffers, write your own GpuView and wrap it in a GpuSurface. See GPU rendering with GpuSurface. The shipped AnimatedMeshGradient is a good example – it carries a 4x4 palette array uniform, which is exactly the kind of thing ShaderSurface will not give you.

Performance tips

  • Shader compilation: WGSL is compiled at setup time. Use shader! or with_label so the backend can cache compiled pipelines via the pre-warm system.
  • Avoid branching: GPUs prefer uniform control flow. Replace branches with select(), step(), and smoothstep() where possible.
  • Texture reads: ShaderSurface does not expose texture bindings. If you need to sample images, drop down to GpuView.
  • Precision: WGSL is 32-bit float by default. For pixel-precise work, multiply UVs by uniforms.resolution.
  • Pipeline cache: WaterUI threads a PipelineCache through setup; the shader! and with_label paths automatically take advantage of it.

Example: a plasma effect

Let’s put it all together with a classic plasma shader – the kind of swirling, colorful effect that has mesmerized programmers since the demoscene era:

// shaders/plasma.wgsl

const PI: f32 = 3.14159265359;

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let t = uniforms.time * 0.5;
    let res = max(uniforms.resolution, vec2<f32>(1.0));

    var p = uv * 10.0;

    var v = 0.0;
    v += sin(p.x + t);
    v += sin(p.y + t * 0.7);
    v += sin((p.x + p.y) * 0.5 + t * 1.3);
    v += sin(length(p - vec2<f32>(5.0)) + t);

    let r = sin(v * PI) * 0.5 + 0.5;
    let g = sin(v * PI + 2.094) * 0.5 + 0.5;
    let b = sin(v * PI + 4.189) * 0.5 + 0.5;

    return vec4<f32>(r, g, b, 1.0);
}

Use it in your app:

fn plasma_background() -> impl View {
    shader!("shaders/plasma.wgsl").size(400.0, 300.0)
}

Next

Shaders compose visual effects from scratch. To transform views you already have, continue to Filters and visual effects.

Filters and visual effects

In this chapter, you will:

  • Apply blur, brightness, contrast, and other filters to any view
  • Chain and compose filters with automatic GPU pass fusion
  • Drive filter parameters with reactive signals
  • Build custom effects with ViewEffect and GpuFilter
  • Understand how the multi-pass pipeline optimizes your filter chains

Filters turn ordinary views into polished UI: blurred photo galleries, frosted-glass cards, dramatic black-and-white portraits. WaterUI’s GPU filter system captures the rendered output of a view, runs it through one or more shader passes, and displays the result with automatic pass fusion and animation support.

Quick start

FilterViewExt adds filter methods to every view. Pull it in with the prelude:

use waterui::prelude::*;
use waterui::graphics::FilterViewExt;

fn frosted_card() -> impl View {
    text("Hello, World!")
        .blur(10.0)
        .brightness(0.1)
        .contrast(1.2)
}

Three filters on a text view – and thanks to automatic fusion, the brightness and contrast adjustments execute as a single GPU pass.

Filtered gradient card with blur brightness and contrast

FilterViewExt applied to a WaterUI view snapshot. Example source.

Architecture

The filter pipeline has four layers:

  1. FilterViewExt – the convenience methods (.blur(), .brightness(), …) that you call from view code.
  2. FilterAdapter<F> – bridges the pure-data Filter trait from filtrate-core to the GPU-aware GpuFilter trait, handling pass fusion and signal-driven animation.
  3. GpuFilter – the low-level trait you implement for custom filters.
  4. Native backend – captures the child view to a texture, runs the GPU filter in Rust, and displays the resulting texture.
view.blur(10.0)
    -> Filtered<V, FilterAdapter<Blur>>
        -> AppliedFilter metadata
            -> backend captures child to texture
            -> GpuFilter::render(input, output)
            -> backend displays output texture

Built-in filters

All built-in filters accept reactive values through the IntoSignalF32 trait, so you can pass static f32s, Binding<f64>, Computed<f32>, or any signal that yields a finite float. The adapter converts to Computed<f32> and watches it for animations.

Color filters

Color filters run per pixel and fuse into a single GPU pass when chained consecutively.

MethodParameterDescription
.brightness(amount)f32Adjust brightness. 0.0 = unchanged, positive = brighter, negative = darker
.contrast(amount)f32Adjust contrast. 1.0 = unchanged, >1.0 = more contrast
.saturation(amount)f32Adjust color saturation. 1.0 = unchanged, 0.0 = desaturated
.grayscale(intensity)f32Desaturate to grayscale. 0.0 = full color, 1.0 = fully gray
.hue_rotation(angle)f32Rotate hue by angle (in radians)
.opacity(amount)f32Adjust opacity. 1.0 = fully opaque, 0.0 = transparent
.sepia(intensity)f32Apply sepia tone. 0.0 = no effect, 1.0 = full sepia
.invert()(none)Invert all colors
.vignette(radius, softness)f32, f32Darken edges with a vignette effect

Spatial Filters

Spatial filters sample neighboring pixels and require a separate GPU pass (compute shader).

MethodParameterDescription
.blur(radius)f32Gaussian blur with the given pixel radius
.sharpen(amount)f32Sharpen edges. Higher values = more sharpening

Basic usage

A single filter

use waterui::prelude::*;
use waterui::media::Photo;
use waterui::graphics::FilterViewExt;

fn blurred_photo(url: impl Into<waterui::Url>) -> impl View {
    Photo::new(url).blur(8.0)
}

Chaining filters

Use .then() to stack low-level Filter types from filtrate_core::filters:

fn stylized_photo(url: impl Into<waterui::Url>) -> impl View {
    Photo::new(url)
        .blur(5.0)
        .then(filtrate_core::filters::Brightness(0.15))
        .then(filtrate_core::filters::Contrast(1.3))
        .then(filtrate_core::filters::Saturation(0.8))
}

Or use the convenience methods directly – consecutive color filters are automatically fused:

fn warm_vintage(url: impl Into<waterui::Url>) -> impl View {
    Photo::new(url)
        .brightness(0.05)
        .then(filtrate_core::filters::Sepia(0.3))
        .then(filtrate_core::filters::Contrast(1.1))
        .then(filtrate_core::filters::Vignette(0.7, 0.5))
}

Filter fusion

The filter system automatically optimizes consecutive color-only filters into a single GPU pass. When you chain brightness -> contrast -> saturation, these three fragment shader snippets are fused into one render pass rather than three separate texture reads and writes.

Spatial filters (like blur and sharpen) always require their own pass. A chain like blur -> brightness -> contrast -> sharpen produces three passes:

  1. Compute pass: blur
  2. Fragment pass: brightness + contrast (fused)
  3. Compute pass: sharpen

Tip: Ordering matters for performance. Group your color-only filters together to maximize fusion. Interleaving spatial and color filters creates unnecessary pass boundaries.

Reactive filters

Every filter convenience method accepts a reactive signal – in particular, a Binding<f64> from a slider works directly. Pass the binding by clone; never call .get() to feed a reactive sink.

use waterui::prelude::*;
use waterui::media::Photo;
use waterui::graphics::FilterViewExt;

fn interactive_blur(url: waterui::Url) -> impl View {
    let blur_radius = Binding::f64(0.0);

    vstack((
        Photo::new(url).blur(blur_radius.clone()),
        Slider::new(&blur_radius).range(0.0..=30.0),
    ))
}

Animated transitions

When the binding feeding a filter is wrapped with an animation, the FilterAdapter interpolates between the previous and current values automatically.

fn animated_filter_demo(url: waterui::Url) -> impl View {
    let blur = Binding::f64(0.0);

    vstack((
        Photo::new(url).blur(blur.clone()),
        button("Toggle blur")
            .action(|State(blur): State<Binding<f64>>| {
                let target = if blur.get() > 0.0 { 0.0 } else { 20.0 };
                with_animation(Animation::spring(180.0, 22.0), || blur.set(target));
            })
            .state(&blur),
    ))
}

The animation system supports three modes:

  • Default: ease-in-out over 250 ms.
  • Bezier: cubic bezier curves with configurable duration.
  • Spring: physics-based spring with stiffness and damping.

HDR policy

Filter pipelines handle HDR-capable surfaces automatically. Override the default per filter chain:

fn hdr_aware_filter(url: waterui::Url) -> impl View {
    Photo::new(url)
        .blur(10.0)
        .prefer_hdr()   // use HDR intermediates when available (default)
    //  .require_hdr()  // fail if HDR is unavailable
    //  .force_ldr()    // always use LDR intermediates
}
PolicyBehavior
PreferHdrUse HDR intermediates with automatic LDR fallback (default)
RequireHdrRequire HDR-capable pipeline; fail setup if unavailable
ForceLdrForce LDR intermediates for compatibility or performance

When the input or output surface is HDR (Rgba16Float), the scratch textures between passes use Rgba16Float to preserve dynamic range. Otherwise, Rgba8Unorm is used.

ViewEffect: a lower-level effect API

For effects that go beyond simple filters – distortion, particle overlays, custom post-processing – use ViewEffect with the EffectRenderer trait:

use core::future::Future;
use waterui::graphics::{ViewEffect, EffectRenderer, EffectContext, EffectInput, EffectOutput, wgpu};

struct WaveDistortion {
    pipeline: Option<wgpu::RenderPipeline>,
    bind_group_layout: Option<wgpu::BindGroupLayout>,
    sampler: Option<wgpu::Sampler>,
}

impl EffectRenderer for WaveDistortion {
    fn setup(&mut self, ctx: &EffectContext) -> impl Future<Output = ()> {
        // create pipeline, bind group layout, sampler.
        // ctx.input_format and ctx.output_format may differ.
        async {}
    }

    fn render(&mut self, input: &EffectInput, output: &EffectOutput) {
        // read from input.texture/input.view, write to output.texture/output.view.
        // input and output may have different dimensions.
    }
}

Using ViewEffect

fn distorted_content() -> impl View {
    ViewEffect::new(
        text("Wavy text"),
        WaveDistortion::default(),
    )
}

Output size control

By default, the output texture matches the captured view’s size. Override it via OutputSize:

// double resolution for higher quality
ViewEffect::new(my_view(), effect)
    .output_size(OutputSize::Scale(2.0))

// fixed-size output
ViewEffect::new(my_view(), effect)
    .output_size(OutputSize::Fixed { width: 1920, height: 1080 })

OutputSize does not affect layout – it changes only the GPU processing resolution.

Custom GpuFilter

For maximum control, implement GpuFilter directly. Notice that the trait’s setup returns FilterSetupResult (i.e. Result<(), &'static str>) and render returns FilterRenderResult (Result<bool, &'static str>). The bool answers “is animation still running?”.

use core::future::Future;
use waterui::graphics::{
    FilterContext, FilterInput, FilterOutput, FilterRenderResult, FilterSetupResult, GpuFilter, wgpu,
};

struct CustomFilter {
    pipeline: Option<wgpu::RenderPipeline>,
    bind_group_layout: Option<wgpu::BindGroupLayout>,
    sampler: Option<wgpu::Sampler>,
    animating: bool,
}

impl GpuFilter for CustomFilter {
    fn setup(&mut self, ctx: &FilterContext) -> impl Future<Output = FilterSetupResult> {
        // build pipelines from ctx.device, ctx.queue, ctx.input_format, ctx.output_format,
        // and (when present) ctx.pipeline_cache.
        async { Ok(()) }
    }

    fn render(&mut self, input: &FilterInput, output: &FilterOutput) -> FilterRenderResult {
        // render input.view -> output.view. Return Ok(true) while interpolating.
        Ok(self.animating)
    }
}

Apply it using FilterViewExt::filter:

fn custom_filtered() -> impl View {
    text("Hello").filter(CustomFilter::default())
}

FilteredView::new is the lower-level wrapper – it takes an AnyView and the filter directly:

use waterui::AnyView;
use waterui::graphics::FilteredView;

fn custom_filtered() -> impl View {
    FilteredView::new(AnyView::new(text("Hello")), CustomFilter::default())
}

The filtrate-core Filter trait

WaterUI’s high-level filters sit on top of the filtrate-core crate, which provides a pure-data Filter trait with parameter arrays. FilterAdapter bridges that into GpuFilter:

filtrate_core::Filter
    -> FilterAdapter<F>  (implements GpuFilter)
        -> AppliedFilter (type-erased metadata on the view)
            -> backend renders

Filter provides:

  • COLOR_ONLY – whether the filter only modifies pixel colors (no spatial sampling).
  • Params – a ParamArray type for reactive parameters.
  • params() – returns the current parameter values.

FilterGraph (internal) enables stage collection for pass planning, animation watcher installation for reactive parameters, and pass fusion.

Composing filters in practice

Photo editor effect stack

use filtrate_core::filters::{Brightness, Contrast, Saturation};

fn photo_adjustments(
    url: waterui::Url,
    blur: Binding<f64>,
    brightness: Binding<f64>,
    contrast: Binding<f64>,
    saturation: Binding<f64>,
) -> impl View {
    Photo::new(url)
        .blur(blur)
        .then(Brightness(brightness.computed()))
        .then(Contrast(contrast.computed()))
        .then(Saturation(saturation.computed()))
}

Frosted glass

use filtrate_core::filters::{Brightness, Saturation};

fn frosted_glass(content: impl View) -> impl View {
    content
        .blur(20.0)
        .then(Brightness(0.05))
        .then(Saturation(1.2))
}

Dramatic black-and-white

use filtrate_core::filters::{Contrast, Vignette};

fn dramatic_bw(url: waterui::Url) -> impl View {
    Photo::new(url)
        .grayscale(1.0)
        .then(Contrast(1.6))
        .then(Vignette(0.6, 0.4))
}

Performance notes

  • Pass fusion: consecutive color-only filters fuse into a single fragment shader pass. Five colour filters cost roughly the same as one.
  • Spatial filters: blur and sharpen use compute shaders with intermediate textures. Each spatial filter is a separate pass.
  • Texture captures: the backend captures the child view to a texture before filtering. For GpuSurface children, the backend can sample the existing texture without an extra capture step.
  • Animation: when filter parameters change with animation context, FilterAdapter keeps the surface dirty until the animation settles. Spring animations finish naturally; bezier animations run for a fixed duration.
  • Scratch textures: multi-pass filters ping-pong between two scratch textures. They are allocated lazily and resized only when needed.

Next

Filters transform existing content. To create new visual content from scratch on the GPU, continue to Particle systems.

Particle Systems

In this chapter, you will:

  • Build GPU-accelerated particle effects with the ParticleSystem view
  • Configure emitters, motion, collisions, and blend modes from a single chain
  • Use built-in shapes for fireworks, rain, snow, and ambient effects
  • Apply collision and particle-particle interaction without writing shaders
  • Render particle systems offscreen for visual testing

Picture confetti bursting across the screen when a user completes a purchase, or snowflakes drifting gently behind a winter-themed card. Particle effects bring delight and motion to an app – and with WaterUI’s ParticleSystem view, you describe the effect declaratively and the GPU does the rest.

Feature flag: Particles live behind the particle feature on waterui. Enable it in Cargo.toml (waterui = { version = "...", features = ["particle"] }) before importing waterui::particle.

Quick Start

use waterui::prelude::*;
use waterui::particle::ParticleSystem;
use core::f32::consts::PI;

fn rain() -> impl View {
    ParticleSystem::new(5_000)
        .emit_from_rect(1.5, 0.0)
        .at(0.5, -0.05)
        .rate(800.0)
        .life(0.8..1.3)
        .speed(1.8..2.2)
        .angle(PI * 0.49..PI * 0.51)
        .size(0.002..0.004)
        .color(
            Color::srgb(255, 255, 255).with_opacity(0.5),
            Color::transparent(),
        )
        .stretch_with_velocity()
        .gravity(0.0, 2.5)
}

ParticleSystem is itself a View, so it composes with vstack, zstack, frames, and any other layout primitive in the framework.

Deterministic confetti emitter preview with particle colors

A WaterUI preview image illustrating a confetti particle emitter. Example source.

How It Works

ParticleSystem builds an internal ParticleConfig through a flat modifier chain. When the view is rendered, it materializes a GPU surface that:

  1. Allocates a particle storage buffer sized to max_particles.
  2. Runs a compute shader each frame to emit, advance, and recycle particles.
  3. Renders all live particles in a single instanced draw call.
  4. Returns needs_redraw() = true while at least one particle is alive.

Coordinates are normalized: [0.0, 0.0] is the top-left of the system’s frame and [1.0, 1.0] is the bottom-right. Sizes, gravity, and emitter offsets all use the same normalized space, which means an effect looks identical at any output resolution.

Configuring the Emitter

The emitter controls where particles spawn and how often.

ModifierDescription
at(x, y)Position the emitter (normalized coordinates)
rate(per_second)Particles per second
emit_from_point()Spawn from a single point (default)
emit_from_rect(width, height)Spawn anywhere inside a rectangle
emit_from_circle(radius)Spawn anywhere inside a disk
use waterui::particle::ParticleSystem;

ParticleSystem::new(2_000)
    .emit_from_circle(0.05)
    .at(0.5, 0.5)
    .rate(400.0);

Particle Properties

Each particle is randomized within the ranges you provide.

ModifierDescription
life(range)Lifetime in seconds
speed(range)Initial speed magnitude
angle(range)Initial direction in radians
size(range)Particle size in normalized units
spin(range)Rotation speed in radians/second
color(start, end)Tint at birth and at death
softness(value)Edge softness (0.0 hard, 1.0 soft)
shape(ParticleShape)Circle or Rect SDF sprite
stretch_with_velocity()Stretch the sprite along its velocity vector
use waterui::particle::{ParticleShape, ParticleSystem};
use core::f32::consts::TAU;

ParticleSystem::new(1_500)
    .emit_from_point()
    .at(0.5, 0.8)
    .rate(120.0)
    .life(0.6..1.4)
    .speed(0.4..0.9)
    .angle(0.0..TAU)
    .size(0.01..0.03)
    .shape(ParticleShape::Circle)
    .softness(0.6);

Environment Forces

Once particles spawn, world-space forces shape their motion.

ModifierDescription
gravity(x, y)Constant acceleration vector
wind(x, y)Constant velocity offset
turbulence(value)Perlin-style noise jitter
drag(factor)Velocity damping per 60 fps frame (1.0 = no damping)
ParticleSystem::new(3_000)
    .emit_from_rect(1.0, 0.05)
    .at(0.5, 0.0)
    .rate(900.0)
    .life(1.0..2.0)
    .speed(0.0..0.2)
    .gravity(0.0, 0.4)
    .wind(0.05, 0.0)
    .turbulence(0.6)
    .drag(0.98);

Blending and Compositing

Use additive() for fire, sparks, and glow effects where overlapping particles should brighten:

use waterui::particle::ParticleSystem;
use core::f32::consts::PI;

fn embers() -> impl View {
    ParticleSystem::new(2_000)
        .emit_from_point()
        .at(0.5, 0.95)
        .rate(300.0)
        .life(0.7..1.4)
        .speed(0.4..0.8)
        .angle(-PI * 0.6..-PI * 0.4)
        .size(0.005..0.012)
        .color(
            Color::srgb(255, 196, 96),
            Color::srgb(255, 64, 16).with_opacity(0.0),
        )
        .gravity(0.0, -0.4)
        .additive()
}

The default is BlendMode::Alpha (standard premultiplied alpha blending).

Collisions and Interaction

ParticleSystem includes a pure-GPU broadphase for both static obstacles and particle-particle forces.

ModifierDescription
collide_with_viewport()Bounce off the normalized [0,0]..[1,1] rectangle
collide_with_rect(x, y, w, h)Bounce off an arbitrary axis-aligned rectangle
collide_with_circle_obstacle(x, y, radius)Bounce off a static disk
bounce(restitution)Fraction of normal velocity preserved on impact
surface_friction(value)Fraction of tangential velocity preserved on impact
collide_with_particles(radius, strength)Soft particle-particle repulsion within radius
ParticleSystem::new(1_200)
    .emit_from_circle(0.05)
    .at(0.5, 0.2)
    .rate(180.0)
    .life(2.0..3.0)
    .speed(0.4..0.7)
    .gravity(0.0, 0.6)
    .collide_with_viewport()
    .collide_with_circle_obstacle(0.5, 0.7, 0.12)
    .bounce(0.6)
    .surface_friction(0.85);

Using a Particle System as a View

Because ParticleSystem implements View, you can place it anywhere – as a full-screen background, layered behind UI, or inside a card:

fn celebration_card() -> impl View {
    zstack((
        ParticleSystem::new(2_000)
            .emit_from_rect(1.0, 0.0)
            .at(0.5, -0.05)
            .rate(400.0)
            .life(1.4..2.4)
            .speed(0.3..0.6)
            .size(0.005..0.012)
            .gravity(0.0, 0.6),
        vstack((
            text("Order placed"),
            text("Thanks for shopping with us."),
        ))
        .padding(),
    ))
}

ParticleSystem stretches in both axes by default (it inherits GpuSurface’s layout behavior). Use .size(width, height) if you need a fixed size.

Offscreen Testing

Use the offscreen render APIs to render a fixed number of frames without a window. This is ideal for visual regression tests:

use core::num::NonZeroU32;
use waterui::graphics::{OffscreenRenderConfig, OffscreenSize};
use waterui::particle::ParticleSystem;

#[test]
fn fireworks_renders() {
    let mut env = waterui::Environment::new();
    let size = OffscreenSize::try_from_pixels(512, 512).unwrap();
    let config = OffscreenRenderConfig::new(size);

    let output = ParticleSystem::new(1_000)
        .emit_from_point()
        .at(0.5, 0.5)
        .rate(2_000.0)
        .life(0.4..1.0)
        .render_offscreen_frames(config, &mut env, NonZeroU32::new(8).unwrap())
        .expect("offscreen render should succeed");

    output.save_png("fireworks.png").unwrap();
}

Performance Tips

  • Pre-size the buffer: ParticleSystem::new(max_particles) allocates once. Pick a value that fits your peak particle count.
  • Watch the rate: emission rate * average_life should not exceed max_particles, or new particles will be dropped.
  • Use additive blending for fire and glow to keep alpha sorting cheap.
  • Disable particle-particle interaction when you do not need it – the neighbor grid pass adds work proportional to particle count.

What’s Next

You have particles flying across the screen. The final chapter in this section, Animated Gradients, shows you how to build flowing, animated gradient backgrounds – from simple linear fills to self-animating mesh gradients that run entirely on the GPU.

Animated Gradients

In this chapter, you will:

  • Apply linear, radial, angular, and mesh gradients as backgrounds
  • Drive gradient colors and stop positions from reactive signals
  • Drop in self-animating gradients with AnimatedMeshGradient and FlowingGradient
  • Choose between the high-level waterui::gradient types and the low-level GPU Gradient view

Think of an app store hero banner, or a login screen where colors drift and blend like liquid paint. Animated gradients are one of the easiest ways to add visual richness, and WaterUI ships several GPU-accelerated gradient components – from simple static fills to flowing, palette-driven mesh gradients.

Static mesh gradient rendered by WaterUI graphics

A mesh gradient rendered with waterui::graphics::Gradient. Example source.

Where Gradient Types Live

WaterUI exposes two complementary gradient layers:

  • waterui::gradient – semantic gradient descriptions used by the rendering pipeline. LinearGradient, RadialGradient, AngularGradient, and MeshGradient are pure data types here, with reactive Computed<Color> stops and UnitPoint anchors.
  • waterui::graphics – the GPU View layer. Gradient is a View backed by GradientConfig, and the AnimatedMeshGradient / FlowingGradient views ship pre-tuned shader effects.

Note: At the pinned waterui revision, the descriptive types in waterui::gradient are not themselves View and cannot be passed straight to .background(...). Use waterui::graphics::Gradient (or one of the self-animating views below) when you want to drop a gradient into the view tree.

GPU Gradient Views with waterui::graphics

waterui::graphics::Gradient is the all-in-one View. It accepts Vec<(f32, ResolvedColor)> color stops and maps to backend-native rendering for linear, radial, and angular variants, and to a dedicated GPU shader for mesh variants.

Linear Gradient

use waterui::prelude::*;
use waterui::graphics::{Gradient, color::ResolvedColor};

fn linear_bg() -> impl View {
    Gradient::linear(
        vec![
            (0.0, ResolvedColor { red: 1.0, green: 0.0, blue: 0.5, opacity: 1.0, headroom: 0.0 }),
            (1.0, ResolvedColor { red: 0.0, green: 0.3, blue: 1.0, opacity: 1.0, headroom: 0.0 }),
        ],
        [0.5, 0.0], // start point (normalized)
        [0.5, 1.0], // end point
    )
}

Wrap the gradient in zstack or use .background(gradient) to draw content on top of it.

Radial Gradient

fn radial_bg() -> impl View {
    Gradient::radial(
        vec![
            (0.0, ResolvedColor { red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0, headroom: 0.0 }),
            (1.0, ResolvedColor { red: 0.0, green: 0.0, blue: 0.2, opacity: 1.0, headroom: 0.0 }),
        ],
        [0.5, 0.5], // center
        0.0,        // start radius
        0.7,        // end radius
    )
}

Angular (Conic) Gradient

use core::f32::consts::TAU;

fn conic_bg() -> impl View {
    Gradient::angular(
        vec![
            (0.0,  ResolvedColor { red: 1.0, green: 0.0, blue: 0.0, opacity: 1.0, headroom: 0.0 }),
            (0.33, ResolvedColor { red: 0.0, green: 1.0, blue: 0.0, opacity: 1.0, headroom: 0.0 }),
            (0.66, ResolvedColor { red: 0.0, green: 0.0, blue: 1.0, opacity: 1.0, headroom: 0.0 }),
            (1.0,  ResolvedColor { red: 1.0, green: 0.0, blue: 0.0, opacity: 1.0, headroom: 0.0 }),
        ],
        [0.5, 0.5],
        0.0,
        TAU,
    )
}

Mesh Gradient

Static mesh gradients interpolate across a vertex grid. Provide width * height vertices in row-major order:

use waterui::graphics::{Gradient, color::ResolvedColor};

fn mesh_bg() -> impl View {
    let red    = ResolvedColor { red: 1.0, green: 0.0, blue: 0.0, opacity: 1.0, headroom: 0.0 };
    let blue   = ResolvedColor { red: 0.0, green: 0.0, blue: 1.0, opacity: 1.0, headroom: 0.0 };
    let green  = ResolvedColor { red: 0.0, green: 1.0, blue: 0.0, opacity: 1.0, headroom: 0.0 };
    let yellow = ResolvedColor { red: 1.0, green: 1.0, blue: 0.0, opacity: 1.0, headroom: 0.0 };

    Gradient::mesh(
        2, 2,
        vec![
            ([0.0, 0.0], red),
            ([1.0, 0.0], blue),
            ([0.0, 1.0], green),
            ([1.0, 1.0], yellow),
        ],
        true, // smooth color interpolation
    )
}

Gradient::mesh panics if vertices.len() != width * height, so the grid stays internally consistent.

Composing Gradients with GradientConfig

For full control, build a GradientConfig directly and hand it to Gradient::new:

use waterui::graphics::{Gradient, GradientConfig, GradientType};

let config = GradientConfig {
    gradient_type: GradientType::Linear,
    stops: vec![(0.0, color_a), (0.5, color_b), (1.0, color_c)],
    start_point: [0.0, 0.0],
    end_point: [1.0, 1.0],
    start_value: 0.0,
    end_value: 1.0,
    mesh_size: (2, 2),
    mesh_vertices: Vec::new(),
    smooths_colors: true,
};

let view = Gradient::new(config);

The same struct also drives GradientConfig::linear / radial / angular / mesh constructors if you prefer the named entry points.

Reactive Mesh Gradients

waterui::graphics::MeshGradient<C> accepts any Signal whose Output iterates ResolvedColor values. Update the source binding and the GPU buffer refreshes only when the colors actually change:

use waterui::graphics::MeshGradient;
use waterui::graphics::color::ResolvedColor;
use waterui::prelude::*;

fn reactive_mesh(colors: Binding<Vec<ResolvedColor>>) -> impl View {
    MeshGradient::new(3, 3, colors).smooths_colors(true)
}

Mesh vertices are arranged in row-major order (row 0 first). Positions are derived from the grid dimensions automatically – you only supply the colors.

Self-Animating Gradients

Two views in waterui::graphics animate continuously without any host-side work.

AnimatedMeshGradient

A 4x4 mesh palette warped by GPU noise. The default config is already production-ready:

use waterui::graphics::AnimatedMeshGradient;

fn animated_background() -> impl View {
    AnimatedMeshGradient::default()
}

Tune the speed, warp, or palette through AnimatedMeshGradientConfig:

use waterui::graphics::{AnimatedMeshGradient, AnimatedMeshGradientConfig};

fn bespoke_background() -> impl View {
    let config = AnimatedMeshGradientConfig::aqua_bloom()
        .speed(0.8)
        .warp(0.3);

    AnimatedMeshGradient::new(config)
}

Built-in palettes include aqua_bloom, pastel_lagoon, soft_blush, and deep_blue. Each is a 16-color (4 * 4) ResolvedColor array, accessible via AnimatedMeshGradientConfig::palette([...]) if you want to supply your own. The constant ANIMATED_MESH_PALETTE_LEN documents the array length.

FlowingGradient

FlowingGradient ships a procedural fBm-noise shader that produces a slow, ocean-like flow:

use waterui::graphics::flowing_gradient::FlowingGradient;

fn ambient_bg() -> impl View {
    FlowingGradient::default()
}

The shader uses gradient noise (4-octave fBm) with two flow fields warping the sample coordinates, plus a soft vignette and a deep navy-to-white palette. There are no public knobs – FlowingGradient is the “set it and forget it” option.

Composing Gradients with Other Views

Both gradient layers integrate cleanly with the rest of the framework. Stack content on top of an animated background with zstack:

use waterui::prelude::*;
use waterui::graphics::AnimatedMeshGradient;

fn welcome_card() -> impl View {
    zstack((
        AnimatedMeshGradient::default(),
        vstack((
            text("Welcome"),
            text("Beautiful gradient backgrounds"),
        ))
        .padding(),
    ))
}

Or constrain the gradient to a specific frame:

use waterui::graphics::{AnimatedMeshGradient, AnimatedMeshGradientConfig};

fn banner() -> impl View {
    AnimatedMeshGradient::new(AnimatedMeshGradientConfig::deep_blue())
        .size(400.0, 200.0)
}

Performance Notes

  • One pass per gradient: Linear, radial, and angular gradients map to native primitives. Mesh and animated mesh gradients run a single full-screen quad through their respective shaders.
  • Reactive efficiency: MeshGradient<C> (low-level) caches the previous color slice and skips uploads when nothing changed. ColorStops on the high-level types are tracked through their Computed<Color> channel.
  • Continuous redraw: AnimatedMeshGradient and FlowingGradient request a redraw every frame while their animation speed is non-zero. Set AnimatedMeshGradientConfig::speed(0.0) to freeze the gradient when the app is backgrounded.

You now have a full toolbox of gradient effects – from simple linear fills to self-animating mesh backgrounds. Drop one behind your hero copy, then move on to the next part of the book where you will assemble these pieces into complete screens.

Animation

In this chapter, you will:

  • Learn how WaterUI’s declarative animation system works with reactive state
  • Use .animated() and .with_animation() to bring values to life
  • Choose between bezier curves and spring physics for different effects
  • Compose multiple animations that run in parallel
  • Implement custom interpolation for your own types

Your app works, but it feels static. Buttons snap into place, views appear instantly, and state changes feel jarring. Animation is the difference between software that functions and software that feels good. WaterUI’s animation system makes this easy: instead of writing imperative “start animation from A to B” code, you attach animation metadata to reactive values and let the framework interpolate automatically whenever those values change.

Reactive Values  --->  Change Propagation  --->  Animation System
(Binding/Compute)      (With Metadata)           (Renderer)

Core Concepts

The animation system lives in waterui_core::animation and exposes two fundamental primitives:

  • Bezier – Timed interpolation along a cubic bezier curve. Great for predictable, time-based transitions like fades and slides.
  • Spring – Physics-based movement with configurable stiffness and damping. Ideal for interactions that should feel organic, like drag releases or toggles.

Both primitives are variants of the Animation enum:

pub enum Animation {
    Default,
    Bezier { duration: Duration, x1: f32, y1: f32, x2: f32, y2: f32 },
    Spring { stiffness: f32, damping: f32 },
}

Now let’s see how to apply these to your reactive values.

Animated Signals

The AnimationExt trait is implemented for every WaterUI reactive signal. It provides two methods that cover most use cases.

.animated()

The quickest way to add animation. This applies a sensible default (ease-in-out, 250 ms) to any reactive value:

use waterui::prelude::*;

let opacity = Binding::f64(1.0);
let animated_opacity = opacity.clone().animated();

When opacity changes from 1.0 to 0.0, the renderer will smoothly transition through intermediate values over 250 ms using an ease-in-out curve.

Tip: .animated() is perfect for quick prototyping. You can always switch to .with_animation() later for finer control.

.with_animation(animation)

When you need a specific curve or duration, use this method instead:

use waterui::prelude::*;
use waterui_core::animation::Animation;
use core::time::Duration;

let scale = Binding::f64(1.0);
let animated_scale = scale.with_animation(
    Animation::ease_in_out(Duration::from_millis(300))
);

Both methods return a WithMetadata<Self, Animation> – the original signal wrapped with animation metadata that the renderer inspects during each frame.

Bezier Animations

Bezier animations use cubic bezier control points to define the easing curve. The curve starts at (0, 0) and ends at (1, 1). The four control-point values (x1, y1, x2, y2) shape the acceleration and deceleration profile.

Convenience Constructors

WaterUI provides four standard curves that match the CSS easing keywords:

ConstructorControl PointsBehavior
Animation::linear(duration)(0.0, 0.0, 1.0, 1.0)Constant velocity
Animation::ease_in(duration)(0.42, 0.0, 1.0, 1.0)Starts slow, accelerates
Animation::ease_out(duration)(0.0, 0.0, 0.58, 1.0)Starts fast, decelerates
Animation::ease_in_out(duration)(0.42, 0.0, 0.58, 1.0)Slow start and end

If you have worked with CSS transitions before, these will feel familiar.

Custom Bezier Curves

For fine-grained control, use Animation::bezier:

use waterui_core::animation::Animation;
use core::time::Duration;

// A bounce-like feel
let bounce = Animation::bezier(
    Duration::from_millis(400),
    0.25, 0.1, 0.25, 1.0,
);

Note: The x1 and x2 values must be in the range [0.0, 1.0]. The y1 and y2 values are unclamped, allowing overshoot effects. Providing out-of-range x values or non-finite values panics from inside Animation::bezier.

Spring Animations

Sometimes a fixed-duration curve does not capture the right feel. Drag releases, toggles, and pull-to-refresh interactions feel more natural with physics-based motion. That is what spring animations are for.

use waterui_core::animation::Animation;

// stiffness: how quickly the spring accelerates (higher = faster)
// damping:   how quickly oscillation decays     (higher = less bounce)
let springy = Animation::spring(100.0, 10.0);

The physics simulation uses the following model:

  • Underdamped (damping / (2 * sqrt(stiffness)) < 1.0) – the spring overshoots and oscillates before settling.
  • Critically damped – the spring reaches its target as fast as possible without overshooting.
  • Overdamped – the spring approaches the target slowly without overshoot.

Tip: Start with stiffness: 100.0 and damping: 10.0, then tweak from there. Lower damping gives more bounce; higher stiffness makes things snappier.

Spring animations do not have a fixed duration. The framework uses a default duration of 600 ms for timing calculations, but the actual visual completion depends on the physics parameters.

use waterui::prelude::*;
use waterui_core::animation::Animation;

let position = Binding::container((0.0, 0.0));
let animated_pos = position.with_animation(Animation::spring(120.0, 12.0));

The Easing System

Under the hood, Animation delegates to EasingCurve for progress calculation. EasingCurve is a standalone type in waterui_core::easing with two variants:

pub enum EasingCurve {
    CubicBezier(f32, f32, f32, f32),
    Spring { stiffness: f32, damping: f32 },
}

You can use EasingCurve directly if you need easing outside of the animation metadata system. Common constants are available:

use waterui_core::easing::EasingCurve;

let _ = EasingCurve::LINEAR;       // (0, 0, 1, 1)
let _ = EasingCurve::EASE_IN;      // (0.42, 0, 1, 1)
let _ = EasingCurve::EASE_OUT;     // (0, 0, 0.58, 1)
let _ = EasingCurve::EASE_IN_OUT;  // (0.42, 0, 0.58, 1)
let _ = EasingCurve::EASE;         // (0.25, 0.1, 0.25, 1) -- CSS default

The Animatable Trait

For the animation system to interpolate between two values, the type must implement Animatable. Each animatable value exposes a payload that the renderer blends frame-by-frame using the lower-level Interpolatable trait from waterui_core::easing:

pub trait Animatable: Clone {
    type AnimatableData: Interpolatable;
    fn animatable_data(&self) -> Self::AnimatableData;
    fn from_animatable_data(data: Self::AnimatableData) -> Self;
}

pub trait Interpolatable: Clone {
    fn lerp(&self, other: &Self, t: f32) -> Self;
}

WaterUI provides built-in Animatable implementations for f32, f64, tuples up to four elements, and fixed-size arrays [T; N] where the element type is Animatable + Copy. The matching Interpolatable impls cover the same shapes on the easing side.

To animate a custom type (for example, a color struct), implement Animatable and pick a tuple or array AnimatableData that the easing system already knows how to interpolate.

Coordinated Transitions

Real-world UIs rarely animate a single property. A card appearing on screen might fade in, slide up, and scale all at once. Because each signal carries its own animation metadata, different properties can use different curves and durations:

use waterui::prelude::*;
use waterui_core::animation::Animation;
use core::time::Duration;

let opacity = Binding::f64(0.0);
let position = Binding::container((0.0, 100.0));
let scale = Binding::f64(0.8);

// Opacity fades in with ease-in-out
let anim_opacity = opacity.with_animation(
    Animation::ease_in_out(Duration::from_millis(300))
);

// Position slides up with a spring
let anim_position = position.with_animation(
    Animation::spring(100.0, 10.0)
);

// Scale grows to 1.0 with ease-out
let anim_scale = scale.with_animation(
    Animation::ease_out(Duration::from_millis(250))
);

When you trigger the state change, all three properties animate in parallel:

// Trigger the "appear" state
let opacity = waterui::prelude::Binding::f64(0.0);
let position = waterui::prelude::Binding::container((0.0, 100.0));
let scale = waterui::prelude::Binding::f64(0.8);
opacity.set(1.0);
position.set((0.0, 0.0));
scale.set(1.0);

The framework handles each animation independently, so a 300 ms opacity fade will finish before a bouncy spring position settles.

Composition with Reactive Operators

Animation metadata composes naturally with map, zip, and other signal combinators. This means you can derive animated values from other signals without any special effort:

use waterui::prelude::*;
use waterui_core::animation::Animation;
use core::time::Duration;

let count = Binding::i32(0);

// Map count to opacity, then animate
let opacity = count
    .map(|n: i32| if n > 5 { 1.0 } else { 0.5 })
    .animated();

// Combine two values and animate the result
let value1 = Binding::i32(1);
let value2 = Binding::i32(2);

let combined = value1
    .zip(&value2)
    .map(|(a, b)| a + b)
    .with_animation(Animation::ease_in_out(Duration::from_millis(250)));

Manual Interpolation

If you need to compute intermediate values outside of the signal system (for example, in a custom view renderer), use Animation::interpolate directly. It accepts the bounds by reference so you can interpolate any Animatable type, including tuples and arrays:

use waterui_core::animation::Animation;
use core::time::Duration;

let anim = Animation::ease_in_out(Duration::from_millis(300));
let elapsed = Duration::from_millis(150);

let value = anim.interpolate(&0.0_f32, &100.0_f32, elapsed);
// value is approximately 50.0, but eased

let is_done = anim.is_complete(elapsed); // false
let is_done = anim.is_complete(Duration::from_millis(300)); // true

The progress method returns the eased progress as a float:

use waterui_core::animation::Animation;
use core::time::Duration;
let anim = Animation::ease_in_out(Duration::from_millis(300));
let p = anim.progress(Duration::from_millis(150));
// p is between 0.0 and 1.0, shaped by the easing curve

Using Animations with View Modifiers

Many ViewExt modifiers accept reactive values. Passing an animated signal automatically animates the visual property – no extra wiring needed:

use waterui::prelude::*;
use waterui_core::animation::Animation;

let angle = Binding::f64(0.0).animated();
let x_scale = Binding::f64(1.0).animated();
let y_scale = Binding::f64(1.0).animated();

text("Hello")
    .rotation(angle)
    .scale(x_scale, y_scale);

When angle or the scale bindings change, the rotation and scale transforms will animate smoothly to their new values.

Try it yourself: Create a button that toggles between angle = 0.0 and angle = 3.14. Watch it spin smoothly each time you tap.

Summary

APIPurpose
signal.animated()Default ease-in-out animation (250 ms)
signal.with_animation(anim)Custom animation configuration
Animation::linear(dur)Constant velocity
Animation::ease_in(dur)Slow start
Animation::ease_out(dur)Slow end
Animation::ease_in_out(dur)Slow start and end
Animation::spring(stiff, damp)Physics-based spring
Animation::bezier(dur, x1, y1, x2, y2)Custom cubic bezier
anim.interpolate(from, to, elapsed)Manual value interpolation
anim.progress(elapsed)Eased progress [0, 1]
anim.is_complete(elapsed)Check if animation finished
EasingCurve::ease(t)Low-level easing calculation
Interpolatable::lerp(other, t)Linear interpolation trait

What’s Next

Your app now moves smoothly, but users interact with more than taps. In the next chapter, you will learn how to recognize gestures – taps, drags, pinches, and rotations – and pair them with the animations you just learned.

Gestures and Haptics

In this chapter, you will:

  • Understand WaterUI’s hit-testing model for touch events
  • Attach tap, long-press, drag, pinch, and rotation gestures to views
  • Inject reactive state and pull it back through the State<T> extractor
  • Compose gestures sequentially, simultaneously, and with priority
  • Add haptic feedback to make interactions feel tangible

A button click is just the beginning. Real apps need drag-to-reorder, pinch-to-zoom, long-press context menus, and double-tap shortcuts. WaterUI provides a declarative gesture system where gesture descriptors are lightweight data structures that backends translate into platform-native gesture recognizers. You describe what you want to recognize, and the platform handles the how.

Hit-Testing Model

Before diving into gestures, it is important to understand how WaterUI decides which view receives a touch event.

WaterUI uses a pass-through model:

  • Non-interactive views (plain Text, Spacer, layout containers) are transparent to touch events. Touches fall through to views behind them in the Z-order.
  • Interactive views (Button, views with a GestureObserver attached) capture touches within their bounds.

In a ZStack or overlay, only the topmost interactive view at a touch location receives the event.

use waterui::prelude::*;

zstack((
    VideoPlayer::new(url).show_controls(true),
    vstack((
        spacer(),  // non-interactive: touches pass through to VideoPlayer
        button("Play").action(|| { /* ... */ }),  // captures touches
    )),
))

Note: If you find that taps are not reaching a view behind an overlay, check whether the overlay contains any interactive elements that might be capturing the touch.

Gesture Types

All gesture descriptors live in waterui::gesture. Each type captures the minimum configuration required for a backend to register the interaction.

TapGesture

Recognizes one or more consecutive taps:

use waterui::gesture::TapGesture;

let single = TapGesture::new();       // single tap
let double = TapGesture::repeat(2);   // double tap
let triple = TapGesture::repeat(3);   // triple tap

LongPressGesture

Activates after the pointer is held for a minimum duration:

use waterui::gesture::LongPressGesture;

let press = LongPressGesture::new(500); // 500ms minimum hold

The duration unit is interpreted by each backend (typically milliseconds).

DragGesture

Begins after the pointer moves beyond a minimum distance:

use waterui::gesture::DragGesture;

let drag = DragGesture::new(5.0); // 5pt minimum travel

MagnificationGesture

Recognizes pinch-to-zoom interactions:

use waterui::gesture::MagnificationGesture;

let pinch = MagnificationGesture::new(1.0); // initial scale factor

RotationGesture

Recognizes two-finger rotation:

use waterui::gesture::RotationGesture;

let rotation = RotationGesture::new(0.0); // initial angle in radians

All of these types can be converted into the unified Gesture enum, which also contains composition variants (Then, Simultaneous, Exclusive) that we will explore later in this chapter.

use waterui::gesture::{Gesture, TapGesture};

let gesture: Gesture = TapGesture::new().into();

Gesture Event Payloads

When a backend recognizes a gesture, it creates an event payload and places it in the environment. The payload types carry interaction details:

Event TypeFields
TapEventlocation: GesturePoint, count: u32
LongPressEventlocation: GesturePoint, duration: f32
DragEventphase, location, translation, velocity
MagnificationEventphase, center, scale, velocity

GesturePhase tracks the lifecycle: Started, Updated, Ended, or Cancelled.

Attaching Gestures to Views

Now that you know the gesture types, let’s attach them to views. WaterUI offers several approaches, from general-purpose to convenient shorthand.

The .gesture() Method

The most general way to attach a gesture is through ViewExt::gesture:

use waterui::prelude::*;
use waterui::gesture::TapGesture;

text("Tap Me!")
    .gesture(TapGesture::new(), || {
        tracing::info!("Tapped!");
    })

The first argument is anything that implements Into<Gesture>. The second is any type that implements Handler<Args, ()> – a plain closure with no arguments, a closure that pulls injected state via State<T>, or any combination of extractors documented in Resolvers and Hooks.

.on_tap()

A convenience shorthand for single-tap gestures:

use waterui::prelude::*;

text("Click me")
    .on_tap(|| tracing::info!("Clicked!"))

.on_tap_gesture() and .on_tap_gesture_count()

.on_tap_gesture() is an alias for .on_tap(). Use .on_tap_gesture_count(n, action) for multi-tap gestures:

use waterui::prelude::*;

text("Double-tap me")
    .on_tap_gesture_count(2, || {
        tracing::info!("Double tapped!");
    })

.on_long_press_gesture()

Attach a long-press handler with a minimum duration in milliseconds:

use waterui::prelude::*;

text("Long press me")
    .on_long_press_gesture(500, || {
        tracing::info!("Long pressed!");
    })

.gesture_observer()

For full control, construct a GestureObserver directly. The action is any type that implements Handler<Args, ()>, so you can pull captured state out of the environment with the State<T> extractor:

use waterui::prelude::*;
use waterui::gesture::{GestureObserver, TapGesture};
use waterui::reactive::binding;

let counter = binding(0i32);

text("Count taps")
    .state(&counter)
    .gesture_observer(GestureObserver::new(
        TapGesture::new(),
        |State(counter): State<Binding<i32>>| counter.set(counter.get() + 1),
    ))

Stateful Gesture Handlers

Most interactions need access to reactive state – counting taps, tracking drag positions, or toggling a boolean. WaterUI keeps the handler ergonomic without a custom builder: inject state into the subtree’s environment with ViewExt::state, then ask for it back through the State<T> extractor in the handler signature.

Injecting State

ViewExt::state clones a binding (or any cloneable value) into the subtree’s environment. The injected value is keyed by type, so every handler in scope can extract it:

use waterui::prelude::*;
use waterui::gesture::TapGesture;
use waterui::reactive::binding;

let count = binding(0i32);

text("Tap to count")
    .padding()
    .background(Color::srgb(200, 220, 255))
    .state(&count)
    .gesture(
        TapGesture::new(),
        |State(count): State<Binding<i32>>| count.set(count.get() + 1),
    )

Stack multiple .state(...) calls to inject several values. Each extractor in the handler tuple pulls one out:

use waterui::prelude::*;
use waterui::reactive::binding;

let count = binding(0i32);
let label = binding(String::from("Ready"));

text("Interact")
    .state(&count)
    .state(&label)
    .on_tap(
        |State(count): State<Binding<i32>>,
         State(label): State<Binding<String>>| {
            count.set(count.get() + 1);
            label.set(format!("Tapped {} times", count.get()));
        },
    )

Inside a GestureObserver

The same State<T> extractor works when you build a GestureObserver manually – the action signature is identical to the one you would pass to .gesture(...):

use waterui::gesture::{GestureObserver, TapGesture};
use waterui::prelude::*;
use waterui::reactive::binding;

let counter = binding(0i32);

let observer = GestureObserver::new(
    TapGesture::repeat(2),
    |State(counter): State<Binding<i32>>| counter.set(counter.get() + 1),
);

Attach the observer with view.state(&counter).gesture_observer(observer) so the binding is available when the handler fires.

Combining Gestures

Single gestures are useful, but real interactions often involve combinations. WaterUI supports three composition modes, mirroring SwiftUI.

Sequential: .then()

The second gesture starts only after the first completes:

use waterui::gesture::{TapGesture, LongPressGesture};

let chained = TapGesture::new()
    .then(LongPressGesture::new(300));
// User must tap, then long-press

.sequenced_before() is an alias for .then().

Simultaneous: .simultaneously_with()

Both gestures can be recognized at the same time:

use waterui::gesture::{TapGesture, DragGesture};

let combined = TapGesture::new()
    .simultaneously_with(DragGesture::new(8.0));

Exclusive: .exclusively_before()

The first gesture has recognition priority; the second is a fallback:

use waterui::gesture::{TapGesture, LongPressGesture};

let exclusive = TapGesture::new()
    .exclusively_before(LongPressGesture::new(500));

These composition methods can be chained to build arbitrarily complex gesture graphs. Each produces a Gesture::Then, Gesture::Simultaneous, or Gesture::Exclusive variant.

Priority Modifiers

ViewExt provides SwiftUI-style naming for attaching composed gestures directly to views:

use waterui::prelude::*;
use waterui::gesture::DragGesture;

text("Drag or tap")
    .simultaneous_gesture(DragGesture::new(5.0), || {
        tracing::info!("Drag detected");
    })
    .high_priority_gesture(
        waterui::gesture::TapGesture::new(),
        || tracing::info!("Tap wins")
    )

Haptic Feedback

On platforms that support it (iOS, Android), WaterUI integrates with the waterkit-haptic crate to trigger tactile feedback alongside gestures. Haptics make interactions feel real – a subtle vibration on a successful action, a heavier pulse on a destructive one.

.on_tap_haptic()

Combines a tap gesture with haptic impact:

use waterui::prelude::*;
use waterkit_haptic::Intensity;

text("Haptic Tap")
    .on_tap_haptic(Intensity::MEDIUM, || {
        tracing::info!("Felt that!");
    })

Intensity provides constants for common feedback levels. The haptic fires before the action closure runs.

.on_tap_haptic_default()

Uses Intensity::MEDIUM as a sensible default:

use waterui::prelude::*;

text("Default Haptic")
    .on_tap_haptic_default(|| {
        tracing::info!("Medium haptic fired");
    })

Note: Both haptic methods require the std feature flag and are no-ops on platforms without haptic hardware.

Complete Example

Let’s put it all together. This example demonstrates multiple gesture types, state handling, and gesture composition in a single view:

use waterui::prelude::*;
use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};
use waterui::reactive::binding;

fn main() -> impl View {
    let tap_count = binding(0i32);
    let long_press_count = binding(0i32);
    let drag_count = binding(0i32);
    let chained_status = binding(String::from("Waiting..."));

    fn bump(c: &Binding<i32>) {
        c.set(c.get() + 1);
    }

    scroll(vstack((
        text("Gesture Demo").title(),

        // Single tap
        text("Tap Me!")
            .padding()
            .background(Color::srgb(33, 150, 243).with_opacity(0.3))
            .state(&tap_count)
            .gesture(
                TapGesture::new(),
                |State(c): State<Binding<i32>>| bump(&c),
            ),

        // Long press
        text("Long Press Me!")
            .padding()
            .background(Color::srgb(255, 152, 0).with_opacity(0.3))
            .state(&long_press_count)
            .gesture(
                LongPressGesture::new(500),
                |State(c): State<Binding<i32>>| bump(&c),
            ),

        // Drag
        text("Drag Here")
            .padding()
            .width(200.0).height(100.0)
            .background(Color::srgb(156, 39, 176).with_opacity(0.3))
            .state(&drag_count)
            .gesture(
                DragGesture::new(5.0),
                |State(c): State<Binding<i32>>| bump(&c),
            ),

        // Chained: tap then long press
        text("Tap then Long Press")
            .padding()
            .background(Color::srgb(244, 67, 54).with_opacity(0.3))
            .state(&chained_status)
            .gesture(
                TapGesture::new().then(LongPressGesture::new(300)),
                |State(s): State<Binding<String>>| {
                    s.set(String::from("Chained gesture completed!"));
                },
            ),
    )))
}

Try it yourself: Add a double-tap gesture to one of the views above using TapGesture::repeat(2). Can you make it coexist with the single tap using .exclusively_before()?

Summary

APIPurpose
TapGesture::new()Single tap
TapGesture::repeat(n)Multi-tap
LongPressGesture::new(ms)Long press with minimum duration
DragGesture::new(distance)Drag with minimum distance
MagnificationGesture::new(scale)Pinch-to-zoom
RotationGesture::new(angle)Two-finger rotation
.gesture(g, action)Attach any gesture to a view
.on_tap(action)Single-tap shorthand
.on_tap_gesture_count(n, action)Multi-tap shorthand
.on_long_press_gesture(ms, action)Long-press shorthand
.gesture_observer(observer)Full-control gesture attachment
.state(&binding)Inject cloneable state into the subtree environment
State<T> extractorPull injected state into a handler
.then()Sequential composition
.simultaneously_with()Parallel composition
.exclusively_before()Priority composition
.on_tap_haptic(intensity, action)Tap with haptic feedback
.on_tap_haptic_default(action)Tap with medium haptic

What’s Next

Your app now responds to rich touch interactions. But what happens when a gesture triggers a network request? In the next chapter, you will learn how to handle async operations gracefully with Suspense, showing loading states while data arrives.

Suspense and Async Views

In this chapter, you will:

  • Use Suspense to show loading states while async operations run
  • Customize loading views per-instance or app-wide
  • Implement the SuspendedView trait for environment-aware loading
  • Combine Suspense with reactive state for data that changes over time
  • Understand task lifecycle and cancellation

Most applications need to load data asynchronously – from a network API, a database, or a file system. Without proper handling, your users stare at a blank screen wondering if the app is broken. WaterUI’s Suspense component solves this declaratively: show a placeholder while an async operation runs, then seamlessly swap in the loaded content.

The Suspense Component

Suspense lives in waterui::widget::suspense. It wraps any type that implements the SuspendedView trait and pairs it with a loading view:

use waterui::widget::suspense::Suspense;

async fn fetch_user() -> impl View {
    // simulate network request
    text("John Doe")
}

let view = Suspense::new(fetch_user());

When the view tree is built, Suspense:

  1. Immediately renders the loading view (by default, whatever DefaultLoadingView is in the environment).
  2. Spawns the async content on the local executor.
  3. Once the future resolves, replaces the loading view with the loaded content.

Internally, Suspense creates a Dynamic view and uses its handler to swap content when the future completes.

Custom Loading Views

The default loading view might not fit your design. WaterUI gives you two ways to customize it: per-instance and app-wide.

Inline Loading View

Use .loading() to provide a custom loading view for a specific Suspense instance. The method has two type parameters – the loading view type and the async output view type – so the call site needs the turbofish to pin down the output type:

use waterui::widget::suspense::Suspense;
use waterui::prelude::*;
use waterui::text::Text;

async fn fetch_data() -> Text {
    text("Data loaded!")
}

let view = Suspense::new(fetch_data())
    .loading::<_, Text>(text("Loading data..."));

The loading view can be any type that implements View – a spinner, a skeleton placeholder, or even a complex layout. Pin the output of the async function to a concrete view type (here, Text) so the second turbofish slot can match it.

Environment-Based Default

To set a consistent loading view across your entire application, install a DefaultLoadingView in the environment. DefaultLoadingView::new accepts any ViewBuilder, which is satisfied by closures of the form Fn() -> impl View:

use waterui::widget::suspense::DefaultLoadingView;
use waterui::app::App;
use waterui::prelude::*;

fn app(env: Environment) -> App {
    let mut env = env;
    env.insert(DefaultLoadingView::new(|| {
        vstack((
            text("Please wait..."),
        ))
    }));
    App::new(main, env)
}

Any Suspense component that does not provide an explicit .loading() view will use this default. If no DefaultLoadingView is installed, Suspense renders an empty view while loading.

Tip: Always install a DefaultLoadingView in your root environment. This ensures every Suspense in your app has a visible loading state, even if you forget to add .loading() at a specific call site.

UseDefaultLoadingView

UseDefaultLoadingView is the sentinel type used internally. When it renders, it queries the environment for a DefaultLoadingView and invokes its builder. You can use it explicitly if you want:

use waterui::widget::suspense::{Suspense, UseDefaultLoadingView};

let view = Suspense::new(fetch_data())
    .loading::<_, ()>(UseDefaultLoadingView);

This is equivalent to Suspense::new(fetch_data()).

The SuspendedView Trait

Suspense accepts anything that implements SuspendedView:

pub trait SuspendedView: 'static {
    fn body(self, env: Environment) -> impl Future<Output = impl View>;
}

Automatic Implementation for Futures

Any Future whose output implements View automatically satisfies SuspendedView. This is why the simple async function approach works out of the box:

async fn load_profile() -> impl View {
    let data = api::get_profile().await;
    text(data.name)
}

// This works because the future implements SuspendedView
let view = Suspense::new(load_profile());

Custom SuspendedView

For more control, implement SuspendedView directly. This gives you access to the Environment during the async operation, which is useful when you need services like API clients or configuration:

use waterui::widget::suspense::SuspendedView;
use waterui::prelude::*;

struct UserLoader {
    user_id: u32,
}

impl SuspendedView for UserLoader {
    async fn body(self, env: Environment) -> impl View {
        // Access environment services during loading
        let api_client = env.get::<ApiClient>().unwrap().clone();
        let user = api_client.fetch_user(self.user_id).await;

        vstack((
            text(user.name).headline(),
            text(user.email),
        ))
    }
}

let view = Suspense::new(UserLoader { user_id: 42 });

The environment is cloned when the future is spawned, so you have access to all services, themes, and configuration that were in scope.

The suspense() Function

A convenience function creates a Suspense with the default loading view:

use waterui::widget::suspense::suspense;

let view = suspense(async {
    let data = load_something().await;
    text(data)
});

Error Handling within Suspense

Async operations can fail. Since Result<V, E> implements View when both V: View and E: View, you can handle errors directly inside the async block:

use waterui::prelude::*;
use waterui::widget::suspense::Suspense;
use waterui::widget::error::Error;

async fn fetch_with_error() -> impl View {
    match api::get_data().await {
        Ok(data) => text(data.content).anyview(),
        Err(e) => Error::new(e).anyview(),
    }
}

let view = Suspense::new(fetch_with_error());

For a more ergonomic pattern, combine with the ResultExt trait described in the Error Handling chapter.

Combining Suspense with Reactive State

Suspense is a one-shot component – it resolves once and then shows the result. But what if your data source can change? For example, a user profile page where the user ID comes from navigation state. Combine Suspense with Dynamic::watch to trigger reloads:

use waterui::prelude::*;
use waterui::widget::suspense::Suspense;

fn user_profile(user_id: Binding<u32>) -> impl View {
    Dynamic::watch(user_id, |id| {
        Suspense::new(async move {
            let user = api::get_user(id).await;
            text(user.name)
        })
    })
}

Every time user_id changes, a new Suspense is created, which shows the loading view and kicks off a fresh async operation.

Lifecycle and Cancellation

The async task spawned by Suspense uses executor_core::spawn_local. The task handle is detached, meaning it will run to completion even if the Suspense view is removed from the tree.

Warning: If you navigate away from a screen while a Suspense task is running, the task will complete in the background. Be mindful of this if your async operation has side effects.

If you need cancellation semantics, tie the task to the view lifecycle using ViewExt::task instead of Suspense:

use waterui::prelude::*;

fn my_view() -> impl View {
    let data = Binding::container::<Option<String>>(None);
    let data_for_task = data.clone();

    text("Loading...")
        .task(async move {
            let result = api::get_data().await;
            data_for_task.set(Some(result));
        })
}

The task spawned by .task() returns a handle that is retained by the view. When the view is dropped, the handle is dropped and the task is cancelled.

Nested Suspense

You can nest Suspense components for situations where loaded content itself needs to fetch more data. Each inner suspense manages its own loading state independently:

use waterui::prelude::*;
use waterui::widget::suspense::Suspense;

let view = Suspense::new(async {
    let user = api::get_user(1).await;

    vstack((
        text(user.name).headline(),
        Suspense::new(async move {
            let posts = api::get_posts(user.id).await;
            vstack(
                posts.into_iter().map(|p| text(p.title)).collect::<Vec<_>>()
            )
        }).loading(text("Loading posts...")),
    ))
}).loading(text("Loading user..."));

The outer suspense shows “Loading user…” while the user is fetched. Once the user loads, the inner suspense shows “Loading posts…” while fetching posts. This creates a progressive loading experience where content appears as it becomes available.

Summary

APIPurpose
Suspense::new(content)Create suspense with default loading view
.loading(view)Set a custom loading view
suspense(future)Convenience function
SuspendedView traitCustom async content loading
DefaultLoadingView::new(builder)App-wide default loading view
UseDefaultLoadingViewRender the default loading view
ViewExt::task(future)Lifecycle-bound async task
Dynamic::watch(signal, f)Reactive suspense reloading

What’s Next

Async operations can fail, and when they do, your users need to see something useful – not a blank screen. In the next chapter, you will learn how WaterUI turns errors into views and how to build consistent error presentation across your entire application.

Error Handling

In this chapter, you will:

  • Understand how Result and Option work as views in WaterUI
  • Use the Error type to render any std::error::Error visually
  • Configure app-wide error presentation with DefaultErrorView
  • Convert errors to custom views inline with ResultExt
  • Build nested error boundaries for different parts of your UI

Errors in UI applications need special treatment. In a traditional Rust program you propagate errors with ? until someone handles them. But in a declarative UI, errors must become visible – they need to render as views that the user can see and act on. A network failure should not crash your app; it should show a helpful message with a retry button.

WaterUI provides two complementary modules for this:

  • waterui::widget::error – The Error type, DefaultErrorView, UseDefaultErrorView, and the ResultExt trait.
  • waterui::error – A simpler ErrorView and ErrorViewBuilder for environment-based rendering.

Errors as Views

The key insight is that Result<V, E> implements View when both V: View and E: View. This means you can return a Result directly from a view body:

use waterui::prelude::*;

fn user_card() -> impl View {
    match load_user() {
        Ok(user) => text(user.name).anyview(),
        Err(_) => text("Failed to load user").anyview(),
    }
}

WaterUI also makes Option<V> a ViewNone renders as an empty view, Some(v) renders v. These two implementations let you write fallible view functions with minimal boilerplate.

The Error Type

For real applications, you want more than a string. waterui::widget::error::Error is a type-erased error wrapper that implements View. It wraps any std::error::Error and renders it using the environment’s configured error view builder:

use waterui::widget::error::Error;

let io_err = std::io::Error::new(
    std::io::ErrorKind::NotFound,
    "File not found",
);
let error_view = Error::new(io_err);

When rendered, Error looks for a DefaultErrorView in the environment. If one is found, it delegates rendering to that builder. If not, it uses UseDefaultErrorView which falls back to an empty view.

Creating Errors from Views

You can also create an Error directly from a view. This is useful when you want to present a rich error UI that does not originate from a Rust std::error::Error:

use waterui::prelude::*;
use waterui::widget::error::Error;

let custom_error = Error::from_view(vstack((
    text("Something went wrong!"),
    text("Please try again later."),
)));

Type Downcasting

Error preserves the original error type and supports downcasting, so you can recover the specific error when you need to:

use waterui::widget::error::Error;
use std::io;

let error = Error::new(
    io::Error::new(io::ErrorKind::NotFound, "File not found")
);

match error.downcast::<io::Error>() {
    Ok(io_error) => {
        // Handle specific IO error
        assert_eq!(io_error.kind(), io::ErrorKind::NotFound);
    }
    Err(original) => {
        // Not an IO error, handle generically
        drop(original);
    }
}

DefaultErrorView

Now let’s set up consistent error presentation across your entire app. DefaultErrorView is a configuration type stored in the Environment. It holds a builder function that converts any boxed error into a view:

use waterui::prelude::*;
use waterui::widget::error::{BoxedStdError, DefaultErrorView};

let env = Environment::new().extending(DefaultErrorView::new(
    |error: BoxedStdError| {
        let message = Binding::container(error.to_string());
        vstack((
            text!("Error: {message}"),
            text("Please contact support if this persists.")
                .foreground(Color::srgb(128, 128, 128)),
        ))
    },
));

Environment::extending is the chainable, by-value form: it returns a new Environment that overlays the inserted value on top of the previous state. Use it for builder-style setup. If you already own &mut Environment, the shorter env.with(value) and env.insert(value) mutate in place.

Every Error view rendered within this environment will use this builder to present the error. This creates a consistent error appearance throughout your application.

UseDefaultErrorView

UseDefaultErrorView is the view type that performs the environment lookup. It queries for DefaultErrorView and invokes the builder:

use waterui::widget::error::UseDefaultErrorView;

let view = UseDefaultErrorView::new(some_error);
// Renders using the DefaultErrorView from the environment,
// or renders empty if none is configured.

In practice, you rarely use UseDefaultErrorView directly – Error::new creates one internally.

The ErrorView (Simple Module)

The waterui::error module provides a simpler alternative when you want a quick error display without the full DefaultErrorView machinery:

use waterui::error::ErrorView;

let view = ErrorView::from(
    std::io::Error::new(std::io::ErrorKind::NotFound, "Not found")
);

If an ErrorViewBuilder is present in the environment, it is used for rendering. Otherwise, ErrorView falls back to rendering the error message as plain text:

use waterui::error::ErrorViewBuilder;
use waterui::prelude::*;

let builder = ErrorViewBuilder::new(|error, env| {
    text(format!("Error: {error}")).anyview()
});

let mut env = Environment::new();
env.insert(builder);

The ResultExt Trait

ResultExt adds the .error_view() method to any Result, letting you convert errors to custom views inline. This is particularly useful when different call sites need different error presentations:

use waterui::prelude::*;
use waterui::widget::error::ResultExt;

fn load_data() -> Result<String, std::io::Error> {
    Ok("data".to_string())
}

fn my_view() -> impl View {
    match load_data().error_view(|err| {
        let message = Binding::container(err.to_string());
        text!("Failed to load: {message}")
    }) {
        Ok(data) => text(data).anyview(),
        Err(error_view) => error_view.anyview(),
    }
}

.error_view() transforms the Err variant into an Error that wraps the view you provide. The Ok variant passes through unchanged.

Pairing Error Handling with Suspense

Errors and async loading go hand-in-hand. Here is a pattern that combines Suspense with Error for a complete loading-and-error experience:

use waterui::prelude::*;
use waterui::widget::suspense::Suspense;
use waterui::widget::error::Error;

async fn fetch_profile() -> impl View {
    match api::get_profile().await {
        Ok(profile) => vstack((
            text(profile.name).headline(),
            text(profile.bio),
        )).anyview(),
        Err(e) => Error::new(e).anyview(),
    }
}

fn profile_screen() -> impl View {
    Suspense::new(fetch_profile())
        .loading(text("Loading profile..."))
}

When the async operation fails, the error renders using your application’s DefaultErrorView. When it succeeds, the profile content appears.

Nested Error Boundaries

Because Error renders as a regular view, error boundaries compose naturally with the view hierarchy. To override DefaultErrorView for a subtree, wrap the configuration in a small plugin and install it with ViewExt::install:

use waterui::prelude::*;
use waterui::widget::error::{BoxedStdError, DefaultErrorView};
use waterui_core::{Environment, plugin::Plugin};

struct TopLevelErrorStyle;

impl Plugin for TopLevelErrorStyle {
    fn install(self, env: &mut Environment) {
        env.insert(DefaultErrorView::new(|error: BoxedStdError| {
            vstack((
                text("Application Error").headline(),
                text(error.to_string()),
                button("Retry").action(|| { /* retry logic */ }),
            ))
        }));
    }
}

fn app_shell() -> impl View {
    vstack((
        header(),
        content_area(),
    )).install(TopLevelErrorStyle)
}

Different parts of the view tree can install different DefaultErrorView plugins to customize error presentation per-section.

Best Practices

  1. Always install a DefaultErrorView in your root environment. This ensures that any uncaught error has a visible representation rather than rendering as an empty view.

  2. Use .error_view() for localized error handling when a specific call site needs a custom error presentation.

  3. Use Error::from_view() for rich error UIs that include retry buttons, contact links, or contextual information.

  4. Prefer Error::new() over Error::from_view() when you want consistent, centralized error styling from DefaultErrorView.

  5. Combine with Suspense for async operations that can fail. The SuspendedView body is the natural place to handle both success and error cases.

Summary

APIPurpose
Error::new(e)Wrap any std::error::Error as a view
Error::from_view(view)Create an error from a custom view
Error::downcast::<T>()Recover the original error type
DefaultErrorView::new(builder)App-wide error view configuration
UseDefaultErrorView::new(e)Render using the environment’s error builder
ErrorView::from(e)Simple error-to-view (with text fallback)
ErrorViewBuilder::new(f)Custom error renderer for the simple module
ResultExt::error_view(f)Convert Result::Err to a custom view
Result<V, E>: ViewBuilt-in: results render as views
Option<V>: ViewBuilt-in: None renders empty

What’s Next

Your app handles errors gracefully, but does it work for everyone? In the next chapter, you will learn how to make your WaterUI application accessible to users who rely on screen readers, keyboard navigation, and other assistive technologies.

Accessibility

In this chapter, you will:

  • Learn how WaterUI’s built-in accessibility defaults work
  • Override labels, roles, and states for custom widgets
  • Hide decorative elements from screen readers
  • Respect reduced motion preferences
  • Test accessibility with platform tools

Your app looks great and handles errors gracefully. But can everyone use it? A user who relies on VoiceOver, TalkBack, or keyboard navigation deserves the same experience as someone tapping a touchscreen. The good news: WaterUI components ship with sensible accessibility defaults. Buttons announce themselves as buttons, text views expose their content, and interactive controls report their states. This chapter covers what to do when the defaults are not enough – when you build custom composite widgets, use icons without text labels, or need to communicate specific semantic meaning to assistive technologies.

The accessibility types live in waterui::accessibility and are attached to views through ViewExt methods.

Design Philosophy

WaterUI follows two principles:

  1. Defaults first. Built-in components already carry the right roles, labels, and states. You should not need to touch accessibility code for standard UIs.
  2. Override when necessary. Custom widgets, decorative elements, and complex layouts sometimes need explicit annotations.

Because WaterUI renders to native platform widgets, accessibility metadata maps directly to the platform’s accessibility APIs (UIAccessibility on Apple, AccessibilityNodeInfo on Android, ATK/AT-SPI on GTK).

AccessibilityLabel

An AccessibilityLabel overrides the spoken label for a component. Use it when the visual content does not adequately describe the element’s purpose – the most common case is an icon-only button.

use waterui::prelude::*;

// An icon-only button (label is read by VoiceOver, not painted on screen).
button(trash_icon())
    .action(delete_item)
    .a11y_label("Delete draft")

The label should be short, action-oriented, and match what a sighted user would understand from context. Avoid redundant prefixes like “Button:” – the accessibility role already communicates that.

Creating Labels

AccessibilityLabel::new accepts anything that converts to Str:

use waterui::accessibility::AccessibilityLabel;

let label = AccessibilityLabel::new("Delete draft");
let label = AccessibilityLabel::new(format!("Item {index}"));

Attaching to Views

Use ViewExt::a11y_label:

use waterui::prelude::*;

logo_image().a11y_label("Company logo")

AccessibilityRole

An AccessibilityRole describes the semantic purpose of a component. WaterUI components set their own roles (a Button is Role::Button, a Toggle is Role::Switch), but custom composites need explicit role assignment.

Available Roles

The AccessibilityRole enum covers a wide range of semantics:

CategoryRoles
InteractiveButton, Link, Checkbox, RadioButton, Switch, Slider
ContentText, Image, Header, Footer, Article
StructureNavigation, Main, Search, Section, Group
CollectionsList, ListItem, Tab, TabList, TabPanel
MenusMenu, MenuItem, MenuBar, MenuItemCheckbox, MenuItemRadio
FormsCombobox, Option, ProgressBar

Attaching Roles

Use ViewExt::a11y_role. Here is a custom toggle that would otherwise be invisible to assistive technology:

use waterui::prelude::*;
use waterui::accessibility::AccessibilityRole;

fn custom_toggle(is_on: &Binding<bool>) -> impl View {
    let background = is_on.map(|on| {
        if on { Color::srgb(52, 199, 89) } else { Color::srgb(200, 200, 200) }
    });

    hstack((knob(),))
        .padding()
        .background(background)
        .a11y_role(AccessibilityRole::Switch)
        .a11y_label("Dark mode")
        .state(is_on)
        .on_tap(|State(b): State<Binding<bool>>| b.toggle())
}

The role tells VoiceOver/TalkBack to announce this as a switch and provide the appropriate interaction hints.

AccessibilityState

AccessibilityState communicates nuanced state information to assistive technologies. You need it when building custom controls whose state goes beyond what a label and role can express.

pub struct AccessibilityState {
    disabled: bool,
    selected: bool,
    checked: Option<bool>,
    expanded: Option<bool>,
    busy: bool,
    hidden: bool,
}
FieldMeaning
disabledThe control is visible but not interactive
selectedThe control is the current selection in its group
checkedChecked (Some(true)), unchecked (Some(false)), or mixed/indeterminate (None when the concept applies)
expandedExpanded (Some(true)) or collapsed (Some(false)) for disclosure controls
busyThe control is loading or processing
hiddenThe control should be invisible to assistive technology

When to Use States

Most of the time, built-in components handle state automatically. Use AccessibilityState when you build custom controls:

  • A custom accordion needs expanded.
  • A custom checkbox needs checked.
  • A skeleton loading placeholder needs busy.
  • Decorative elements need hidden.

Hiding Decorative Elements

Purely decorative views (background patterns, divider lines, brand marks) should be hidden from assistive technology so screen readers do not announce noise. Use ViewExt::a11y_hidden(true), which attaches the AccessibilityHidden metadata:

use waterui::prelude::*;

decorative_swirl().a11y_hidden(true)

If you also want to drop a subtree’s children from the tree (for example, an icon-and-label composite that you re-described with a single label), use ViewExt::a11y_children(AccessibilityChildren::ExcludeDescendants) instead.

Custom Control Accessibility

Building a fully accessible custom control requires combining label, role, and state. Here is a complete example of a custom star rating widget:

use waterui::prelude::*;
use waterui::accessibility::AccessibilityRole;
use waterui::reactive::watch;

fn star_rating(rating: &Binding<i32>, max: i32) -> impl View {
    let label = rating.map(move |r| format!("Rating: {r} out of {max}"));

    hstack(
        (0..max).map(|i| {
            let filled = rating.map(move |r| r > i);
            let star_label = format!("{} star", i + 1);

            watch(filled, |is_filled| {
                if is_filled { text("*") } else { text("o") }
            })
            .a11y_label(star_label)
            .a11y_role(AccessibilityRole::Button)
            .state(rating)
            .on_tap(move |State(r): State<Binding<i32>>| r.set(i + 1))
        }).collect::<Vec<_>>()
    )
    .a11y_role(AccessibilityRole::Slider)
    .a11y_label(label)
}

The container has Slider role and a dynamic label. Each star has Button role with its own label. This gives screen reader users both the overall rating and individual star controls.

Try it yourself: Build a custom accordion component and use AccessibilityState with the expanded field to announce whether each section is open or closed.

Reduced Motion

Some users are sensitive to animation. WaterUI does not yet ship a built-in “prefers reduced motion” signal – the recommended pattern is to define your own marker type, install it from the platform layer, and gate animation metadata behind it:

use waterui::prelude::*;
use waterui_core::animation::Animation;
use core::time::Duration;

#[derive(Debug, Clone, Copy)]
struct PrefersReducedMotion(bool);

fn animated_entrance(env: &Environment) -> impl View {
    let opacity = Binding::f64(0.0);

    let prefers_reduced = env
        .get::<PrefersReducedMotion>()
        .map_or(false, |p| p.0);

    let target_opacity = if prefers_reduced {
        opacity.clone().computed()
    } else {
        opacity
            .clone()
            .with_animation(Animation::ease_in_out(Duration::from_millis(300)))
            .computed()
    };

    text("Welcome!")
        .opacity(target_opacity)
        .on_appear(move || opacity.set(1.0))
}

Note: Respecting reduced motion is a real accessibility requirement that affects users with vestibular disorders. Wire your platform’s reduced-motion API into the environment from your backend integration.

Accessible Navigation

When using NavigationView or TabView, WaterUI automatically sets the correct navigation landmarks. Screen readers announce tab switches and navigation transitions. You can enhance this by adding descriptive labels to containers:

use waterui::prelude::*;
use waterui::accessibility::AccessibilityRole;

fn sidebar() -> impl View {
    vstack((
        text("Menu").headline(),
        button("Home").action(|| {}),
        button("Settings").action(|| {}),
    ))
    .a11y_role(AccessibilityRole::Navigation)
    .a11y_label("Main navigation")
}

Focus Management

WaterUI’s focus system (covered in the Modifiers chapter) works with accessibility. When a view is focused, the accessibility system announces it. The focused() modifier on ViewExt integrates with both the visual focus ring and the accessibility focus:

use waterui::prelude::*;

let focus = Binding::container::<Option<Field>>(None);
let name = Binding::container(Str::from(""));

field("Name", &name)
    .focused(&focus, Field::Name)

When focus is set to Some(Field::Name), VoiceOver/TalkBack will move focus to that field.

Testing Accessibility

WaterUI’s preferred automated check is the waterui-testing crate, which drives views through the Hydrolysis accessibility tree. Because every component is expected to expose meaningful accessibility metadata, waterui-testing doubles as both an interaction harness and an accessibility-correctness check. Treat a missing or wrong tree as a bug to fix in the component, not a gap to paper over.

For visual smoke checks, render a view with water preview ... --output preview.png and inspect the result. Pair these with platform-native auditors when shipping:

  • iOS: Accessibility Inspector in Xcode
  • Android: Accessibility Scanner
  • macOS: VoiceOver (Cmd+F5)
  • Linux/GTK: Accerciser (AT-SPI explorer)

Tip: Spend ten minutes navigating your app with VoiceOver or TalkBack before shipping. Automated checks cannot replace the experience of actually hearing how a screen reader interprets your UI.

Best Practices

  1. Let defaults work. Do not add .a11y_label() to every view. Built-in components already expose their text content.

  2. Label icons and images. Any visual element without text needs an explicit label.

  3. Use semantic roles. A custom div-like container should have Group, Navigation, or Main role depending on purpose.

  4. Hide decorative content. Background images, dividers, and brand marks should not be announced.

  5. Test with a screen reader. Automated checks cannot replace the experience of navigating your app with VoiceOver or TalkBack.

  6. Provide dynamic labels. Use reactive bindings to keep accessibility labels in sync with changing content.

Summary

APIPurpose
.a11y_label(text)Override the spoken label
.a11y_role(role)Set the semantic role
AccessibilityLabel::new(text)Create a label value
AccessibilityRole::ButtonInteractive control role
AccessibilityRole::ImageImage role
AccessibilityRole::TextNon-interactive text
AccessibilityRole::NavigationNavigation landmark
AccessibilityRole::SwitchToggle/switch control
AccessibilityRole::SliderRange input
AccessibilityStateDisabled, selected, checked, expanded, busy, hidden
.focused(binding, value)Programmatic focus management

What’s Next

Your app is accessible to users regardless of ability. But what about users who speak different languages? In the next chapter, you will learn how WaterUI’s internationalization system handles translations, plural rules, and locale-aware formatting.

Internationalization

In this chapter, you will:

  • Set up locale identifiers and understand fallback chains
  • Write TOML translation files with plural support
  • Use the text! macro for localized, reactive text views
  • Format dates, numbers, units, and lists according to locale
  • Switch locales at runtime with instant UI updates

Your app has users worldwide – it is time to make it speak their language. Getting internationalization right means more than translating strings. Different languages have different plural rules (“1 apple” vs. “2 apples” vs. Russian’s four forms), different date formats, and different list conventions. WaterUI’s waterui::locale module handles all of this, integrated with the reactive system so the UI updates instantly when the user changes their language.

Overview

The i18n system is built on several pillars:

  • ICU4X for locale identifiers, plural rules, and list formatting.
  • TOML translation files for storing localized strings.
  • The text! macro for embedding localizable text in views.
  • Reactive locale tracking via Binding<Locale> so views re-render when the locale changes.

The Locale Type

waterui::locale::Locale wraps an ICU4X icu_locid::Locale. It preserves Unicode extension data (calendar, hour cycle, number system), making it suitable for complete locale preferences rather than just language tags.

use waterui::locale::Locale;
use core::str::FromStr;

let locale = Locale::from_str("en-US").unwrap();
let tag = locale.canonical_tag(); // "en-US"
let lang = locale.language.as_str(); // "en"

Built-in Locale Constants

The locales module provides pre-defined constants so you do not have to parse strings at runtime:

use waterui::locale::locales;

let _ = locales::EN;        // English
let _ = locales::EN_US;     // English (United States)
let _ = locales::EN_GB;     // English (United Kingdom)
let _ = locales::ZH_CN;     // Chinese Simplified (China)
let _ = locales::ZH_TW;     // Chinese Traditional (Taiwan)
let _ = locales::ZH_HK;     // Chinese Traditional (Hong Kong)
let _ = locales::ZH_HANS;   // Chinese Simplified (script)
let _ = locales::ZH_HANT;   // Chinese Traditional (script)
let _ = locales::JA;        // Japanese
let _ = locales::KO;        // Korean
let _ = locales::FR;        // French
let _ = locales::DE;        // German
let _ = locales::ES;        // Spanish
let _ = locales::RU;        // Russian
let _ = locales::AR;        // Arabic
let _ = locales::HI;        // Hindi
let _ = locales::PT;        // Portuguese
let _ = locales::PT_BR;     // Portuguese (Brazil)
let _ = locales::PT_PT;     // Portuguese (Portugal)
let _ = locales::SR_LATN;   // Serbian (Latin)
let _ = locales::SR_CYRL;   // Serbian (Cyrillic)

Locale Fallback Chain

When a translation is not available in the user’s exact locale, WaterUI needs to know where to look next. ICU4X’s LocaleFallbacker provides script-aware fallback that handles tricky cases correctly:

use waterui::locale::locale::{get_fallback_chain, Locale};
use core::str::FromStr;

let locale = Locale::from_str("zh-TW").unwrap();
let chain = get_fallback_chain(&locale);
// zh-TW -> zh-Hant -> zh (NOT zh-Hans!)

Note: The fallback chain correctly distinguishes Traditional from Simplified Chinese, Latin from Cyrillic Serbian, and other script-variant pairs. This is critical for delivering the right translations.

Translation Files

Translations are stored in TOML files, parsed by waterui::locale::parser::TranslationFile. Let’s start with the simplest case and build up to complex plural forms.

Simple Translations

"Hello, World!" = "Hello, World!"
"Goodbye" = "Farewell"

The key is the source string (typically the English text used in code). The value is the translated string.

Plural Translations

English has two plural forms (“1 apple” vs “2 apples”), but many languages have more. The {#variable} syntax in the key marks a plural source:

"I have {#count} apple" = {
    one = "I have {count} apple",
    other = "I have {count} apples"
}

Note the difference: the key uses {#count} (with #) to identify the plural source. The values use {count} (without #) for simple interpolation.

Available Plural Forms

The plural form keys follow CLDR categories:

KeyUsed By
zeroArabic, Welsh, …
oneEnglish, German, Spanish, French, …
twoArabic, Welsh, …
fewRussian, Polish, Czech, …
manyRussian, Polish, Arabic, …
otherRequired – fallback for all languages

Not all languages use all forms. Chinese and Japanese only use other. English uses one and other. Russian uses one, few, many, and other.

Dual Plural Translations

When a sentence contains two independently-pluralized quantities, use the DualPluralForms format:

"I have {#apples} apple and {#oranges} orange" = {
    one_one = "I have {apples} apple and {oranges} orange",
    one_other = "I have {apples} apple and {oranges} oranges",
    other_one = "I have {apples} apples and {oranges} orange",
    other_other = "I have {apples} apples and {oranges} oranges"
}

Parsing Translation Files

use waterui::locale::parser::{TranslationFile, TranslationValue};
use waterui::locale::PluralCategory;

let content = include_str!("../locales/en.toml");
let file = TranslationFile::parse(content).unwrap();

match file.get("Goodbye") {
    Some(TranslationValue::Simple(s)) => {
        assert_eq!(s, "Farewell");
    }
    _ => unreachable!(),
}

match file.get("I have {#count} apple") {
    Some(TranslationValue::Plural(forms)) => {
        assert_eq!(forms.get(PluralCategory::One), "I have {count} apple");
        assert_eq!(forms.get(PluralCategory::Other), "I have {count} apples");
    }
    _ => unreachable!(),
}

Plural Rules

Pluralization is one of the trickiest parts of i18n. waterui::locale::select_plural determines the correct plural category for a number in a given locale:

use waterui::locale::{select_plural, locales, PluralCategory};

// English
assert_eq!(select_plural(&locales::EN, &1), PluralCategory::One);
assert_eq!(select_plural(&locales::EN, &2), PluralCategory::Other);
assert_eq!(select_plural(&locales::EN, &0), PluralCategory::Other);

// Chinese (no plural distinction)
assert_eq!(select_plural(&locales::ZH_CN, &1), PluralCategory::Other);
assert_eq!(select_plural(&locales::ZH_CN, &100), PluralCategory::Other);

// Russian (complex rules)
assert_eq!(select_plural(&locales::RU, &1), PluralCategory::One);
assert_eq!(select_plural(&locales::RU, &2), PluralCategory::Few);
assert_eq!(select_plural(&locales::RU, &5), PluralCategory::Many);
assert_eq!(select_plural(&locales::RU, &21), PluralCategory::One);

// French (0 and 1 are both "one")
assert_eq!(select_plural(&locales::FR, &0), PluralCategory::One);
assert_eq!(select_plural(&locales::FR, &1), PluralCategory::One);
assert_eq!(select_plural(&locales::FR, &2), PluralCategory::Other);

Plural rules use absolute values (negative numbers are treated as their positive counterpart) and handle fractional values correctly.

Validation

valid_categories returns the set of plural categories that are meaningful for a locale. Use this to validate translation files – you can warn if a translator provides a few form for English (which never uses it):

use waterui::locale::plural::valid_categories;
use waterui::locale::locales;

let en_cats = valid_categories(&locales::EN);
// [One, Other]

let zh_cats = valid_categories(&locales::ZH_CN);
// [Other]

let ru_cats = valid_categories(&locales::RU);
// [One, Few, Many, Other]

The text! Macro

With translation files and plural rules in place, you need a way to use them in your views. The text! macro creates a LocalizedText view that automatically reacts to locale changes:

use waterui::prelude::*;

fn greeting(name: &str) -> impl View {
    text!("Hello, {name}!").bold().size(24.0)
}

When the runtime locale changes (for example, the user switches their device language), all text! views re-render with the new locale’s translations.

Styling Methods on LocalizedText

LocalizedText supports the same fluent styling methods as regular text:

MethodPurpose
.size(f32)Set the font size
.bold()Make the text bold
.italic()Make the text italic
.font(font)Set a custom font
.title()Use the title font style
.headline()Use the headline font style
.sub_headline()Use the subheadline font style
.body()Use the body font style
.caption()Use the caption font style
.footnote()Use the footnote font style

Locale-Aware Formatting

Translating strings is only part of the story. Numbers, dates, units, and lists all have locale-specific conventions.

LocalizedDisplay Trait

Types that implement LocalizedDisplay can format themselves differently depending on the locale:

use waterui::locale::{LocalizedDisplay, locales};

let value = 42;
let formatted = value.to_localized_string(&locales::EN);

A blanket implementation covers all Display types, though they will not produce locale-specific output. Custom types can override for locale-aware formatting.

Unit Formatting

The waterui::locale::format::unit module provides type-safe physical units with locale-aware display. The same distance reads differently in different languages:

use waterui::locale::format::unit::{Length, Meter, Kilometer, Mile};
use waterui::locale::locales;

let distance = Length::<Meter>::new(100.0);

distance.to_localized_string(&locales::EN);    // "100 m"
distance.to_localized_string(&locales::ZH_CN); // "100米"
distance.to_localized_string(&locales::JA);    // "100メートル"
distance.to_localized_string(&locales::KO);    // "100미터"

Units support conversion:

use waterui::locale::format::unit::{Length, Kilometer, Meter, Mile};

let km = Length::<Kilometer>::new(1.0);
let meters = km.to::<Meter>();     // 1000.0 m
let miles = km.to::<Mile>();       // ~0.621 mi

And arithmetic:

use waterui::locale::format::unit::{Length, Kilometer, Meter};

let km = Length::<Kilometer>::new(1.0);
let m = Length::<Meter>::new(500.0);
let total = km + m; // 1.5 km

Available unit types include:

  • Length: Meter, Kilometer, Mile, Feet
  • Mass: Kilogram, Gram

Date Formatting

use waterui::locale::format::date::{SimpleDate, DateStyle, format_date};
use waterui::locale::locales;

let date = SimpleDate::new(2026, 2, 15);

format_date(&locales::EN, &date, DateStyle::Short);   // "2/15/26"
format_date(&locales::EN, &date, DateStyle::Long);     // "February 15, 2026"
format_date(&locales::JA, &date, DateStyle::Long);     // "2026年2月15日"
format_date(&locales::ZH_CN, &date, DateStyle::Long);  // "2026年2月15日"
format_date(&locales::DE, &date, DateStyle::Short);     // "15.02.26"

List Formatting

LocalizedList formats arrays according to locale conventions – commas, conjunctions, and separators all vary:

use waterui::locale::format::LocalizedList;
use waterui::locale::{LocalizedDisplay, locales};

let items = LocalizedList(&["Apple", "Banana", "Orange"]);

items.to_localized_string(&locales::EN);    // "Apple, Banana, and Orange"
items.to_localized_string(&locales::ZH_CN); // "Apple、Banana和Orange"

Dynamic Locale Switching

The locale system integrates with waterkit-regional for runtime locale changes. A shared Binding<Locale> is maintained per thread. When the system locale changes (or the user explicitly selects a language), the binding updates, and all LocalizedText views re-render.

Setting the Locale in the Environment

To override the locale for a subtree of views, insert a Locale into the environment:

use waterui::prelude::*;
use waterui::locale::locales;

fn japanese_section() -> impl View {
    vstack((
        text!("Hello"),
        text!("Goodbye"),
    )).with(locales::JA)
}

All text! views within this subtree will resolve against ja instead of the system locale.

Locale as an Extractor

Locale implements Extractor, so you can use it with use_env to build locale-aware components:

use waterui::prelude::*;
use waterui::locale::Locale;

fn locale_aware_view() -> impl View {
    use_env(|locale: Locale| {
        text(format!("Current locale: {}", locale.canonical_tag()))
    })
}

Complete Example

Here is a minimal localized application that demonstrates plural-aware text with reactive state:

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

pub fn main() -> impl View {
    let count = Binding::i32(0);

    vstack((
        text!("I have {#count} apple", count = count.clone())
            .headline(),
        hstack((
            button("Add")
                .action(|State(c): State<Binding<i32>>| c.set(c.get() + 1))
                .state(&count),
            button("Remove")
                .action(|State(c): State<Binding<i32>>| c.set((c.get() - 1).max(0)))
                .state(&count),
        )),
    ))
}

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

With the matching translation files:

locales/en.toml:

"I have {#count} apple" = { one = "I have {count} apple", other = "I have {count} apples" }

locales/zh-CN.toml:

"I have {#count} apple" = "我有{count}个苹果"

locales/ja.toml:

"I have {#count} apple" = "{count}個のりんごがあります"

Chinese and Japanese do not need plural forms because they only use Other.

Try it yourself: Add a French translation file. Remember that in French, both 0 and 1 use the one form, so you will need one and other entries.

Summary

APIPurpose
LocaleICU4X-backed locale identifier
locales::EN, locales::ZH_CN, …Pre-defined locale constants
get_fallback_chain(locale)Script-aware locale fallback
TranslationFile::parse(toml)Parse a TOML translation file
select_plural(locale, n)CLDR-compliant plural category
valid_categories(locale)Valid plural forms for a locale
text!("...")Localizable text view macro
LocalizedTextLocale-reactive text view
LocalizedDisplayLocale-aware formatting trait
LocalizedListLocale-aware list formatting
Length, MassType-safe units with conversion
format_date(locale, date, style)Locale-aware date formatting
.with(locale)Override locale for a view subtree

What’s Next

Your app speaks multiple languages. But as it grows in complexity, you will want to organize cross-cutting concerns – theming, analytics, error views – into reusable units. In the next chapter, you will learn how WaterUI’s plugin system lets you extend the framework without modifying core code.

Plugins

In this chapter, you will:

  • Understand the Plugin trait and how it integrates with Environment
  • Install plugins globally or scoped to a view subtree
  • Build plugins for theming, analytics, and default error/loading views
  • Compose multiple plugins into setup functions
  • Use keyed storage with Store<K, V> for plugins that hold many values of one type

As your application grows, you accumulate cross-cutting concerns: theming, analytics, default error views, loading indicators. Scattering env.insert(...) calls through view code gets messy fast. WaterUI’s plugin system gives you a clean pattern: a plugin is a self-contained unit that installs itself into an Environment, injecting services, configuration, or view hooks that every view in the hierarchy can read.

The Plugin trait

The Plugin trait lives in waterui_core::plugin and is intentionally minimal:

pub trait Plugin: Sized + 'static {
    fn install(self, env: &mut Environment) {
        env.insert(self);
    }

    fn uninstall(self, env: &mut Environment) {
        env.remove::<Self>();
    }
}

Both methods have default bodies:

  • install stores the plugin instance keyed by its concrete type.
  • uninstall removes that instance.

You override install when the plugin needs to do more than just store itself, for example, register a service, install a hook, or extract data into multiple environment slots.

A minimal plugin

The simplest plugin just stores itself in the environment:

use waterui_core::{plugin::Plugin, Environment};

struct MyPlugin;
impl Plugin for MyPlugin {}

let mut env = Environment::new();
env.install(MyPlugin);

// Later, any view can check if the plugin is active.
assert!(env.get::<MyPlugin>().is_some());

This is useful as a feature flag or a marker that some behavior is enabled. Most plugins do more interesting work during installation.

Installing plugins

There are two ways to install a plugin: globally on the application environment, or locally on a view subtree.

During environment setup

Install plugins when building the application’s environment. Environment::install calls plugin.install(&mut self) and returns &mut Self for chaining:

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

pub fn app(env: Environment) -> App {
    let mut env = env;
    env.install(ThemePlugin::dark())
        .install(AnalyticsPlugin::new("api-key"));
    App::new(main, env)
}

fn main() -> impl View {
    text("Hello")
}

Per-view with ViewExt

ViewExt::install installs a plugin for a specific subtree by cloning the environment, applying the plugin, and wrapping the view with the modified environment:

use waterui::prelude::*;

fn themed_section() -> impl View {
    vstack((
        text("Dark mode section"),
        text("All children see DarkTheme"),
    ))
    .install(DarkThemePlugin)
}

Building a custom plugin

A useful plugin typically:

  1. Carries configuration.
  2. Inserts services or values into the environment during install.
  3. Optionally cleans up during uninstall.

The next sections walk through real-world examples.

Theming plugin

This plugin installs a color palette that any view in the hierarchy can read. Note that Use<T> is the extractor wrapper: any T: 'static + Clone becomes extractable through Use<T> without you implementing Extractor yourself.

use waterui::prelude::*;
use waterui::graphics::color::Color;
use waterui_core::{
    Environment,
    extract::Use,
    plugin::Plugin,
};

#[derive(Debug, Clone)]
pub struct ThemeConfig {
    pub primary: Color,
    pub secondary: Color,
    pub background: Color,
}

pub struct ThemePlugin {
    config: ThemeConfig,
}

impl ThemePlugin {
    pub fn dark() -> Self {
        Self {
            config: ThemeConfig {
                primary: Color::srgb(100, 149, 237),
                secondary: Color::srgb(144, 238, 144),
                background: Color::srgb(30, 30, 30),
            },
        }
    }

    pub fn light() -> Self {
        Self {
            config: ThemeConfig {
                primary: Color::srgb(0, 122, 255),
                secondary: Color::srgb(52, 199, 89),
                background: Color::srgb(255, 255, 255),
            },
        }
    }
}

impl Plugin for ThemePlugin {
    fn install(self, env: &mut Environment) {
        env.insert(self.config);
    }
}

fn themed_card() -> impl View {
    use_env(|Use(config): Use<ThemeConfig>| {
        vstack((
            text("Themed Card").foreground(config.primary),
            text("Secondary text").foreground(config.secondary),
        ))
        .background(config.background)
    })
}

Analytics plugin

A plugin that installs a service for event tracking. The service itself is Clone, so views can extract it through Use<AnalyticsService> and move it into action closures:

use waterui::prelude::*;
use waterui_core::{
    Environment,
    extract::Use,
    plugin::Plugin,
};

#[derive(Clone)]
pub struct AnalyticsService {
    api_key: String,
}

impl AnalyticsService {
    pub fn track(&self, event: &str) {
        tracing::info!(api_key = %self.api_key, event, "analytics event");
    }
}

pub struct AnalyticsPlugin {
    api_key: String,
}

impl AnalyticsPlugin {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self { api_key: api_key.into() }
    }
}

impl Plugin for AnalyticsPlugin {
    fn install(self, env: &mut Environment) {
        env.insert(AnalyticsService { api_key: self.api_key });
    }
}

fn tracked_button() -> impl View {
    use_env(|Use(analytics): Use<AnalyticsService>| {
        button("Purchase").action(move || {
            analytics.track("purchase_clicked");
        })
    })
}

Default error view plugin

Install a DefaultErrorView that any error in the subtree falls back to. See Error handling for context. The text! macro reads named placeholders from the surrounding scope, so bind message first:

use waterui::prelude::*;
use waterui::widget::error::{BoxedStdError, DefaultErrorView};
use waterui_core::{Environment, plugin::Plugin};

pub struct ErrorViewPlugin;

impl Plugin for ErrorViewPlugin {
    fn install(self, env: &mut Environment) {
        env.insert(DefaultErrorView::new(|error: BoxedStdError| {
            let message = Binding::container(error.to_string());
            vstack((
                text("Something went wrong").headline(),
                text!("{message}"),
            ))
            .padding()
        }));
    }
}

Default loading view plugin

The same shape works for Suspense fallbacks. loading() returns an indeterminate circular Progress indicator:

use waterui::prelude::*;
use waterui::widget::suspense::DefaultLoadingView;
use waterui_core::{Environment, plugin::Plugin};

pub struct LoadingViewPlugin;

impl Plugin for LoadingViewPlugin {
    fn install(self, env: &mut Environment) {
        env.insert(DefaultLoadingView::new(|| {
            vstack((
                loading(),
                text("Loading..."),
            ))
        }));
    }
}

Plugin lifecycle

Plugins are installed once during environment setup. The lifecycle is:

  1. Installationinstall(self, env) runs and injects values.
  2. Active — installed services are visible to every view that reads the environment.
  3. Uninstallationuninstall(self, env) removes the plugin entry. This is rarely needed at runtime, but is the way to undo a per-subtree install.

Note: Environment is a type-indexed map, so installing the same plugin type twice replaces the first instance. Treat that as the intended way to override defaults, not a bug.

Querying plugin state

The Environment provides two ways to look up values that plugins installed.

Direct type lookup

let plugin = env.get::<ThemePlugin>();
let config = env.get::<ThemeConfig>();

Keyed store lookup

When a plugin needs to install several values of the same type under different logical keys, use Environment::store and Environment::query. The K type acts as a phantom key; the V is the actual stored value:

use waterui::Environment;

struct ApiBaseUrl;
struct CdnBaseUrl;

let env = Environment::new()
    .store::<ApiBaseUrl, _>("https://api.github.com".to_string())
    .store::<CdnBaseUrl, _>("https://static.rust-lang.org".to_string());

let api_url = env.query::<ApiBaseUrl, String>();
let cdn_url = env.query::<CdnBaseUrl, String>();

This is the same mechanism the theme system uses to keep many color and font slots distinct under one container.

Composing plugins

Larger applications benefit from composing many plugins into a single setup function. This keeps app() clean and makes it easy to swap configurations:

use waterui::Environment;

fn setup_production(env: &mut Environment, analytics: AnalyticsPlugin) {
    env.install(ThemePlugin::light())
        .install(analytics)
        .install(ErrorViewPlugin)
        .install(LoadingViewPlugin);
}

fn setup_development(env: &mut Environment) {
    env.install(ThemePlugin::dark())
        .install(ErrorViewPlugin)
        .install(LoadingViewPlugin);
}

Plugins and view hooks

A plugin’s install body is the natural place to register a view hook — a function that intercepts a ViewConfiguration for a given component and substitutes a new view. The full mechanics live in the Resolvers and hooks chapter; the shape inside a plugin looks like this:

use waterui::prelude::*;
use waterui::component::button::ButtonConfig;
use waterui_core::{Environment, plugin::Plugin};

pub struct LoggingButtonsPlugin;

impl Plugin for LoggingButtonsPlugin {
    fn install(self, env: &mut Environment) {
        env.insert_hook(|env, config: ButtonConfig| {
            tracing::debug!(?config, "button rendered");
            config.render()
        });
    }
}

Environment::insert_hook accepts any Fn(&Environment, C) -> impl View where C: ViewConfiguration. It boxes the closure into a Hook<C> and stores it under that configuration’s type.

Best practices

  1. Keep plugins focused. Each plugin should install one logical unit. Prefer many small plugins over one large one.
  2. Document what gets installed. Callers need to know which types appear in the environment after installation.
  3. Prefer Use<T> extractors so views read services through use_env(|Use(svc): Use<T>| ...) instead of grabbing the environment directly.
  4. Avoid side effects in install. Plugins should configure the environment, not perform I/O or spawn tasks. Defer runtime behavior to the services they install.
  5. Use env.install(plugin) instead of env.insert(plugin). The plugin pattern documents intent and lets the plugin run custom installation logic.

Summary

APIPurpose
Plugin traitCore interface for environment extensions
Plugin::install(self, env)Add functionality to the environment
Plugin::uninstall(self, env)Remove functionality
Environment::install(plugin)Install a plugin (chainable)
ViewExt::install(plugin)Install a plugin for a view subtree
Environment::insert(value)Store a typed value
Environment::get::<T>()Retrieve a typed value
Environment::store::<K, V>(value)Store under a phantom key
Environment::query::<K, V>()Retrieve under a phantom key
Environment::insert_hook(f)Install a hook over ViewConfiguration
Environment::remove::<T>()Remove a typed value

Next

Plugins install services and configuration. But how do colors, fonts, and other design tokens become reactive values that update when the OS toggles dark mode? Move on to Resolvers and hooks to see the machinery underneath the theme system and .foreground() modifier.

Resolvers and hooks

In this chapter, you will:

  • Understand how the Resolvable trait turns design tokens into reactive signals
  • Implement your own resolvable types for custom theming
  • Use AnyResolvable<T> for type-erased resolution
  • Transform resolved values with the Map combinator
  • Intercept view rendering with Hook<C> for cross-cutting concerns
  • Bridge reactive signals to views with Dynamic::watch and watch

You have built views, handled errors, localized text, and organized code with plugins. There is a deeper pattern underneath all of it: resolvers. When you write .foreground(Accent), how does WaterUI know what color “Accent” is? The answer is that Accent is a token — a lightweight value that knows how to look itself up in the Environment at runtime. The lookup returns a reactive signal, so when the OS toggles dark mode, every view that read that token updates without rebuilding the tree.

This chapter covers the Resolvable trait, the AnyResolvable<T> type-erased wrapper, the Map combinator, the Hook<C> system for intercepting view configurations, and Dynamic::watch for bridging signals to views.

WaterUI custom color token preview with accent success highlight and foreground swatches

A Hydrolysis preview of custom color tokens resolved through the environment. Example source.

The Resolvable trait

The core abstraction is waterui_core::resolve::Resolvable:

pub trait Resolvable: Debug + Clone {
    type Resolved;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved>;
}

A Resolvable does not hold its final value. It holds enough information to find the value in an Environment and return a reactive signal.

The flow

End-to-end, from the native platform all the way to your view:

Native Backend         Environment              View
(iOS/Android)
     |                     |                      |
     | 1. Create reactive  |                      |
     |    Computed signal  |                      |
     |-------------------->|                      |
     |                     |                      |
     | 2. Install into env |                      |
     |    via Theme        |                      |
     |-------------------->|                      |
     |                     | 3. View resolves     |
     |                     |    Accent.resolve(env)
     |                     |<---------------------|
     |                     |                      |
     |                     | 4. Returns signal    |
     |                     |--------------------->|
     |                     |                      |
     | 5. System event     |                      |
     |    (dark mode)      |                      |
     |-------------------->| 6. Signal updates    |
     |                     |--------------------->|
     |                     |    View re-renders   |

Why signals?

The key point is that resolve() returns an impl Signal, not a plain value. This means:

  1. Native backends inject reactive signals. The iOS or Android runtime pushes a Computed<ResolvedColor> that updates when the user toggles dark mode.
  2. Views automatically re-render. When the signal updates, every view that read the resolved value updates without any manual code.
  3. No rebuild required. Theme changes propagate instantly through the existing view tree.

Implementing Resolvable

A token is typically a zero-sized type that knows where to look in the environment. The lookup uses Environment::query::<K, V>() so the token type itself can act as the phantom key:

use waterui_core::{Computed, Environment, Signal, resolve::Resolvable};

#[derive(Debug, Clone, Copy)]
pub struct BrandColor;

impl Resolvable for BrandColor {
    type Resolved = ResolvedColor;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
        env.query::<Self, Computed<ResolvedColor>>()
            .cloned()
            .unwrap_or_else(|| Computed::constant(ResolvedColor::default()))
    }
}

env.query::<Self, Computed<T>>() reads the Store<Self, Computed<T>> slot. The theme system installs these signals during environment setup (see waterui::theme::install_color_signal).

How themes use resolvers

The theme pipeline works like this:

  1. The native backend creates Computed<ResolvedColor> signals from the system palette. These signals are reactive — they fire whenever the OS switches between light and dark mode.
  2. Theme::install (a Plugin) stores those signals in the environment, keyed by token type (for example color::Foreground, color::Accent).
  3. Token types implement Resolvable to query the environment for their signal.
  4. When you write text("Hello").foreground(Accent), the Accent token is resolved into a signal that the renderer subscribes to.

Note: This is why theme changes feel instant — there is no rebuild step. The existing view tree simply reacts to the signal update.

AnyResolvable<T>

Several resolvable types can produce the same output type. A Color, for example, might come from a hex literal, a theme token, or a derived expression. AnyResolvable<T> provides type erasure so all of them coexist behind one interface:

use waterui_core::resolve::AnyResolvable;
use waterui::theme;

let from_hex = AnyResolvable::new(Color::srgb(255, 0, 0));
let from_token = AnyResolvable::new(theme::color::Accent);

AnyResolvable<T> itself implements Resolvable<Resolved = T>, so it can be used wherever a Resolvable is expected. Internally it stores a Box<dyn ResolvableImpl<T>> for dynamic dispatch.

Constructing and resolving

pub fn new(value: impl Resolvable<Resolved = T> + 'static) -> Self;
pub fn resolve(&self, env: &Environment) -> Computed<T>;

AnyResolvable::resolve returns a concrete Computed<T> (not impl Signal), which is the type you store, clone, and feed into other reactive APIs.

The Map combinator

When you want a variation of an existing token — a lighter accent, a scaled font size — Map<R, F> transforms a resolvable’s output without losing reactivity. WaterUI’s own Color::lighten is implemented on top of Map:

use waterui_core::resolve::Map;
use waterui::theme::color::Accent;

let lighter_accent = Map::new(Accent, |color| color.lighten(0.2));

The closure runs lazily on each emission, so when the underlying Accent signal updates, the derived signal emits a new lightened value automatically.

Map itself implements Resolvable:

impl<R, F, T, U> Resolvable for Map<R, F>
where
    R: Resolvable<Resolved = T>,
    F: Fn(T) -> U + Clone + 'static,
    T: 'static,
    U: 'static,
{
    type Resolved = U;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = U> {
        let func = self.func.clone();
        self.resolvable.resolve(env).map(func)
    }
}

This composes with the standard signal .map() operator, so the derived signal re-evaluates whenever the source changes.

Hooks: intercepting view configuration

Resolvers handle values — colors, fonts, strings. Hooks handle views. Some views implement ConfigurableView, which separates the view into a configuration and a renderer:

pub trait ConfigurableView: View {
    type Config: ViewConfiguration;
    fn config(self) -> Self::Config;
}

pub trait ViewConfiguration: 'static {
    type View: View;
    fn render(self) -> Self::View;
}

A ConfigurableView can be intercepted by a Hook<Config> stored in the environment. When the view body runs, it checks the environment for a matching hook. If one exists, the hook receives the configuration, the environment (with the hook removed to prevent recursion), and returns a modified view. Otherwise, the configuration renders normally through config.render().

The Hook type

pub struct Hook<C>(/* boxed Fn(&Environment, C) -> AnyView */);

A Hook<C> is a function from (&Environment, C) to AnyView, where C is the view’s ViewConfiguration type.

Installing a hook

Use Environment::insert_hook:

use waterui::prelude::*;
use waterui::component::button::ButtonConfig;
use waterui::Environment;

let mut env = Environment::new();
env.insert_hook(|env, config: ButtonConfig| {
    tracing::debug!(?config, "button rendered");
    config.render()
});

insert_hook wraps the closure in a Hook<C> and stores it under that configuration’s type. Before calling your closure, the framework removes the hook from the cloned environment passed in, which is how recursion is prevented when your closure ends with config.render().

How hooks execute

For a ConfigurableView body:

  1. It produces its Config.
  2. It checks the environment for Hook<Config>.
  3. If a hook is present, the hook receives the config plus the environment (with this hook removed) and returns a view.
  4. If no hook is present, the config renders normally via config.render().

This mechanism enables powerful cross-cutting concerns:

  • Theming — wrap every button with a consistent style.
  • A/B testing — modify certain configurations based on experiment flags.
  • Logging — record every view configuration for debugging.

Hooks in plugins

A plugin’s install body is the natural place to register a hook. Here is a plugin that switches every button in its subtree to the bordered style:

use waterui::prelude::*;
use waterui::component::button::{ButtonConfig, ButtonStyle};
use waterui_core::{Environment, plugin::Plugin};

pub struct BorderedButtonsPlugin;

impl Plugin for BorderedButtonsPlugin {
    fn install(self, env: &mut Environment) {
        env.insert_hook(|env, mut config: ButtonConfig| {
            config.style = ButtonStyle::Bordered;
            config.render()
        });
    }
}

Try it yourself: Build a LoggingPlugin that installs hooks for several view configurations and logs each one with tracing::debug!.

Dynamic views

The final piece is how a resolved signal becomes visible on screen. Most of the time you do not write this glue yourself — a Computed<V> whose value type is itself a View automatically renders through Dynamic::watch. For the cases where you do need it, two entry points exist.

Dynamic::watch

Dynamic::watch bridges any Signal to the view system. It calls your closure on each emission and swaps the rendered subtree:

use waterui::prelude::*;
use waterui_core::dynamic::Dynamic;

let theme_name = Binding::container(String::from("Default"));

let view = Dynamic::watch(theme_name, |name: String| {
    let name = Binding::container(name);
    text!("Current theme: {name}")
});

Internally, Dynamic::watch:

  1. Allocates a Dynamic view and its DynamicHandler.
  2. Renders the initial value with handler.set(f(value.get())).
  3. Subscribes to the signal with value.watch(...).
  4. On each update, calls handler.set(f(new_value)) to replace the subtree.
  5. Retains the watcher guard and the source signal through Metadata<Retain> so they live as long as the view does.

The Dynamic type

Dynamic is the low-level updatable view. You should prefer reactive bindings, text!, and Computed<V> over raw Dynamic usage. When you do need to swap an entire subtree on demand, Dynamic::new returns the handler and the view together:

use waterui::prelude::*;
use waterui_core::dynamic::Dynamic;

let (handler, view) = Dynamic::new();

handler.set(text("Initial content"));

// Later, replace the content:
handler.set(text("Updated content"));

The handler is Clone and can be moved into closures or async tasks.

Computed<V> as View

Any Computed<V> where V: View automatically implements View. The implementation is just Dynamic::watch(self, |view| view):

use waterui::prelude::*;
use waterui::AnyView;

let show_detail = Binding::bool(false);

let view = show_detail.map(|show| {
    if show {
        AnyView::new(text("Detail view"))
    } else {
        AnyView::new(text("Summary view"))
    }
});

// `view` is a Computed<AnyView> and renders as a View directly.

The watch function

watch is a thin wrapper around Dynamic::watch:

use waterui::prelude::*;
use waterui_core::dynamic::watch;

let count = Binding::i32(0);

let view = watch(count, |n: i32| {
    let n = Binding::container(n);
    text!("Count: {n}")
});

Putting it all together

A complete example combining a resolver, a plugin that installs it, and a view that consumes it:

use waterui::prelude::*;
use waterui::app::App;
use waterui_core::{
    Computed,
    Environment,
    Signal,
    env::Store,
    plugin::Plugin,
    resolve::Resolvable,
};

// 1. Define a resolvable token.
#[derive(Debug, Clone, Copy)]
pub struct AppTitle;

impl Resolvable for AppTitle {
    type Resolved = String;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = String> {
        env.query::<Self, Computed<String>>()
            .cloned()
            .unwrap_or_else(|| Computed::constant("My App".to_string()))
    }
}

// 2. Plugin that installs a signal under the AppTitle key.
pub struct AppTitlePlugin {
    title: String,
}

impl Plugin for AppTitlePlugin {
    fn install(self, env: &mut Environment) {
        env.insert(Store::<AppTitle, Computed<String>>::new(
            Computed::constant(self.title),
        ));
    }
}

// 3. Use the resolver in a view.
fn title_bar() -> impl View {
    use_env(|env: Environment| {
        let signal = AppTitle.resolve(&env);
        Dynamic::watch(signal, |title: String| {
            let title = Binding::container(title);
            text!("{title}").headline()
        })
    })
}

// 4. Wire it up.
pub fn app(env: Environment) -> App {
    let mut env = env;
    env.install(AppTitlePlugin {
        title: "WaterUI Tutorial".to_string(),
    });
    App::new(title_bar, env)
}

Summary

APIPurpose
Resolvable traitLook up a value from the environment as a signal
Resolvable::resolve(env)Returns impl Signal<Output = Resolved>
AnyResolvable<T>Type-erased resolvable wrapper
AnyResolvable::new(r)Wrap any resolvable
AnyResolvable::resolve(env)Returns Computed<T>
Map::new(r, f)Transform a resolvable’s output
ViewConfiguration traitView config that hooks can intercept
Hook<C>Intercepts a view configuration
Environment::insert_hook(f)Install a hook in the environment
Dynamic::new()Low-level updatable view
Dynamic::watch(signal, f)Bridge a signal to a subtree
watch(signal, f)Convenience wrapper for Dynamic::watch
Computed<V>: ViewReactive view from computed signals

Next

You have reached the end of the advanced topics. From here you can revisit any chapter to deepen your understanding, or build your own resolvable tokens and plugins to bend WaterUI to your application’s shape.

Preview system

In this chapter, you will:

  • Mark a view function with #[preview] and render it to a PNG with water preview
  • Set up the dev feature flag and Water.toml keys that preview requires
  • Read the preview command’s arguments, defaults, and supported platforms
  • Understand the build, handshake, and render path that produces the image

You wrote a card view. You want to see it. Spinning up the simulator, navigating five screens deep, and waiting for a debug build is too much friction for a two-pixel adjustment. The preview system shortcuts that loop: annotate the function, run one command, get a PNG.

Preview is supported on macOS, the iOS Simulator, physical iOS, and Android — the four targets that can load a Rust dylib through the WaterUI dynamic-linking path. There is no Linux, Windows, or Web preview backend.

The #[preview] attribute

Mark any function returning impl View with #[preview]:

use waterui::prelude::*;

#[preview]
fn sidebar() -> impl View {
    vstack((
        text("Sidebar"),
        text("Content"),
    ))
}

The macro keeps your original function untouched and generates a #[unsafe(no_mangle)] extern "C" companion that constructs the view, wraps it in AnyView, and returns the boxed pointer. The preview support app loads that symbol at render time.

Default arguments for parameterized views

If your view function takes parameters, every parameter needs a default value supplied through the macro attribute. Preview has no other way to invent argument values:

#[preview(count = 5, name = "John")]
fn user_card(count: i32, name: &str) -> impl View {
    vstack((
        text!("Name: {name}"),
        text!("Count: {count}"),
    ))
}

Forgetting a default produces a compile error pinned to the parameter:

error: Function parameter `count` needs a default value in #[preview(count = ...)]

Tip: Pick defaults that resemble real data. A user_card previewed with name = "" teaches you nothing about typography or wrapping; name = "John Appleseed" does.

Symbol naming

The macro emits exactly one C symbol per preview function:

waterui_preview_{crate_name}_{function_path}

crate_name is CARGO_PKG_NAME with dashes converted to underscores. function_path is the path you pass on the command line, with :: flattened to _.

Crate nameFunction pathExport symbol
my_appsidebarwaterui_preview_my_app_sidebar
together-appdashboard::admin::cardwaterui_preview_together_app_dashboard_admin_card

There is no fallback or “leaf-only” alternate; the path you give to water preview is the path the symbol resolves against. Misspelling it produces a Preview component not found error that prints both the requested function path and the expected export name.

Project requirements

Preview is a development-mode feature. Two things must be true before water preview will work.

A dev feature on your crate

Your root crate must declare a dev feature that turns on waterui/dynamic_linking:

# Cargo.toml
[features]
dev = ["waterui/dynamic_linking"]

water preview reads your Cargo.toml and refuses to continue if either the feature or the waterui/dynamic_linking enablement is missing — it surfaces the exact line you need to add. The CLI then scaffolds a generated wrapper crate (managed_backends/preview_ffi) that depends on your app crate with features = ["dev"] and emits the dylib that the support app loads.

A clean local WaterUI worktree (dev mode)

If Water.toml points waterui_path at a local checkout, that checkout must be a git worktree with no uncommitted changes to runtime-affecting paths (core/, components/, ffi/, macros/, Cargo.lock, etc.). Preview hashes the clean HEAD commit into a runtime fingerprint that travels in the TCP handshake. A dirty worktree fails fast with:

Preview dev mode requires a clean WaterUI worktree at <path>.
Commit or stash changes before running preview.

For the WaterUI monorepo’s own examples and playgrounds, Water.toml must explicitly set waterui_path = "../.." so the CLI uses the local checkout instead of resolving WaterUI from the registry. Release-mode projects (no waterui_path) skip this rule and resolve WaterUI through registry metadata.

The water preview command

water preview sidebar --platform macos --path ./my-app --output preview.png

Arguments and flags

Argument / flagDescriptionDefault
function_pathFunction path, e.g. dashboard::admin::cardrequired
--platform, -pios, macos, or androidrequired
--backendapple, android, or hydrolysisper-platform
--frame, -fRender size as WIDTHxHEIGHT375x667
--output, -oOutput PNG pathpreview.png
--pathProject directory.

The default backend follows the platform: apple for ios/macos, android for android. The hydrolysis backend is only valid with --platform macos and renders directly through WaterUI’s self-drawn renderer instead of a support app. Any other combination is rejected with a clear error.

Examples

# Preview a top-level function on macOS
water preview my_view --platform macos

# Preview a nested function with a custom frame size on the iOS Simulator
water preview settings::profile_card --platform ios --frame 390x844

# Preview on Android emulator, save to a specific file
water preview home_screen --platform android --output screenshots/home.png

Try it: Add #[preview] to any view function, run water preview <name> --platform macos, and look for preview.png in your project root. Re-run the command — the second invocation reuses the running support app and only rebuilds your dylib.

How preview works internally

    water preview sidebar --platform macos
                  |
                  v
    1. Resolve preview requirements      (waterui_path, runtime fingerprint)
    2. Try to connect to existing support app via TCP (Ping/Pong)
       - If absent, scaffold ~/.water/preview_support and launch it on platform
    3. Verify handshake: support app fingerprint == expected fingerprint && platform matches
    4. Build managed_backends/preview_ffi as a dylib (cargo + Rust dynamic linking)
    5. Compute DylibId from the build signature + dylib path/size/mtime
    6. Send Render { dylib, symbol, frame } over TCP (or DylibId if app already has it)
    7. Support app loads the dylib via libloading, ad-hoc codesigns on macOS if needed
    8. Resolve waterui_preview_<crate>_<path>, call it, render the AnyView
    9. PNG bytes flow back over TCP and are written to --output

Step 1: support app on disk

The CLI manages a generated WaterUI app at ~/.water/preview_support/. It is scaffolded the first time you run a preview and re-scaffolded only when the embedded templates or the WaterUI runtime fingerprint change. Its main returns a single Preview view from the waterui-preview crate that owns the TCP server and rendering loop. You never edit it.

Step 2: TCP handshake

The support app binds a TCP server starting at port 2106 (configurable). The CLI connects, sends Ping, and reads Pong { protocol }. The protocol struct carries the support app’s runtime platform and its WaterUI core fingerprint. Both must match what the CLI computed for the project; otherwise the connection is rejected and the CLI launches a fresh support app for the right runtime.

After the handshake, requests use a binary frame format (4-byte big-endian length prefix + bincode payload). Request types: Ping, HasDylib, Render, Shutdown.

Step 3: dylib build and identity

water preview builds the generated managed_backends/preview_ffi crate. Because that wrapper depends on your crate with features = ["dev"], the resulting dylib contains every #[preview] symbol your code defines.

The CLI assigns each build a DylibId derived from a SHA-256 over (build_signature, dylib path, file length, mtime), where build_signature includes the runtime fingerprint, target triple, and crate name. This id is the cache key the support app uses to recognise an already-loaded library — the CLI sends only the id when the app already has the bytes, and the full payload otherwise.

Step 4: render

The support app loads the dylib through libloading, resolves the export symbol, calls it to get an AnyView, hands it to the platform ViewRenderer at the requested frame size, encodes the result as PNG, and ships the bytes back. On macOS, if the initial dlopen fails the system applies an ad-hoc codesign --force --sign - and retries; you never sign preview dylibs by hand.

macOS codesigning

System Integrity Protection requires loaded dylibs to be signed. The preview support app handles this transparently: it tries dlopen, runs codesign --verify on failure, applies an ad-hoc signature with codesign --force --sign - --timestamp=none if needed, and retries the load. The ad-hoc signature satisfies the OS without any Apple Developer account.

Environment variables

The TCP server, on-disk caches, and timeouts are configurable when defaults do not fit your environment:

VariableDescriptionDefault
WATERUI_PREVIEW_HOSTTCP bind/connect address127.0.0.1
WATERUI_PREVIEW_PORT_STARTFirst port to try2106
WATERUI_PREVIEW_PORT_RANGENumber of consecutive ports to scan50
WATERUI_PREVIEW_DYLIB_CACHE_SIZEMax in-memory dylib cache entries8
WATERUI_PREVIEW_MAX_FRAME_BYTESMax TCP frame size (bytes)128 MiB
WATERUI_PREVIEW_CONNECT_TIMEOUT_MSTCP connect timeout100
WATERUI_PREVIEW_HANDSHAKE_TIMEOUT_MSPing/Pong handshake timeout500
WATERUI_PREVIEW_REQUEST_TIMEOUT_MSGeneral request timeout20000
WATERUI_PREVIEW_RENDER_TIMEOUT_MSRender request timeout120000

Build-cache hygiene

Each project gets its own managed build cache under ~/.water/build_cache/<absolute-project-path>/managed_backends/. Stale entries — caches whose source projects are gone or have not been touched in a while — accumulate over time. The dedicated command to clean them is:

water gc build-cache

water preview and water run may trigger this in a detached subprocess, but they never scan the cache on the hot path. Run it manually if you want to free disk after archiving an old project.

Error recovery

The command retries transient failures and prints actionable errors for the rest:

  • TCP drops mid-render (broken pipe, EOF, timeout) → relaunch the support app and retry once.
  • Symbol not found → print the function path, the expected export symbol, and a #[preview] snippet.
  • Support app crashes on launch → surface the crash via the device event stream rather than waiting for a timeout.

Next: hot reload

The preview command builds, loads, and renders a single moment. Re-running it on a saved file gives you a fast iteration loop because the support app and dylib cache survive between invocations. The next chapter breaks that loop down — what is reused, what is rebuilt, and what state does and does not persist between renders.

Hot reload

In this chapter, you will:

  • See exactly what the preview pipeline reuses across runs and what it rebuilds
  • Read how the project watcher decides whether the dylib is still fresh
  • Understand the dylib identity that drives the support app’s cache
  • Know which platforms support this loop and which kinds of edits force a fresh launch

“Hot reload” in WaterUI today is a re-rendering loop, not a live-attached runtime patch. Each save-and-rerun rebuilds your crate’s preview dylib and asks the long-lived preview support app to render it again. The win is that almost everything around your code — the support app process, its TCP connection, its loaded WaterUI runtime, and any cached dylibs — survives between invocations.

The whole pipeline only exists for the targets that can load a Rust dylib through WaterUI’s dynamic-linking path: macOS, the iOS Simulator, physical iOS, and Android. There is no Linux, Windows, or Web hot-reload story.

What gets reused, what gets rebuilt

Edit src/views/sidebar.rs and save
        |
        v
You re-run:  water preview sidebar --platform macos
        |
        v
1. CLI reconnects to the running support app over TCP and re-validates the handshake
2. ProjectWatcher scans your project; if nothing changed since the last build, the dylib is reused
3. Otherwise, managed_backends/preview_ffi is rebuilt as a dylib (incremental cargo build)
4. CLI computes the new DylibId; if the support app still has it, only the id is sent
5. Support app loads the (possibly new) dylib, resolves the symbol, renders, returns PNG

The first run scaffolds and launches the support app; later runs skip that work entirely. On a warm cache with no source changes, you mostly pay for a TCP round-trip and a fresh render call.

Project watcher

ProjectWatcher decides whether a rebuild is needed by snapshotting your project’s input files and comparing the snapshot against the previous one. It watches:

  • src/ and assets/ (recursive) — all files with build-input extensions
  • Top-level files: Cargo.toml, Cargo.lock, Water.toml, build.rs
  • Build-input extensions include .rs, .toml, .json, .yaml, .swift, .kt, .java, shader files (.metal, .wgsl), and other assets that may be include_*!’d at compile time
  • Ignored: target/, .water/, .git/, .jj/, node_modules/, .gradle/, .idea/, .vscode/

Each scan produces both a “latest mtime” and a structural fingerprint over file paths and sizes, so additions and removals are detected even when their mtimes are older than the current dylib.

let mut watcher = ProjectWatcher::new();

// First check: always returns changed = true (no previous stamp).
let stamp = watcher.stamp(project_path).await?;
assert!(stamp.changed);

// Second check without modifying files: changed = false.
let stamp = watcher.stamp(project_path).await?;
assert!(!stamp.changed);

// Edit a watched file, then check again: changed = true.
let stamp = watcher.stamp(project_path).await?;
assert!(stamp.changed);

The check reads metadata only, so it stays cheap even on large projects.

Dylib identity

Every preview build produces a DylibId that the support app uses as its cache key. The id is a SHA-256 over:

DylibId = SHA-256(
    build_signature  // runtime fingerprint + target triple + crate name + link mode
    || dylib_path
    || file_length
    || mtime_seconds || mtime_subsec_nanos
)

Two consequences fall out of this:

  1. Cache hits across runs. If you re-render without rebuilding, the id is unchanged and the CLI tells the support app HasDylib { id }. The app reports that it already has the bytes loaded, and only the render request flies across the wire.
  2. ABI mismatches invalidate automatically. The build signature embeds the WaterUI runtime fingerprint (the clean git rev-parse HEAD of the local waterui_path worktree). Changing the WaterUI checkout — even just to a different commit — produces a different id, and the dylib will not be confused with the previous one.

The on-disk dylib lives next to its build signature file at <dylib>.waterui-preview-dylib-signature. The support app also keeps an in-memory LRU of loaded libraries (default capacity 8, configurable via WATERUI_PREVIEW_DYLIB_CACHE_SIZE).

The reload cycle, step by step

1. Watcher scan

The CLI calls ProjectWatcher::stamp and gets back the latest mtime plus a changed flag. The mtime is fed into the freshness check on disk; changed only signals that the watcher itself saw a difference.

2. Freshness check

If a dylib already exists on disk, its mtime is at least as new as the project’s latest mtime, and its stored build signature still matches the one the CLI just computed, the build is skipped. Otherwise the CLI runs cargo build on managed_backends/preview_ffi with -Cprefer-dynamic so WaterUI itself is also dynamically linked.

3. Identity and transfer

The CLI hashes the new dylib into a DylibId and queries the support app with HasDylib { id }. If the answer is “no”, the bytes are streamed in the next Render request; on macOS the CLI prefers passing a local file path to the in-process support app instead of copying the bytes.

4. Load and render

The support app stores the bytes (or maps the file) and loads them through libloading. macOS retries with an ad-hoc codesign if the initial dlopen is rejected. The export symbol is resolved, called once to produce a fresh AnyView, and rendered through the platform ViewRenderer. The PNG bytes ship back to the CLI.

Persistent sessions

The support app outlives any single CLI invocation. After a successful render the CLI calls session.detach(), which intentionally mem::forgets the Running handle so dropping it does not kill the app. Subsequent commands reuse the connection.

On failure the CLI calls session.shutdown() instead — it sends a Shutdown request and drops the handle so the next invocation gets a clean process.

The practical timing this produces:

  • First invocation: scaffold + launch + first build (a few seconds).
  • Subsequent invocations on unchanged code: TCP reconnect + cached render (sub-100 ms once the OS is warm).
  • Subsequent invocations after editing one file: incremental cargo rebuild + fresh render, typically a few seconds with sccache warm.

Tip: If consecutive previews feel slow, look for Connected to existing preview app versus No preview app running, launching... in the CLI logs. The second message means something invalidated the running app — usually a waterui_path change or a runtime fingerprint mismatch.

Build caching with sccache

The preview build path threads sccache into RustBuild automatically when it can find the binary. With sccache warm, incremental rebuilds are dominated by linker time. Without sccache the CLI prints a one-time hint:

sccache not found. Build efficiency may be reduced. Install with: brew install sccache

Install it once and forget about it; nothing in the preview pipeline disables sccache, and you should not set WATERUI_DISABLE_SCCACHE=1 because doing so makes the per-project build cache balloon.

State across reloads

Every water preview call asks the support app to re-render a brand-new AnyView. The view tree is rebuilt from the new code — that is the entire point. There is no shared Binding, Computed, or Environment carried over between invocations: the support app constructs each preview from scratch, drives one render, and drops everything it built.

If you want a preview that exercises a specific data shape, set up that data inside the preview function or pass it via #[preview(...)] defaults. Do not expect the support app to remember inputs from the previous run.

Per-function granularity

#[preview] works at the function level. Mark as many functions as you like in the same crate; each gets its own export symbol:

#[preview]
fn sidebar() -> impl View { /* ... */ }

#[preview]
fn header() -> impl View { /* ... */ }

#[preview(count = 3)]
fn notification_list(count: usize) -> impl View { /* ... */ }

Switching between them shares the same dylib and the same support-app session:

water preview sidebar --platform macos
water preview header --platform macos
water preview notification_list --platform macos

When you need a full restart

This loop covers most edits. A few cases still force you to relaunch the support app:

  • WaterUI runtime changed. Bumping the local waterui_path checkout, switching commits, or leaving a dirty WaterUI worktree changes the runtime fingerprint. The handshake will reject the running support app and the CLI launches a fresh one.
  • Water.toml or backend configuration changed. The watcher catches the file change, but switching backends or platforms also invalidates the support app.
  • Support app crashed. When the CLI sees the support app exit, it shuts the session down so the next preview launches a clean process.

Struct layout changes inside your crate are safe by themselves: every preview rebuilds your dylib from source and constructs a fresh view tree, so there is no stale binding state to corrupt. Layout problems only matter if you keep state outside the preview’s view tree, and the preview path explicitly does not.

Architecture summary

+------------------+       TCP (port 2106+)       +------------------------+
|                  | <--------------------------> |                        |
|   water CLI      |   Binary protocol (bincode)  |  Preview support app   |
|                  |                              |                        |
+--------+---------+                              +-----------+------------+
         |                                                    |
   Build managed_backends/preview_ffi             Load dylib via libloading
   as a dylib (cargo + sccache)                   Ad-hoc codesign on macOS
         |                                        Resolve preview symbol
   Watch project inputs (ProjectWatcher)          Render AnyView via native
         |                                        ViewRenderer, encode PNG
   Compute DylibId
   (build signature + path/size/mtime)            LRU dylib cache

The build-and-watch side and the load-and-render side communicate only over TCP. That is what lets the support app survive across CLI runs, and that is the whole basis of the “hot” feel.

Next: how WaterUI renders

You now know what the developer tools accelerate. The Internals section opens the box on the runtime they accelerate: how WaterUI walks the view tree, crosses the FFI boundary, and lays out widgets on each platform.

How WaterUI Renders

In this chapter, you will:

  • Trace a view from Rust struct all the way to pixels on screen
  • Understand the difference between raw views, composite views, and configurable views
  • Learn how 128-bit type IDs enable efficient cross-language dispatch
  • See why WaterUI’s signal-based reactivity avoids tree diffing entirely

You do not need to understand the rendering pipeline to build great apps with WaterUI. But if you have ever wondered what actually happens between writing text("Hello") in Rust and seeing pixels appear on an iPhone screen, this chapter is for you. Understanding these internals will help you debug rendering issues, write more efficient views, and contribute to the framework itself.

WaterUI takes a fundamentally different approach from frameworks that draw their own pixels. Instead of maintaining a virtual DOM or a custom render tree, WaterUI compiles your Rust view declarations into a tree that native backends walk at runtime, mapping each node to a platform widget.

The Rendering Pipeline

The high-level data flow looks like this:

Rust View Tree
     |
     v
FFI Layer (C ABI / JNI)        or        Rust-side backend
     |                                          |
     v                                          v
Native Backend (Swift / Kotlin)        Hydrolysis renderer
     |                                          |
     v                                          v
Platform UI Framework                  Vello + wgpu on GPU
(UIKit / AppKit / Android Views)
     |
     v
Pixels on Screen

Your application code produces a tree of Rust structs that implement the View trait. For the Apple and Android backends, the FFI layer exposes this tree through a stable C ABI (or JNI on Android), and Swift or Kotlin walks the tree to create UIKit/AppKit/Android widgets. For Rust-side backends like Hydrolysis, the dispatcher walks the same tree without crossing a language boundary.

View Categories

Every view in WaterUI falls into one of three categories. Understanding these categories is the key to understanding the render loop.

Raw Views (Leaf Nodes)

A raw view (also called a native view or leaf view) maps directly to a platform widget. Examples include Text, Button, Toggle, Slider, and Color. These views are marked with the raw_view! macro in waterui-core:

// Default stretch axis (None) -- content-sized
raw_view!(Text);

// With explicit stretch axis
raw_view!(Color, StretchAxis::Both);
raw_view!(Spacer, StretchAxis::MainAxis);

The macro implements two traits:

  1. NativeView – marks the type as a leaf that backends should handle directly.
  2. View – implements body() to return Native::new(self), a sentinel wrapper that tells the FFI layer “stop recursing, extract my data.”

When the backend encounters a Native<T> wrapper, it knows to call the corresponding waterui_force_as_* function to extract the view’s data and create a platform widget.

Composite Views

A composite view has a body() method that returns other views. When the backend encounters a composite view, it calls waterui_view_body() to evaluate body() and then continues walking the result. This recursion bottoms out when it reaches a raw view.

pub trait View: 'static {
    fn body(self, env: &Environment) -> impl View;
}

Any closure, struct, or function that implements View is a composite view unless it uses raw_view! or configurable!.

Configurable Views

A third category bridges the gap. The configurable! macro creates views whose configuration can be intercepted by Hooks installed in the Environment:

configurable!(Button, ButtonConfig);
configurable!(Slider, SliderConfig, StretchAxis::Horizontal);

When a configurable view’s body() runs, it checks the environment for a matching Hook<Config>. If found, the hook can alter or replace the view entirely. If no hook is present, the view falls through to Native::new(config) – behaving like a raw view.

Note: The configurable pattern is what makes WaterUI’s theming system so powerful. A library can define a Button, and downstream code can completely replace its rendering – without forking the library.

View Identification

With three categories of views in play, the backend needs a fast way to identify what it is looking at. The FFI layer solves this with 128-bit type IDs.

The function waterui_view_id() returns a WuiTypeId for any AnyView pointer:

#[repr(C)]
pub struct WuiTypeId {
    pub low: u64,
    pub high: u64,
}

The ID is computed from the type’s name using a 128-bit FNV-1a hash. This choice is deliberate: Rust’s std::any::TypeId is not stable across dynamic library boundaries, but type_name() is. Since the preview system loads user code as a dylib, WaterUI needs IDs that remain consistent regardless of how the code was loaded.

The backend maintains a lookup table mapping WuiTypeId values to handler functions. When it receives a view, it compares the ID in O(1) time:

view_id == waterui_text_id()       --> create UILabel / TextView / GtkLabel
view_id == waterui_button_id()     --> create UIButton / MaterialButton / GtkButton
view_id == waterui_metadata_env_id() --> extract new environment, continue
...
otherwise                          --> call waterui_view_body(), recurse

Data Extraction

Once a raw view is identified, the backend extracts its data using type-specific FFI functions. These are generated by the ffi_view! macro:

ffi_view!(TextConfig, WuiText, text);
// Generates:
//   waterui_text_id()       -> WuiTypeId
//   waterui_force_as_text() -> WuiText

The waterui_force_as_* function performs an unchecked downcast – it trusts that the caller already verified the type ID. The returned C struct contains all the data the backend needs to create the widget: text content, font, color signals, action handlers, and so on.

Metadata and Modifiers

Modifiers like .padding(), .opacity(), or .on_appear() do not create new widget types. Instead, they wrap the inner view in a Metadata<T> node:

Metadata<Opacity> {
    content: AnyView,   // the wrapped view
    value: Opacity { value: Computed<f32> }
}

The backend identifies metadata nodes by their own type IDs (generated by ffi_metadata!). When it encounters one, it extracts the metadata value and the inner content view, applies the modifier to the platform widget, and continues rendering the content.

Some metadata types are marked as IgnorableMetadata<T>. If a backend does not recognize the metadata, it can safely skip the modifier and render just the inner content. This allows platform-specific features (like MaterialBackground on Apple) to degrade gracefully on other platforms.

The Render Loop

Putting it all together, here is the algorithm the backend follows for each view node:

  1. Call waterui_view_id(view) to get the 128-bit type ID.
  2. Look up the ID in the handler table.
  3. If a handler is found (raw view or metadata):
    • Call the corresponding waterui_force_as_* to extract data.
    • Create or update the platform widget.
    • For metadata, also render the content child recursively.
  4. If no handler is found (composite view):
    • Call waterui_view_body(view, env) to evaluate body().
    • Go to step 1 with the result.

Rust-side backends formalize this pattern in ViewDispatcher from waterui-backend-core:

// Simplified shape of ViewDispatcher::dispatch.
pub fn dispatch<V: View>(&mut self, view: V, env: &Environment, context: C) -> R {
    if let Some(entry) = self.handlers.get(&TypeId::of::<V>()) {
        // Registered handler: extract the typed view and run it.
        return entry.invoke(&mut self.state, context, view, env);
    }
    // No handler: expand body() and recurse.
    self.dispatch(view.body(env), env, context)
}

Tip: If you are writing a Rust-side backend, ViewDispatcher handles this loop for you. You only need to call register::<MyView>(handler) for each native view type you support.

Reactivity and Updates

The initial render is only half the story. What happens when data changes?

WaterUI does not diff entire view trees. Instead, it relies on fine-grained reactivity exposed through Binding<T> and Computed<T>. When a binding changes, only the computed signals that depend on it fire. Each signal is connected to a specific widget property through a watcher:

Binding<String> --> Computed<Str> --> Watcher --> UILabel.text

The watcher callback runs on the main thread, updating the single widget property that changed. There is no tree reconciliation, no virtual DOM diff, and no full re-render.

For collection views (lists), the Views trait provides an AnyViews abstraction with a watch() method. The backend receives fine-grained change notifications (insertions, deletions, moves) and updates the platform list accordingly.

Stretch Axis Negotiation

Every view declares how it wants to fill available space through StretchAxis:

ValueBehaviorExample
NoneContent-sized, uses intrinsic dimensionsText, Image
HorizontalExpands width, intrinsic heightTextField, Slider
VerticalIntrinsic width, expands height(rare)
BothGreedy, fills all available spaceColor, GpuSurface
MainAxisExpands along the parent stack’s main axisSpacer
CrossAxisExpands along the parent stack’s cross axisDivider

Stack layouts use this information to distribute space. In a VStack, children with StretchAxis::Vertical or StretchAxis::MainAxis share remaining vertical space after content-sized children are measured.

The FFI function waterui_view_stretch_axis() exposes this value to native backends so they can perform layout calculations without evaluating the full view body.

Performance Characteristics

Several design decisions contribute to rendering performance:

  • No tree diffing: Signal-based updates are O(1) per changed property.
  • No virtual DOM: Views are consumed (moved) during body(), not cloned.
  • O(1) type dispatch: 128-bit hash comparison avoids string matching.
  • Native widgets: The platform’s own compositor handles drawing and compositing.
  • Lazy evaluation: body() is only called when the backend actually needs the view tree. Composite views deeper than the visible hierarchy are never evaluated.

The main cost center is the initial view tree walk, which is proportional to the number of visible views. Subsequent updates are proportional only to the number of changed signals, not the tree size.

What’s Next

Now that you understand how views become pixels, the next chapter dives deeper into the FFI bridge – the layer that makes it possible for Rust structs to become Swift objects and Kotlin classes.

The FFI Bridge

In this chapter, you will:

  • Understand how the export!() macro wires up the application entry points
  • Learn the initialization sequence that every native backend follows
  • See how theme signals are injected across the FFI boundary
  • Know the macros that generate type-safe FFI bindings

WaterUI applications are written in Rust, but they render through platform-native backends written in Swift, Kotlin, or GTK4. The FFI (Foreign Function Interface) layer is the bridge between these worlds – a stable C ABI contract that both sides agree on. If you ever need to add a new native view, debug a cross-language issue, or understand why a type needs #[repr(C)], this chapter has the answers.

Overview

The waterui-ffi crate (ffi/ directory) serves as the translation layer between Rust types and C-compatible representations. It defines:

  • IntoFFI – converts Rust types to FFI-safe representations.
  • IntoRust – converts FFI types back to Rust (unsafe, ownership transfer).
  • OpaqueType – marks types as opaque pointers across the boundary.

The crate operates in #![no_std] mode to minimize dependencies, using alloc for heap allocations.

The export!() Macro

Every generated FFI companion crate invokes the export!() macro exactly once. Your application crate exposes pub fn app(env: Environment) -> App; the CLI generated companion depends on that crate and owns the C entry points that native backends call:

waterui_ffi::export!();

The macro expands to three key functions. Let’s look at each one.

waterui_init()

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

Called once at application startup. It:

  1. Initializes the platform logging system (tracing with OS-specific backends):
    • Apple: tracing-oslog with subsystem dev.waterui
    • Android: tracing-android with tag WaterUI
    • Other: tracing-subscriber with fmt output
  2. Sets up a panic hook that forwards panics to tracing::error!.
  3. Initializes the async executor (native-executor).
  4. Optionally initializes the shared GPU context.
  5. Creates a default Environment and returns it as an opaque pointer.

waterui_app()

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

Takes ownership of the environment (which the native side has enriched with theme data) and calls the user’s app(env: Environment) -> App function. Returns a WuiApp struct containing the window array and the environment pointer.

JNI_OnLoad (Android only)

extern "system" fn JNI_OnLoad(vm: *mut c_void, reserved: *mut c_void) -> i32

Initializes the Android NDK context and JNI module. This is generated only when targeting Android.

Initialization Sequence

Every native backend follows the same protocol to start a WaterUI application. Understanding this sequence is essential if you are writing or debugging a backend:

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

Steps 2-4 inject reactive theme signals that track platform appearance changes. The environment carries these signals into the view tree where colors and fonts resolve automatically.

Note: The ordering matters. Theme signals must be installed before calling waterui_app(), because the user’s app() function may immediately reference theme tokens such as theme::color::Foreground or text presets such as .body(), which need the theme to be in place.

Theme Installation APIs

Color Scheme

// Create a reactive color scheme signal
WuiComputed_ColorScheme* scheme =
    waterui_computed_color_scheme_constant(WuiColorScheme_Dark);

// Install it into the environment
waterui_theme_install_color_scheme(env, scheme);

The WuiColorScheme enum has two variants: Light (0) and Dark (1). Native backends typically create a callback-driven signal that tracks the system appearance.

Color Slots

WaterUI defines 8 semantic color slots:

SlotValuePurpose
Background0Primary background
Surface1Elevated surfaces (cards)
SurfaceVariant2Alternate surfaces
Border3Borders and dividers
Foreground4Primary text and icons
MutedForeground5Secondary/dimmed text
Accent6Interactive element highlights
AccentForeground7Text on accent backgrounds

Each slot is installed individually:

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

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

Native code can also read theme values back:

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

View Traversal

Once the app is created, the backend walks the view tree using these functions:

waterui_view_id()

WuiTypeId waterui_view_id(const WuiAnyView* view);

Returns the 128-bit type ID of a view. The backend compares this against known IDs to determine how to render the view.

The WuiTypeId uses FNV-1a hashing of the Rust type name, ensuring stability across dynamic library boundaries (required for the preview/hot-reload system).

waterui_view_body()

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

Evaluates a composite view’s body() method, consuming the view pointer and returning a new view. The backend calls this when it encounters a view type it does not recognize.

waterui_force_as_*() Functions

For each raw view type, the ffi_view! macro generates a force-cast function:

WuiText waterui_force_as_text(WuiAnyView* view);
WuiButton waterui_force_as_button(WuiAnyView* view);

These functions perform an unchecked downcast. The caller must have already verified the type ID. The returned C struct contains all data needed to create the platform widget.

Similarly, ffi_metadata! generates functions for metadata types:

WuiMetadataOpacity waterui_force_as_metadata_opacity(WuiAnyView* view);
WuiMetadataBorder waterui_force_as_metadata_border(WuiAnyView* view);

And ffi_ignorable_metadata! for platform-optional metadata:

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. Used by layout containers to determine how children should fill available space.

FFI Macros

The FFI layer relies on several code-generation macros to reduce boilerplate and prevent mistakes. Here is a quick reference for each one.

ffi_safe!

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

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

opaque!

Creates an opaque wrapper type with pointer-based transfer:

opaque!(WuiEnv, waterui::Environment, env);
// Generates: struct WuiEnv(Environment)
// Also generates: waterui_drop_env() for cleanup

ffi_view!

Generates ID and force-cast functions for native view types:

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

ffi_metadata!

Same pattern for Metadata<T> wrappers:

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

into_ffi!

Derives IntoFFI for structs and enums with field-by-field conversion:

into_ffi!(ListConfig, pub struct WuiList {
    contents: *mut WuiAnyViews,
});

FFI Boundary Safety

All FFI entry points are wrapped in ffi_boundary(), which catches panics and converts them to tracing errors instead of unwinding across the C boundary:

pub fn ffi_boundary<T>(name: &'static str, f: impl FnOnce() -> T) -> Option<T> {
    match std::panic::catch_unwind(AssertUnwindSafe(f)) {
        Ok(value) => Some(value),
        Err(_) => {
            tracing::error!(boundary = name, "panic crossing FFI boundary");
            None
        }
    }
}

This prevents undefined behavior from Rust panics unwinding through C frames.

Warning: If you see panic crossing FFI boundary in your logs, it means a Rust function panicked during an FFI call. Check the surrounding log output for the actual panic message – it will point you to the root cause.

C Header Generation

The C header file ffi/waterui.h is checked into the WaterUI repository and is generated automatically. You must never write or edit it by hand. If you are contributing to WaterUI itself and have modified any FFI function signature, regenerate the header from inside the upstream waterui checkout:

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

CI verifies that the checked-in header matches the generated output, so a missed regeneration will fail your pull request rather than slip through. Application authors who only consume WaterUI never need to run this command.

Android JNI

On Android, the same macros generate JNI entry points alongside the C API. The ffi_view! macro produces both:

// C-API (Apple/GTK)
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;

The JNI module (ffi/src/jni/) provides conversion utilities between Rust structs and Java objects, caching JNI class references for performance.

Adding a New View to FFI

If you are extending WaterUI itself with a new native view, here is the checklist:

  1. Define the Rust view type with raw_view! or configurable! in its component crate.
  2. Define a #[repr(C)] FFI struct (e.g., WuiMyView) in ffi/src/components/.
  3. Implement IntoFFI for the view type.
  4. Call ffi_view!(MyView, WuiMyView, my_view) to generate the entry points.
  5. Regenerate the C header.
  6. Implement the handler in the Apple Swift package and the Android Kotlin runtime so each backend can render the new view.

The header regeneration command will fail if any FFI type is not #[repr(C)] compatible, catching errors at build time rather than runtime.

What’s Next

The FFI bridge gets data across the language boundary, but it does not decide where things go on screen. The next chapter explores WaterUI’s two-phase layout engine – how containers negotiate sizes with their children and place them within the final bounds.

The Layout Engine

In this chapter, you will:

  • Understand WaterUI’s two-phase layout algorithm (propose, then place)
  • Learn how ProposalSize lets parents and children negotiate dimensions
  • See how StretchAxis controls how views fill available space
  • Write a custom layout from scratch

Every UI framework needs to answer one question: where does each element go on screen? WaterUI answers it with a two-phase layout algorithm inspired by SwiftUI’s layout protocol. Parents propose sizes, children respond with their preferences, and then parents make the final placement decisions. If you have ever been frustrated by a view that refuses to fill available space (or one that greedily takes too much), understanding this system will give you the tools to fix it.

Logical Pixels

Before diving into the layout algorithm, a quick note on units.

All layout values in WaterUI use logical pixels (also called “points” or “dp”). This is the same unit system used by design tools like Figma, Sketch, and Adobe XD:

  • iOS/macOS: 1 logical pixel = 1 UIKit/AppKit point (1-3 physical pixels)
  • Android: 1 logical pixel = 1 dp (converted via displayMetrics.density)
  • GTK4: 1 logical pixel = 1 CSS pixel (scaled by GDK)

This means spacing(8.0) produces the same physical size on a 1x display, a 2x Retina display, and a 3x mobile display. You can translate designs from Figma to WaterUI using the exact same numbers.

Tip: If your designer hands you a Figma file with a button at 44pt height and 16pt padding, you can use those exact values in WaterUI: .height(44.0).padding_with(16.0).

The Layout Trait

The Layout trait defines a container’s layout algorithm:

pub trait Layout: Debug {
    /// Phase 1: Calculate the size this layout wants.
    fn size_that_fits(
        &self,
        proposal: ProposalSize,
        children: &[&dyn SubView],
    ) -> Size;

    /// Phase 2: Place children within the given bounds.
    fn place(
        &self,
        bounds: Rect,
        children: &[&dyn SubView],
    ) -> Vec<Rect>;

    /// Which axis this container stretches on.
    fn stretch_axis(&self) -> StretchAxis {
        StretchAxis::None
    }
}

Layout happens in two phases:

  1. Sizing (size_that_fits): The parent proposes a size. The layout queries its children (possibly multiple times) and returns how big it wants to be.
  2. Placement (place): The parent provides final bounds. The layout positions each child within those bounds, returning a Rect per child.

This separation is important: during the sizing phase, you can probe children with different proposals to understand their flexibility before committing to a final arrangement.

ProposalSize

The parent communicates its intentions through ProposalSize:

pub struct ProposalSize {
    pub width: Option<f32>,
    pub height: Option<f32>,
}

Each dimension can be:

ValueMeaning
None“Tell me your ideal/intrinsic size”
Some(0.0)“Tell me your minimum size”
Some(f32::INFINITY)“Tell me your maximum size”
Some(value)“I suggest you use this size”

Children are free to return any size they want – the proposal is just a suggestion. The layout then decides how to handle the response.

The ProposalSize type provides convenience constants:

ProposalSize::UNSPECIFIED  // None, None -- ideal size
ProposalSize::ZERO         // Some(0.0), Some(0.0) -- minimum size
ProposalSize::INFINITY     // Some(INF), Some(INF) -- maximum size

Note: A child is never obligated to accept a proposal. A Text view, for example, always returns its intrinsic size based on the text content and font, regardless of what size is proposed.

The SubView Proxy

During layout, the container does not have direct access to child views. Instead, it works through the SubView trait, which exposes a measure() method (the dual of the layout’s own size_that_fits):

pub trait SubView {
    /// Measure the child for a given proposal. May be called repeatedly.
    fn measure(&self, proposal: ProposalSize) -> ViewDimensions;

    /// Which axis this child stretches on.
    fn stretch_axis(&self) -> StretchAxis;

    /// Layout priority for space distribution.
    fn priority(&self) -> i32;
}

Key design principles:

  • Pure functions: All SubView methods take &self with no side effects. You can call measure multiple times with different proposals to probe a child’s flexibility.
  • Backend-managed caching: Measurement results are cached by the native backend, not in Rust. The SubView proxy simply delegates to the backend’s cache.
  • Priority: Higher-priority children are measured first and get space preference. This allows important content to claim space before flexible elements like spacers.

StretchAxis

Every view declares how it wants to fill available space:

pub enum StretchAxis {
    None,       // Content-sized
    Horizontal, // Expands width, intrinsic height
    Vertical,   // Intrinsic width, expands height
    Both,       // Greedy, fills all space
    MainAxis,   // Expands along the parent's main axis
    CrossAxis,  // Expands along the parent's cross axis
}

MainAxis and CrossAxis are relative to the parent container:

  • In a VStack, MainAxis = vertical, CrossAxis = horizontal.
  • In an HStack, MainAxis = horizontal, CrossAxis = vertical.

This allows Spacer to always push siblings apart regardless of the container orientation, and Divider to always span the cross axis.

How Built-in Layouts Work

Now let’s see how the theory applies in practice with WaterUI’s built-in layout containers.

VStack and HStack

Stacks are the most common layout containers. Their algorithm:

Sizing phase:

  1. Separate children into fixed (non-stretchy) and flexible (stretchy) groups.
  2. Propose the full available size to each fixed child, collect their sizes.
  3. Calculate remaining space after fixed children and spacing.
  4. Distribute remaining space among flexible children, proposing equal shares.
  5. Sum all child heights (VStack) or widths (HStack) plus spacing.

Placement phase:

  1. Start at the top (VStack) or leading edge (HStack).
  2. Place each child sequentially, advancing by child size plus spacing.
  3. Align children on the cross axis according to the stack’s alignment parameter.

A VStack reports StretchAxis::Horizontal by default – it fills available width but determines its own height from content.

Frames

The frame modifiers constrain a view to specific dimensions:

text("Hello").size(200.0, 100.0)

The frame layout proposes the constrained size to its child, then returns the requested dimensions. Use .width(...), .height(...), or .size(width, height) depending on which axes you want to constrain.

Grids

Grid layout arranges children in rows and columns with configurable column definitions. Each column can be fixed-width, flexible, or adaptive.

ScrollView

ScrollView proposes infinite size along its scroll axis, allowing children to be larger than the visible area. The native backend handles the actual scrolling behavior.

Padding

The Padding modifier insets the child by specified amounts on each edge:

text("Padded").padding_with(EdgeInsets::all(16.0))

During sizing, it adds the padding to the child’s size. During placement, it offsets the child’s origin by the padding amounts.

Writing a Custom Layout

To create a custom layout, implement the Layout trait. Here is a flow layout that wraps children to the next line when they exceed the available width:

use waterui_core::layout::*;

#[derive(Debug)]
pub struct FlowLayout {
    pub h_spacing: f32,
    pub v_spacing: f32,
}

impl Layout for FlowLayout {
    fn size_that_fits(
        &self,
        proposal: ProposalSize,
        children: &[&dyn SubView],
    ) -> Size {
        let max_width = proposal.width_or(f32::INFINITY);
        let mut x = 0.0_f32;
        let mut y = 0.0_f32;
        let mut row_height = 0.0_f32;
        let mut total_width = 0.0_f32;

        for child in children {
            let child_size = child.measure(ProposalSize::UNSPECIFIED).size;

            if x + child_size.width > max_width && x > 0.0 {
                // Wrap to next line
                y += row_height + self.v_spacing;
                x = 0.0;
                row_height = 0.0;
            }

            x += child_size.width + self.h_spacing;
            row_height = row_height.max(child_size.height);
            total_width = total_width.max(x - self.h_spacing);
        }

        Size::new(total_width, y + row_height)
    }

    fn place(
        &self,
        bounds: Rect,
        children: &[&dyn SubView],
    ) -> Vec<Rect> {
        let max_width = bounds.width();
        let mut rects = Vec::with_capacity(children.len());
        let mut x = 0.0_f32;
        let mut y = 0.0_f32;
        let mut row_height = 0.0_f32;

        for child in children {
            let child_size = child.measure(ProposalSize::UNSPECIFIED).size;

            if x + child_size.width > max_width && x > 0.0 {
                y += row_height + self.v_spacing;
                x = 0.0;
                row_height = 0.0;
            }

            rects.push(Rect::new(
                Point::new(bounds.x() + x, bounds.y() + y),
                child_size,
            ));

            x += child_size.width + self.h_spacing;
            row_height = row_height.max(child_size.height);
        }

        rects
    }
}

The key pattern: call child.measure() in both phases with consistent proposals, so the sizes you computed in phase 1 match what you place in phase 2.

Try it yourself: Implement a custom layout that arranges children in a circle. Use size_that_fits to calculate the bounding box, and place to position each child at an angle around the center.

Safe Area

Safe area handling is intentionally not part of the Layout trait. Safe areas are a platform-specific concept:

  • iOS: Notch, home indicator, status bar
  • Android: Navigation bar, status bar, cutouts
  • macOS: Toolbar, title bar

Each backend handles safe area insets natively. Views can opt out using the IgnoreSafeArea metadata (exposed via .ignoring_safe_area() modifier), which tells the backend to extend the view beyond the safe area boundaries.

Geometry Types

The layout module provides four fundamental geometry types:

TypeFieldsDescription
Pointx: f32, y: f32Position relative to parent origin
Sizewidth: f32, height: f32Two-dimensional extent
Rectorigin: Point, size: SizePositioned rectangle
ProposalSizewidth: Option<f32>, height: Option<f32>Size negotiation

Rect provides convenience methods for common geometric queries:

let rect = Rect::new(Point::new(10.0, 20.0), Size::new(100.0, 50.0));
rect.min_x()   // 10.0
rect.max_x()   // 110.0
rect.mid_x()   // 60.0
rect.center()  // Point(60.0, 45.0)
rect.inset(10.0, 10.0, 20.0, 20.0)  // Shrink by padding

Layout and the FFI

The Layout trait lives entirely in Rust. Backends that delegate to a platform layout system (the Apple backend through SwiftUI/Auto Layout, the Android backend through Android’s view hierarchy) do not use this trait directly – they read each view’s StretchAxis over FFI through waterui_view_stretch_axis() and let the host system position widgets.

The Rust Layout trait is the source of truth for any backend that performs layout itself, including:

  • The Hydrolysis backend (the active Rust-side self-drawn renderer).
  • Any custom Rust-based backend you write on top of waterui-backend-core.

What’s Next

Layout puts views in the right place, but different platforms need different backends to make it all happen. The next chapter surveys WaterUI’s backend architecture – Apple, Android, GTK4, and the experimental Hydrolysis renderer.

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 WaterUI reactive signals through FFI watchers. When a Computed<T> value changes, the Rust side invokes a C callback that the Swift side registered:

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 {
    debug_assert!(reserved.is_null());
    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.

Library Authoring

In this chapter, you will:

  • Use configurable! and raw_view! to create hookable and simple views
  • Apply the Type::new / free-function constructor split that WaterUI uses everywhere
  • Accept IntoText, IntoLabel, IntoSignal<T>, and IntoComputed<T> for ergonomic APIs
  • Pass context through the Environment and the Plugin trait
  • Follow best practices for composition, testing, and API design

WaterUI is designed for extensibility. Whether you are building a shared component library for your team or an open-source package for the community, the framework provides patterns and macros that help you create clean, composable, and type-safe APIs. This chapter covers the tools and best practices that separate a good WaterUI library from a great one.

The configurable! Macro

The configurable! macro is the standard way to create views that support both builder-pattern configuration and environment-based hooking. This is the pattern you want when your view should be customizable by downstream consumers:

configurable!(Button, ButtonConfig);
configurable!(Slider, SliderConfig, StretchAxis::Horizontal);
configurable!(Progress, ProgressConfig, |config| match config.style {
    ProgressStyle::Linear => StretchAxis::Horizontal,
    ProgressStyle::Circular => StretchAxis::None,
});

This macro generates:

  1. A wrapper struct (e.g., Button) that holds the config.
  2. NativeView impl on the config type, declaring the stretch axis.
  3. ConfigurableView impl on the wrapper, exposing config().
  4. ViewConfiguration impl on the config, with a render() method.
  5. View impl that checks for environment hooks before falling through to native rendering.

The hook mechanism allows library consumers to globally customize how a view renders without modifying the library code:

let mut env = Environment::new();
env.insert_hook(|env: &Environment, config: ButtonConfig| {
    // Return a completely custom button implementation
    custom_button(config.label, config.action)
});

Tip: Think of configurable! as “I am defining this view, but I want consumers to be able to override it.” If you do not need that override capability, use raw_view! instead.

Three Stretch Axis Modes

The macro supports three patterns for declaring stretch behavior:

// Static: Always the same stretch axis
configurable!(MyView, MyConfig);                          // StretchAxis::None
configurable!(MyView, MyConfig, StretchAxis::Horizontal); // Always horizontal

// Dynamic: Depends on configuration at runtime
configurable!(MyView, MyConfig, |config| {
    if config.is_expanded { StretchAxis::Both } else { StretchAxis::None }
});

The raw_view! Macro

For simpler leaf views that do not need hookability, use raw_view!:

raw_view!(Divider, StretchAxis::CrossAxis);
raw_view!(Spacer, StretchAxis::MainAxis);
raw_view!(Image);  // StretchAxis::None by default

This implements NativeView and View without the ConfigurableView / Hook machinery. Use raw_view! when:

  • The view has no meaningful configuration to hook.
  • You want the simplest possible implementation.
  • The view is internal to your library and not meant to be customized.

The Constructor Split

WaterUI is consistent about how public APIs expose construction, and your library should follow the same convention:

  • Type::new(...) is the general constructor. It takes the most general shape the component can render – typically a fully open impl View for the label slot, plus all the dials a power user might need.
  • Free function constructors like button(...) are ergonomic entry points. They accept narrower semantic input types (IntoLabel, IntoText) so that string literals and i18n-friendly text fall into the right semantic pipeline with sensible default accessibility.

Do not introduce parallel Type::custom(...) shapes – if Type::new(...) is not flexible enough, fix Type::new.

// General: arbitrary visual composition for the label, action chained after.
let custom = Button::new(my_view).action(|env: Environment| { /* ... */ });

// Ergonomic: literal flows into the i18n-aware semantic text pipeline,
// and accessibility defaults are inherited automatically.
let ergonomic = button("Save");

Flexible Input Types

A great library API does not force users to think about type conversions. WaterUI provides traits that accept the widest reasonable input types so callers can pass whatever is most natural.

IntoText and IntoLabel

Prefer IntoText for semantic text and IntoLabel for labelled controls (buttons, toggles, fields). These traits route literals, String, Str, StyledStr, and reactive Computed<T> through WaterUI’s i18n-aware semantic text pipeline – so accessibility and localization come for free:

use waterui::text::IntoText;
use waterui::text::font::Caption;

pub fn caption(content: impl IntoText) -> Text {
    Text::new(content).font(Caption)
}

caption("Saved");                  // &'static str -> SemanticText
caption(String::from("Saved"));    // String
caption(text!("Saved at {now}")); // reactive content via text! macro

Only fall back to a raw impl View when the slot really is “arbitrary visual composition,” not a textual label.

IntoSignal<T> and IntoComputed<T>

For non-textual reactive inputs, accept IntoSignal<T> (or IntoComputed<T> when you specifically need a derived value) so callers can pass either a constant or a reactive source without wrapping it in Computed::constant():

pub fn opacity(value: impl IntoComputed<f32>) -> Opacity {
    Opacity { value: value.into_computed() }
}

opacity(0.5);            // f32 constant
opacity(my_binding);     // Binding<f32>
opacity(computed_value); // Computed<f32>

IntoSignalF32

A specialized trait for f32 values that also accepts integers:

pub fn spacing(value: impl IntoSignalF32) -> f32 {
    value.into_signal_f32()
}

spacing(8)      // i32 -> f32
spacing(8.0)    // f32
spacing(8u32)   // u32 -> f32

Environment for Context Passing

The Environment is a type-indexed key-value store. Libraries can define custom environment keys to pass context through the view hierarchy without threading parameters through every function call:

use waterui_core::env::Store;

// Define a theme token
pub struct AccentColor;

// Install into environment
let mut env = Environment::new();
env.insert(Store::<AccentColor, Color>::new(Color::blue()));

// Read in a child view
pub fn themed_button() -> impl View {
    use_env(|env: &Environment| {
        let color = env.query::<AccentColor, Color>()
            .unwrap_or(&Color::blue());
        button("Tap me").tint(*color)
    })
}

The Plugin Trait

For libraries that need to install multiple values, implement Plugin:

pub trait Plugin {
    fn install(&self, env: &mut Environment) {
        // Default: no-op
    }
}

pub struct MyLibraryPlugin {
    pub theme: MyTheme,
}

impl Plugin for MyLibraryPlugin {
    fn install(&self, env: &mut Environment) {
        env.insert(self.theme.clone());
        env.insert_hook(|env, config: ButtonConfig| {
            // Custom button styling
        });
    }
}

// Usage
let mut env = Environment::new();
env.install(MyLibraryPlugin { theme: MyTheme::default() });

Tip: The Plugin trait is the recommended way to distribute a library’s setup logic. Instead of asking users to call five different env.insert(...) lines, give them a single env.install(MyPlugin { ... }).

ViewExt Composition Patterns

WaterUI’s modifier system uses extension traits. When creating library components, prefer composition over wrapping:

// Prefer: compose with existing modifiers
pub fn card(content: impl View) -> impl View {
    content
        .padding_with(EdgeInsets::all(16.0))
        .background(waterui::theme::color::Surface)
        .corner_radius(12.0)
        .shadow(Shadow::default())
}

// Avoid: creating a new view type just for styling
pub struct Card<V> { content: V }
impl<V: View> View for Card<V> {
    fn body(self, env: &Environment) -> impl View {
        self.content
            .padding_with(EdgeInsets::all(16.0))
            .background(waterui::theme::color::Surface)
            // ... same thing but more code
    }
}

The function approach is simpler and composes naturally with the rest of the framework.

When to Create a Custom View Type

Create a dedicated struct when:

  • The component has internal state (use Binding<T>).
  • It needs to participate in FFI (native rendering).
  • It has multiple configuration options (use configurable!).
  • It needs to intercept environment values.

The Extractor Pattern

The Extractor trait lets views declare their dependencies declaratively:

use waterui_core::extract::Extractor;

// Use use_env with tuple extraction
let view = use_env(|(nav, db): (NavigationController, Database)| {
    // Both values extracted from environment
    button("Load").on_tap(move || {
        let data = db.fetch();
        nav.push(detail_view(data));
    })
});

Library views should use use_env to access environment values rather than requiring users to pass them explicitly. This keeps APIs clean and enables dependency injection.

Testing Strategies

Good libraries are well-tested libraries. WaterUI supports several testing approaches.

Unit Testing Views

Test view construction without rendering:

#[cfg(test)]
mod tests {
    use super::*;
    use waterui::Environment;

    #[test]
    fn button_config_has_correct_defaults() {
        let btn = button("Tap me", || {});
        let config = btn.config();
        assert_eq!(config.style, ButtonStyle::Default);
    }

    #[test]
    fn view_body_produces_expected_tree() {
        let env = Environment::new();
        let view = my_component();
        let body = view.body(&env);
        // Assert on the resulting view type
    }
}

Testing Reactive Behavior

Test that signals propagate correctly:

#[test]
fn counter_increments() {
    let count = Binding::i32(0);
    let view = counter_view(count.clone());

    // Simulate action
    count.set(1);
    assert_eq!(count.get(), 1);
}

Visual Testing with Preview

Use the #[preview] macro to render views to PNG for visual regression testing:

#[preview]
fn my_card_preview() -> impl View {
    card(text("Preview content"))
}

Then run:

water preview my_card_preview --platform macos --path ./app --output card.png

Best Practices

Prefer Composition Over Inheritance

Rust does not have inheritance, and WaterUI leans into this. Build complex components by composing simple ones:

// Good: composition
pub fn labeled_field(label: &str, field: impl View) -> impl View {
    vstack((text(label).font(waterui::text::font::Caption), field)).spacing(4.0)
}

// Bad: trying to "inherit" from TextField
pub struct LabeledTextField { /* reimplements TextField internals */ }

Leverage the Type System

Use Rust’s type system to enforce correctness at compile time:

// Good: type-safe builder
pub struct FormBuilder<S: FormState> {
    state: S,
    fields: Vec<AnyView>,
}

// Bad: stringly-typed API
pub fn add_field(form: &mut Form, name: &str, field_type: &str) { /* ... */ }

Keep Views Stateless

Views should be lightweight, stateless descriptions. Put mutable state in Binding<T> values that live outside the view tree:

// Good: state separate from view
pub fn counter() -> impl View {
    let count = Binding::i32(0);
    vstack((
        text(count.map(|c| format!("Count: {c}"))),
        button("+", {
            let count = count.clone();
            move || count.set(count.get() + 1)
        }),
    ))
}

Document with Previews

Every public component should have a #[preview] function in its module:

#[preview]
fn button_styles() -> impl View {
    vstack((
        button("Default", || {}),
        button("Destructive", || {}).style(ButtonStyle::Destructive),
        button("Plain", || {}).style(ButtonStyle::Plain),
    ))
    .spacing(8.0)
}

This serves as both documentation and a visual test.

Minimize Public API Surface

Export only what users need. Keep internal types private:

// lib.rs
pub use button::{button, Button, ButtonStyle};
// ButtonConfig, ButtonInner, etc. stay private

Use #[doc(hidden)] for types that must be public for technical reasons (macro expansion) but should not appear in documentation.

What’s Next

You have now seen WaterUI from the inside out – rendering, FFI, layout, backends, and library authoring. The next chapter steps back from the code to explore the design philosophy that ties all these pieces together.

Philosophy

In this chapter, you will:

  • Understand the “why” behind WaterUI’s core design decisions
  • See how native-first rendering, fine-grained reactivity, and Rust’s type system work together
  • Explore the water metaphor that shaped the framework’s identity
  • Learn the principles that guide WaterUI’s evolution

Every framework is a set of bets. Bets about what matters most, about which trade-offs are worth making, and about what kind of developer experience will stand the test of time. WaterUI’s bets are deliberate, and understanding them will help you use the framework effectively – and decide whether it is the right tool for your project.

Native-First

WaterUI renders to platform-native widgets wherever a real platform widget exists. When you write text("Hello"), it becomes a UILabel on iOS and a TextView on Android, and on Hydrolysis it becomes a GPU-rendered text node that participates in the same accessibility tree the test runner asserts against. Native-first is not a compromise – it is the core design principle.

Why Native?

  • Accessibility for free: Screen readers, voice control, and switch access work because the platform already knows how to interact with its own widgets.
  • Platform conventions: Text selection, context menus, drag-and-drop, and keyboard navigation behave exactly as users expect on each platform.
  • System integration: Dynamic Type (iOS), Material You (Android), and GTK themes automatically apply without extra work from the developer.
  • Performance: Native widgets are GPU-composited by the platform’s own rendering pipeline, which has been optimized for decades.

The Trade-off

Native rendering means pixel-perfect consistency across platforms is not the goal. A WaterUI button will look like an iOS button on iOS and a Material button on Android. If you need pixel-identical rendering everywhere – or you are running somewhere without a native widget set, like a Linux desktop or an embedded target – the Hydrolysis backend draws its own pixels on the GPU instead.

Note: This is a philosophical stance, not just a technical one. WaterUI believes that respecting each platform’s identity leads to better software than forcing a single look everywhere.

Reactive, Not Virtual DOM

WaterUI uses fine-grained reactivity through Binding<T> and Computed<T>. There is no virtual DOM, no tree diffing, and no reconciliation pass.

How It Works

Each piece of mutable state is a Binding<T>. Derived values are Computed<T>. When a binding changes, only the computed values that depend on it are recalculated, and only the specific widget properties bound to those computed values are updated:

Binding<i32> changes from 0 to 1
  |
  v
Computed<String> = "Count: 1"  (only this one recomputes)
  |
  v
UILabel.text = "Count: 1"     (only this property updates)

No other widgets are touched. No tree is walked. The update cost is O(number of affected signals), not O(tree size).

Why Not Virtual DOM?

Virtual DOM frameworks (React, Flutter) rebuild a lightweight tree on every state change and diff it against the previous tree to find what changed. This has advantages (simple mental model, works well for web) but also costs:

  • O(n) diffing: Even if one property changed, the entire subtree must be diffed.
  • Allocation pressure: Every render cycle creates a new tree of objects.
  • Identity problems: The framework must heuristically determine which nodes “are the same” across renders (keys, indices).

WaterUI avoids all of these by connecting signals directly to widget properties. The framework knows exactly what changed because the reactive graph tracks dependencies at a granular level.

Views Are Consumed, Not Retained

In WaterUI, View::body(self, env) takes self by value. The view struct is moved and consumed when its body is evaluated. There is no persistent view tree in memory – the Rust view structs exist only during the initial walk. After that, all state lives in Binding<T> values and native widgets.

This means:

  • No stale view references.
  • No memory leaks from retained view trees.
  • No confusion about whether you are looking at “the current view” or “the previous render’s view.”

Rust All The Way

Application logic, UI composition, state management, and even layout algorithms are all written in Rust. The native backends are thin adapters that translate Rust types into platform widgets.

Why Rust?

  • Memory safety without GC: No garbage collection pauses, no use-after-free, no data races. The borrow checker enforces safety at compile time.
  • Zero-cost abstractions: Generics, traits, and closures compile to the same code you would write by hand. The View trait has no virtual dispatch overhead for statically known types.
  • Shared codebase: Business logic, networking, data processing, and UI all live in the same language. No context switching between Kotlin and Swift.
  • no_std support: The core and FFI crates work without the standard library, enabling embedded and WASM targets.

The FFI Boundary

The price of “Rust all the way” is the FFI layer. Every interaction between Rust and the native backend crosses a C ABI boundary (or JNI on Android). WaterUI minimizes this cost by:

  • Using 128-bit type IDs for O(1) view dispatch (no string comparisons).
  • Transferring ownership of data across the boundary (no reference counting).
  • Catching panics at the boundary to prevent undefined behavior.
  • Generating FFI code automatically from macros and cbindgen.

The Water Metaphor

The name “WaterUI” embodies a key idea: like water, the framework adapts to its container.

  • Water in a glass takes the shape of a glass. WaterUI on iOS takes the shape of iOS – native widgets, platform conventions, system fonts.
  • Water in a bottle takes the shape of a bottle. WaterUI on Android takes the shape of Android – Material Design, system navigation, Compose interop.
  • Water itself does not have a shape. The same view tree flows through Apple, Android, and Hydrolysis without changing what you wrote.

This philosophy extends to the API design:

  • Layout adapts: StretchAxis::MainAxis means “expand along whatever axis the parent uses,” not “expand horizontally.”
  • Colors adapt: theme::color::Foreground is not a specific hex value – it resolves to the platform’s foreground color, whether that is black, white, or a custom theme color.
  • Typography adapts: .body() and text::font::Body resolve to San Francisco on Apple, Roboto on Android, and the system font on Linux.

Design Decisions

The following sections address questions that often arise: “Why did you choose X instead of Y?”

Why Declarative?

WaterUI views are declarative: you describe what the UI should look like, not how to build it. The View trait’s body() method returns a description, and the backend decides how to realize it.

This matches how modern UIs are designed. Designers create mockups that describe the final state, not step-by-step instructions. Declarative code reads like a design specification:

fn profile_card(user: &User) -> impl View {
    use waterui::theme::color::{MutedForeground, Surface};

    hstack((
        avatar(user.photo),
        vstack((
            text(user.name).headline(),
            text(user.bio).foreground(MutedForeground),
        ))
    ))
    .padding_with(16.0)
    .background(Surface)
}

Why Not an ECS?

Entity-Component-System architectures are popular in game engines, where you have thousands of similar entities updated in tight loops. UI frameworks have different needs:

  • Heterogeneous trees: A form has text fields, buttons, sliders, and labels – all with different data shapes.
  • Sparse updates: Usually only one or two widgets change at a time.
  • Deep nesting: Navigation stacks, tab views, and modal sheets create deep hierarchies that ECS struggles with.

WaterUI’s trait-based view system handles these patterns naturally.

Why Signals Over Streams?

Reactive streams (Rx, async streams) represent sequences of events over time. Signals represent the current value of a piece of state. For UI, signals are a better fit because:

  • A text field always has a current value, not a stream of values.
  • A label always displays the current text, not a history of texts.
  • When a new subscriber connects, it immediately gets the current value.

WaterUI exposes Binding<T> (current value + change notifications) and Computed<T> (derived value that updates automatically). These are synchronous, glitch-free, and main-thread-safe – exactly what UI state management needs.

Comparison with Other Frameworks

WaterUI exists in a landscape of cross-platform frameworks. Here is how it compares at a high level:

AspectWaterUIFlutterReact NativeCompose Multiplatform
LanguageRustDartJavaScriptKotlin
RenderingNative widgets on Apple/Android; GPU (Hydrolysis) elsewhereCustom (Skia)Native widgetsNative + Skia
StateSignalssetState/RiverpoduseState/ReduxState/Flow
PlatformsiOS, Android, Linux/desktop via HydrolysisiOS, Android, Web, DesktopiOS, AndroidiOS, Android, Desktop
Runtime overheadNo managed runtime~5MB runtime~5MB + JSC~2MB runtime

Each framework makes different trade-offs. WaterUI’s niche is: Rust developers who want native performance, native look-and-feel, and a single codebase without a managed runtime.

Principles for Contributors

If you are contributing to WaterUI, keep these principles in mind. They are not just guidelines – they are the invariants that hold the framework together:

  1. Native first: If a platform has a built-in widget for something, use it. Do not reimplement platform functionality unless there is a strong reason.

  2. Type safety over runtime checks: Use Rust’s type system to catch errors at compile time. Prefer generics over dyn Any.

  3. Composition over configuration: Instead of adding flags to existing views, create new composable building blocks.

  4. No global state: Pass context through Environment, not through static variables or singletons.

  5. Fail fast: If something is wrong, panic with a clear message. Do not silently fall back to a default behavior that hides the bug.

  6. Less code is better: Import a well-tested crate rather than writing your own implementation. Every line of code is a line that can break.

Automation and CI

In this chapter, you will:

  • Script the water CLI for deterministic, non-interactive builds
  • Set up GitHub Actions workflows for multi-platform CI
  • Run tests, validate FFI headers, and generate preview-based visual tests
  • Debug CI failures with structured logging

WaterUI projects can be fully automated for continuous integration, deployment, and development workflows. This appendix gives you the practical recipes to make that happen.

Deterministic CLI Runs

The water CLI is designed for both interactive and automated use. When running in scripts or CI, use these flags to ensure deterministic behavior.

JSON output

--json is a global flag on water that switches every status, error, and success message to machine-readable JSON instead of human-readable ANSI output:

water --json devices

Pipe through jq to parse fields:

# Inspect the first iOS simulator's identifier
water --json devices | jq '.ios[0].udid'

Non-interactive mode

Subcommands that may prompt for confirmation accept -y/--yes to auto-confirm in scripts:

water clean -y
water backend remove apple -y

Check water <command> --help to see exactly which subcommands accept --yes; not every command prompts.

Tip: In CI, set CI=1 if your scripts depend on it, and pass -y to any command that may prompt. A forgotten prompt will hang the pipeline.

Scripting with Water Commands

Building for Multiple Platforms

#!/bin/bash
set -euo pipefail

# Build for each target you care about. The platform is a flag, not a
# positional argument.
water build --platform ios-simulator
water build --platform android
water build --platform linux

Device discovery

# Capture the device list
water --json devices > devices.json

# Run on a specific device
DEVICE_ID=$(water --json devices | jq -r '.ios[0].udid')
water run --platform ios --device "$DEVICE_ID"

Preview generation

Generate preview images for visual regression testing:

water preview my_component \
    --platform macos \
    --path ./app \
    --output previews/my_component.png

This builds the project as a dylib, loads it into a preview host, and captures the rendered output. Use the same command in CI to regenerate “current” snapshots before diffing them against your committed reference images.

Book visual assets

This book keeps generated illustrations in src/assets/visuals/. Each image is listed in scripts/book-visuals/manifest.tsv and rendered from the pinned WaterUI submodule through the runnable examples/book-visuals preview example:

scripts/render-book-visuals

Use --check when you want to prove the committed PNGs match a fresh water preview render. The renderer requires oxipng so committed assets stay small without changing pixels; --check also uses ffmpeg for a PSNR compare when Hydrolysis text rasterization differs by a few antialiasing pixels. The normal book validation uses --check-links only, so Cloudflare Pages can keep deploying with a plain mdbook build.

CI/CD Integration Patterns

GitHub Actions

Here is a minimal GitHub Actions workflow for a WaterUI project:

name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Check formatting
        run: cargo fmt --check
      - name: Run clippy
        run: cargo clippy -- -D warnings
      - name: Run tests
        run: cargo test

  build-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: aarch64-apple-ios-sim
      - name: Install water CLI
        run: cargo install waterui-cli
      - name: Doctor check
        run: water doctor
      - name: Build for iOS Simulator
        run: water build --platform ios-simulator

  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: aarch64-linux-android
      - name: Install water CLI
        run: cargo install waterui-cli
      - name: Build for Android
        run: water build --platform android

Environment Validation

Always run water doctor at the start of your CI pipeline to verify the environment is correctly configured:

water doctor

This checks for:

  • Rust toolchain version and required targets.
  • Platform SDKs (Xcode, Android SDK, GTK4 development libraries).
  • Required tools (cargo, rustc, xcodebuild, adb, gradle).

Use water doctor --fix to automatically install missing components where possible (e.g., adding Rust targets via rustup).

Caching

Cache the Cargo build directory to speed up CI builds:

- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

Warning: Do not cache platform-specific build artifacts (Xcode derived data, Gradle build directories) as they are more fragile and can cause hard-to-debug failures.

Testing

Rust Unit and Integration Tests

Run the full test suite:

cargo test

Run tests for a specific crate:

cargo test -p waterui-core
cargo test -p waterui-layout
cargo test -p waterui-ffi

Book Validation

If your project includes an mdBook (like this book), validate that all code examples compile:

mdbook test

This extracts Rust code blocks from markdown files and runs them as doctests. Code blocks marked with rust,ignore are skipped.

FFI Header Verification (WaterUI contributors only)

If you are contributing to WaterUI itself, your CI should verify the checked-in C header is up to date. Application authors do not need this step.

# Generate the header
cargo run --bin generate_header --features cbindgen --manifest-path ffi/Cargo.toml

# Check for differences
git diff --exit-code ffi/waterui.h

A drift in ffi/waterui.h fails CI, reminding the developer to regenerate and commit it.

Preview-Based Visual Tests

For visual regression testing, generate preview images and compare them:

# Generate current previews
water preview my_button --platform macos --path ./app --output current/button.png
water preview my_card   --platform macos --path ./app --output current/card.png

# Compare against reference images (using any image diff tool)
# For example, with ImageMagick:
compare -metric RMSE reference/button.png current/button.png diff/button.png

Build Automation Scripts

Regenerating FFI Bindings (WaterUI contributors only)

If you have modified an FFI API in your fork of WaterUI, regenerate the C header from the upstream waterui checkout:

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

Release Builds

For production releases, the water CLI handles platform-specific packaging. --platform is a flag and --release switches to optimized output:

water package --platform ios --backend apple --release
water package --platform android --backend android --arch arm64 --release
water package --platform linux --backend gtk4 --release

Clean Builds

When you need a fresh start (rarely necessary):

water clean

This removes WaterUI-specific build artifacts. Avoid cargo clean as it removes the entire Cargo target directory, which wastes significant rebuild time.

Environment Variables

The water CLI and WaterUI runtime respect these environment variables:

VariablePurpose
RUST_LOGControls tracing log level (e.g., debug)
WATERUI_DISPATCH_DEBUGEnables view dispatch tracing (any Rust-side backend that uses ViewDispatcher, including Hydrolysis)
CARGO_TARGET_DIRCargo build directory (do not customize when working inside the WaterUI monorepo)

Debugging in CI

When CI builds fail, use debug logging to get more information:

# Run with verbose output
water run --platform ios --logs debug

# Or set the environment variable
RUST_LOG=debug cargo test

The --logs debug flag enables tracing output from the WaterUI runtime, showing view dispatch, signal updates, and FFI calls. On Apple platforms, this uses os_log; on Android, logcat; on other platforms, stderr.

What’s Next

If your CI pipeline is passing but something still is not working, head to the Troubleshooting appendix for solutions to common development issues.

Troubleshooting

In this appendix, you will:

  • Diagnose and fix common Rust toolchain and platform SDK issues
  • Resolve build failures, linking errors, and FFI mismatches
  • Debug hot reload, runtime rendering, and signal update problems
  • Use platform-specific debugging tools and structured logging

Something not working? You are in the right place. This appendix covers the most common issues WaterUI developers encounter, organized by category so you can jump straight to your problem. When in doubt, start with water doctor – it diagnoses most environment problems automatically.

First Steps

Before diving into specific issues, try these general diagnostic steps:

# Check your environment for known issues
water doctor

# Automatically fix what can be fixed
water doctor --fix

# Clear WaterUI build artifacts (not cargo target)
water clean

Rust Toolchain Issues

Rust Version Too Old

Symptom: Compilation errors mentioning unstable features or missing syntax.

WaterUI requires Rust edition 2024 and a minimum rustc version of 1.88.

# Check your version
rustc --version

# Update to latest stable
rustup update stable

If you are using a project-level rust-toolchain.toml, make sure it specifies a sufficiently recent version.

Missing Target Triple

Symptom: error[E0463]: can't find crate for 'std' when cross-compiling.

You need to install the target for each platform you build for:

# iOS (device)
rustup target add aarch64-apple-ios

# iOS (simulator, Apple Silicon)
rustup target add aarch64-apple-ios-sim

# iOS (simulator, Intel)
rustup target add x86_64-apple-ios

# Android
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android

# Linux (usually already installed)
rustup target add x86_64-unknown-linux-gnu

Running water doctor --fix will install missing targets automatically.

Cargo Build Fails with Cryptic Errors

Symptom: Internal compiler errors or linker failures.

Try these steps in order:

  1. Update Rust: rustup update
  2. Check for corrupted crate cache: cargo update
  3. If the problem persists, remove the registry cache:
    rm -rf ~/.cargo/registry/cache
    cargo update
    

Do not run cargo clean unless absolutely necessary – it removes all compiled artifacts and forces a full rebuild, which can take many minutes.

Platform SDK Issues

iOS: Xcode Not Found

Symptom: water doctor reports Xcode is missing, or xcodebuild fails.

# Verify Xcode is installed
xcode-select -p

# If it points to CommandLineTools instead of Xcode.app:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

# Accept the license
sudo xcodebuild -license accept

iOS: Simulator Not Booted

Symptom: water run --platform ios fails with “No simulator found.”

# List available simulators that water can see
water devices

# Let water pick and boot a simulator automatically
water run --platform ios

If water devices reports no simulators at all, the issue is the host’s Xcode install – run water doctor to confirm and follow its remediation hints.

iOS: Code Signing Errors

Symptom: Build succeeds but deployment fails with signing errors.

For development builds on the simulator, no signing is required. For device builds, ensure you have:

  1. An Apple Developer account configured in Xcode.
  2. A development certificate and provisioning profile.
  3. The correct team selected in the project settings.

Android: SDK Not Found

Symptom: water doctor reports Android SDK is missing.

Set the ANDROID_HOME or ANDROID_SDK_ROOT environment variable:

# macOS (default Android Studio location)
export ANDROID_HOME="$HOME/Library/Android/sdk"

# Linux
export ANDROID_HOME="$HOME/Android/Sdk"

# Add to your shell profile for persistence
echo 'export ANDROID_HOME="$HOME/Library/Android/sdk"' >> ~/.zshrc

Android: NDK Not Found

Symptom: Cross-compilation fails with missing aarch64-linux-android-* tools.

Install the NDK through Android Studio’s SDK Manager, or via command line:

sdkmanager --install "ndk;27.0.12077973"

The water CLI will detect the NDK if it is installed under $ANDROID_HOME/ndk/.

Android: Emulator Not Running

Symptom: water run --platform android fails to connect.

# List devices and emulators water can see
water devices

If the list is empty, start an emulator from Android Studio (or the avdmanager UI) and re-run water devices. water run --platform android will then deploy to the running device automatically.

Linux desktop: GPU/system libraries missing

Symptom: Compilation fails with missing system headers, or Hydrolysis fails to find a usable wgpu adapter on Linux.

Hydrolysis is the active Linux/desktop backend; it talks to the GPU through wgpu. Make sure your distribution has a working Vulkan stack and the development packages your wgpu backend needs:

# Ubuntu / Debian
sudo apt install libvulkan-dev mesa-vulkan-drivers vulkan-tools

# Fedora
sudo dnf install vulkan-loader-devel mesa-vulkan-drivers vulkan-tools

# Arch Linux
sudo pacman -S vulkan-icd-loader vulkan-tools

If vulkaninfo reports no available device, Hydrolysis production surfaces will refuse to boot rather than silently fall back to a software adapter. Use a machine with a real GPU, or set WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTER=1 for a one-off diagnostic run only.

The legacy GTK4 backend is no longer supported, so its system dependencies (libgtk-4-dev, webkitgtk) are not required for normal app development.

Build Failures

Linking Errors

Symptom: undefined reference or unresolved external symbol during linking.

Common causes:

  • Missing system libraries: The linker cannot find platform SDK libraries. Run water doctor to verify SDK installation.
  • Architecture mismatch: Building for the wrong target. Verify with rustc --print target-list | grep <platform>.
  • Stale build artifacts: Try water clean followed by a fresh build.

FFI Header Out of Date

Symptom: The native backend fails to compile with missing or mismatched function signatures.

Regenerate the C header:

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

Then rebuild the native backend. CI verifies this automatically, so if it passes locally, the header is up to date.

Proc Macro Errors

Symptom: Errors from waterui-macros during compilation.

Proc macro crates must be compiled for the host platform, not the target. If you see errors like “can’t load proc macro,” check that:

  1. You have a working host toolchain: rustup show active-toolchain
  2. The waterui-macros crate compiles on its own: cargo build -p waterui-macros

Hot Reload Issues

Changes Not Reflected

Symptom: You save a file but the running app does not update.

Hot reload works by rebuilding the Rust library as a dylib and reloading it. Check that:

  1. You started the app with water run (not a manual build).
  2. The file you changed is part of the project’s crate graph.
  3. The build succeeds – check the terminal for compilation errors.

Hot Reload Crashes

Symptom: The app crashes after a hot reload.

This can happen if:

  • You changed a struct’s memory layout (added/removed fields) while the native side still holds pointers to the old layout. Restart the app to pick up structural changes.
  • A panic occurred during view construction. Check the logs for panic messages.

Asset Problems

Asset Not Found at Runtime

Symptom: An ImageAsset, FontAsset, or related asset constructor resolves to an empty/missing resource.

  1. Verify the file exists in your project’s assets/ directory.
  2. Check the filename case – mobile platforms are case-sensitive.
  3. Ensure the asset is included in the build. The water CLI bundles the assets/ directory automatically; if you compose a custom Bundle, confirm it points at the right path.

Image Not Displaying

Symptom: No error, but the image view is empty.

  • Check the image format. WaterUI supports PNG, JPEG, and WebP natively.
  • Verify the file is not corrupted: try opening it in an image viewer.
  • Check the image dimensions – very large images may fail to decode on memory-constrained devices.

Runtime Issues

View Not Rendering

Symptom: A view appears blank or invisible.

Common causes:

  1. Zero size: The view has no intrinsic size and no frame constraint. Add .size(width, height) or ensure the parent provides enough space.
  2. Hidden by opacity: Check for .opacity(0.0) or a fully transparent background color.
  3. Incorrect conditional: If using if/else in view bodies, verify the condition is evaluating as expected.

Signals Not Updating

Symptom: Changing a Binding does not update the UI.

  1. Do not call .get() in view bodies: This reads the value once without subscribing. Use .map() to create a Computed<T> that tracks changes:

    // Wrong: reads once, no reactivity
    text(format!("Count: {}", count.get()))
    
    // Correct: reactive
    text(count.map(|c| format!("Count: {c}")))
  2. Binding scope: Ensure the binding outlives the view. If the binding is dropped, watchers are disconnected and updates stop.

  3. Thread safety: Bindings must be updated from the main thread. If you are updating from an async task, use the executor to dispatch back to main.

App Crashes on Startup

Symptom: The app exits immediately with a panic or signal.

Check the logs:

# iOS (Xcode console or)
water run --platform ios --logs debug

# Android
water run --platform android --logs debug
# or: adb logcat -s WaterUI

# Linux
RUST_LOG=debug water run --platform linux

Common startup crash causes:

  • Missing theme signals: The native backend did not install required theme values. This is a backend bug; report it.
  • Panic in app() function: Your app(env) function panicked. The log will show the panic message.
  • FFI mismatch: The Rust library and native backend are out of sync. Rebuild both.

Platform-Specific Troubleshooting

iOS Simulator

# Reset a simulator (clears all data)
xcrun simctl erase "iPhone 16"

# View simulator logs
xcrun simctl spawn "iPhone 16" log stream --level debug --predicate 'subsystem == "dev.waterui"'

Android Emulator

# Clear app data
adb shell pm clear com.your.app

# View logs filtered to WaterUI
adb logcat -s WaterUI:D

# Force-stop the app
adb shell am force-stop com.your.app

Linux (Hydrolysis)

Hydrolysis is the Rust-side backend for desktop targets. Reach for these knobs when a Linux build misbehaves:

# Trace which views the dispatcher matched and which fell through to body().
WATERUI_DISPATCH_DEBUG=1 water run --platform linux --backend hydrolysis --logs debug

# In rare diagnostic situations you can opt into a software wgpu adapter.
# Production surfaces reject software/noop adapters, so do not leave this on.
WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTER=1 water run --platform linux --backend hydrolysis

The legacy GTK4 backend is no longer supported – treat it as historical reference rather than a target you ship against.

Debug Logging

WaterUI uses the tracing crate for structured logging. Enable verbose output:

# Via the water CLI
water run --platform <platform> --logs debug

# Via environment variable
RUST_LOG=debug water run --platform <platform>

# More specific filtering
RUST_LOG=waterui=debug,waterui_core=trace water run --platform <platform>

On Apple platforms, logs are sent to os_log with subsystem dev.waterui. View them in Console.app or with:

log stream --predicate 'subsystem == "dev.waterui"' --level debug

On Android, logs go to logcat:

adb logcat -s WaterUI:D

Getting Help

If the troubleshooting steps above do not resolve your issue:

  1. Search the GitHub Issues for similar problems.
  2. Run water doctor and include the output in your bug report.
  3. Include the full error message and relevant log output.
  4. Specify your OS, Rust version (rustc --version), and platform SDK versions.
  5. Provide a minimal reproduction case if possible.