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:

  • Learn what WaterUI is and how it reaches each platform
  • See which backends exist and what each one renders with
  • Find your way around the workspace and this book
  • Read a working counter written in WaterUI

Pinned to upstream: every example and API name in this book is verified against waterui dev 9a9866c13e7f (2026-08-10, “fix(cli): invalidate generated host crates when scaffold templates change”). When the submodule bumps, the chapters bump with it.

What is WaterUI?

WaterUI is a cross-platform, reactive, declarative UI framework for Rust. You describe your interface as a tree of View values; the framework decides how each node is realised on the current platform.

Realisation is native first. Where a platform provides a canonical primitive for a semantic component – a button, a text field, a list – WaterUI bridges to it: UIKit/AppKit on Apple platforms, Android View on Android, GTK4 on Linux. Where no suitable platform primitive exists, or where the target has no widget toolkit at all, WaterUI uses one of its own renderers. That is a deliberate choice per component, not a fallback after a failed native call.

                            ┌─ Apple backend (Swift)   → UIKit / AppKit
Rust View tree ─ FFI (C ABI)┼─ Android backend (Kotlin)→ Android View
                            ├─ GTK4 backend            → GTK4 widgets
                            ├─ Hydrolysis              → GPU, self-drawn
                            └─ Dew                     → CPU, self-drawn

Updates are fine-grained. Binding<T>, Computed<T>, and signal-aware component inputs update the affected value in place. There is no structural diff pass over the tree, and changing one label does not rebuild its siblings.

Backends

BackendTargetsRealisation
AppleiOS, macOSUIKit / AppKit through a Swift package
AndroidAndroidAndroid View through Kotlin and JNI
GTK4LinuxGTK4 widgets through gtk4-rs
HydrolysismacOS, Linux, Windows, WebSelf-drawn GPU renderer (Vello on wgpu)
DewESP32-S3, ESP32-C3Self-drawn CPU renderer with dirty-area banding

Hydrolysis redraws the whole scene every frame on the GPU and targets high refresh rates. Dew is its opposite: CPU rasterisation, dirty rectangles sliced into bands so peak pixel memory is one band rather than a frame, sized for microcontrollers. hydrolysis-m3 layers a Material Design 3 theme package on top of Hydrolysis.

WaterUI is pre-1.0 (waterui 0.2.x), and the upstream roadmap still lists self-rendering milestones as open, so component coverage in Hydrolysis and Dew trails the native bridges. Pick one backend to start; you do not need the rest.

Workspace layout

You depend on the single waterui crate, which re-exports the rest through waterui::prelude::*. The table is a map for reading the source, not a list of dependencies to add.

CratePathRole
waterui/Facade: prelude, widgets, macro re-exports
waterui-internalsrc/Implementation behind the facade
waterui-corecore/View, Environment, AnyView, layout and accessibility contracts
waterui-layoutcomponents/foundation/layout/Stacks, grids, ScrollView, Spacer, absolute layout
waterui-textcomponents/foundation/text/Text, fonts, styled text
waterui-controlscomponents/foundation/controls/Button, Toggle, Slider, Stepper, TextField, Label
waterui-formcomponents/foundation/form/Form builder, Picker
waterui-navigationcomponents/foundation/navigation/Navigation stacks, tabs, split views, routing
waterui-shapecomponents/foundation/shape/Shape primitives
waterui-iconcomponents/foundation/icon/Icon system; icon sets live under components/icon/
waterui-graphicscomponents/visual/graphics/Colours, gradients, GPU surface, image analysis
waterui-image / waterui-svg / waterui-canvascomponents/visual/Images, SVG, canvas drawing
waterui-media / waterui-videocomponents/multimedia/Photos, audio, video playback
waterui-chart / waterui-mapcomponents/data/Charts and maps
waterui-barcodecomponents/codes/barcode/Barcode and QR rendering
waterui-particlecomponents/effects/particle/Particle systems
waterui-webview / waterui-chromiumcomponents/platform/Embedded web views and Chromium/CDP
waterui-assetscomponents/assets/runtime/Asset loading, asset!, bundles
waterui-macrosmacros/text!, #[form], #[preview], #[derive(Identifiable)]
waterui-localeutils/locale/Locale resolution and catalog!
namiutils/nami/The reactive engine behind waterui::reactive
filtrateutils/filtrate/GPU filter and effect runtime
waterui-testingtesting/Semantic UI tests over the accessibility tree
waterui-ffiffi/C ABI bridge; owned by the CLI, not by your app
waterui-clicli/The water command

Backends live under backends/: apple/ and android/ are git submodules, gtk/, hydrolysis/, hydrolysis_m3/, and dew/ are workspace crates, and core/ holds the shared backend contracts.

waterui-canvas is a workspace crate that the waterui facade does not re-export at this checkpoint.

Prerequisites

You should be comfortable with Rust ownership, traits, generics, and closures – if not, work through The Rust Programming Language first – and with a terminal, since water and cargo do the building. Having one platform toolchain installed (Xcode, Android Studio, or GTK4 development libraries) lets you run the examples on real hardware.

How to use this book

The eight parts build on each other: Getting Started (toolchain, CLI, first app, project layout), Core Concepts (View, reactivity, environment, modifiers), Building UIs (text, layout, controls, forms, lists, navigation), Rich Content (media, maps, web views, barcodes), Graphics and Effects (canvas, GPU surfaces, shaders, filters, particles, gradients), Advanced Patterns (animation, gestures, async, errors, accessibility, i18n, plugins), Developer Tools (the preview system), and Under the Hood (rendering, FFI, layout engine, backend architecture).

Most chapters contain runnable examples. Create a scratch project with water create "Scratch" --mode playground and paste as you read. Chapters that discuss workspace-only internals say so.

A taste of WaterUI

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

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

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

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

That is the whole user crate: a root view and a public app(env) constructor. The water CLI generates the FFI companion crate that native backends load, so you never write waterui_ffi::export!() yourself. The same code runs on every supported target without a #[cfg] branch.

Contributing

Continue to The Water CLI to install the toolchain and scaffold a project.

The Water CLI

In this chapter, you will:

  • Install the water command-line tool
  • Choose between playground and app project modes
  • Learn create, run, build, package, preview, and the maintenance commands

water is the single entry point for WaterUI projects: scaffolding, cross- compiling for mobile and embedded targets, launching on simulators and devices, rendering previews, and packaging for distribution. It wraps Xcode, Gradle, GTK4, and ESP-IDF build systems so you stay in Rust.

Installation

From crates.io:

cargo install waterui-cli

Or from a WaterUI checkout, which is what you want if you also work on the framework:

cargo install --path cli --locked

Verify:

water --help

Tip: While iterating on the CLI itself, cargo build -p waterui-cli is much faster than a full install. Reinstall when you need the new binary on your PATH.

Project modes

Playground mode

Playground mode manages every native backend project for you, inside the global build cache at ~/.water/build_cache/<absolute-project-path>/managed_backends/. You write Rust and nothing else.

water create "My Experiment" --mode playground

water create writes four files and initialises a git repository if the directory is not already inside one:

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

Playground projects auto-initialise their backends on every water run and re-scaffold templates so manifest changes (permissions, theme colours) are always picked up. Nothing platform-specific lands in your working tree.

Tip: Playground mode is what you want while following this book.

App project mode

App mode (the default) checks the native projects into your repository under backends/, so you can edit Xcode settings, add Swift or Kotlin sources, and wire the projects into CI.

water create "Production App" --backends apple,android
production-app/
  Cargo.toml
  Water.toml          # type = "app"
  src/lib.rs
  .gitignore
  backends/
    apple/            # Swift package, checked in
    android/          # Gradle project, checked in
    ffi/              # Generated FFI companion crate

Only the backends you asked for are scaffolded. water backend add gtk4 adds another one later.

Command reference

water create

# Interactive: prompts for name, bundle id, and backends
water create

# Playground
water create "Counter" --mode playground

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

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

# Link to a local WaterUI checkout (framework development)
water create "Dev App" --waterui-path ../waterui --backends apple
ArgumentDescription
nameDisplay name. The folder is its kebab-case form, the crate its snake_case form.
--bundle-idBundle identifier. Defaults to dev.waterui.<snake_case_name>.
--backendsComma-separated: apple, android, gtk4, hydrolysis, esp32. App mode only.
--modeapp (default) or playground.
--waterui-pathPath to a local WaterUI checkout.

--backends accepts aliases: ios/macos map to apple, gtk/linux to gtk4, and esp32s3/dew to esp32. In app mode with no --backends and no prompt, you get apple,android.

Host restrictions apply at scaffold time: GTK4 backends require a Linux host, and Hydrolysis requires macOS, Linux, or Windows.

water run

Builds, packages, and launches in one step. This is the command you will use most.

water run --platform ios
water run --platform ios --device "iPhone 16 Pro"
water run --platform android
water run --platform macos
water run --platform macos --backend hydrolysis
water run --platform linux                 # GTK4 by default
water run --platform windows
water run --platform esp32c3               # Dew firmware; --device qemu to emulate
water run --platform ios --logs debug
water run --platform ios --logs debug --native-logs

Omit --platform and water run targets the host: macos, linux, or windows.

ArgumentDescription
--platform, -pios, android, macos, linux, windows, web, esp32s3, esp32c3. Defaults to the host.
--backend, -bapple, android, gtk4, hydrolysis, dew. Overrides the platform default.
--device, -dDevice name or identifier. Defaults to the first booted or available device.
--pathProject directory (defaults to .).
--logsMinimum level to stream: error, warn, info, debug, verbose.
--native-logsInclude all native logs (NSLog, logcat), not just WaterUI’s.

Platform defaults and the combinations the CLI accepts:

PlatformDefault backendAlso accepts
iOSApple
macOSAppleHydrolysis
AndroidAndroid
LinuxGTK4Hydrolysis
WindowsHydrolysis
WebHydrolysis
ESP32-S3 / ESP32-C3Dew

For app-mode projects the default is the first configured backend in that priority order, so a project with only a Hydrolysis backend runs on Hydrolysis without --backend.

tracing::debug! output only reaches your terminal with --logs debug.

water build

Compile the Rust library for a target without packaging or launching – useful in CI and as the step Xcode and Gradle call. App-mode projects only; playground projects go through water run and water package.

water build --platform ios
water build --platform ios-simulator --arch arm64
water build --platform android --arch arm64
water build --platform macos --release
water build --platform macos --output-dir ./out
water build --platform esp32s3 --release
ArgumentDescription
--platform, -pios, ios-simulator, android, macos, linux, windows, esp32s3, esp32c3.
--backend, -bapple, android, gtk4, hydrolysis, dew.
--arch, -aarm64, x86-64, armv7, x86. Apple and Android backends only.
--releaseOptimised build.
--pathProject directory (defaults to .).
--output-dirCopy the built library here. Apple and Android backends only.

water package

Produce installable artifacts. --backend is required.

water package --platform ios --backend apple
water package --platform ios --backend apple --release --distribution
water package --platform android --backend android --arch arm64
water package --platform android --backend android --arch arm64,x86-64
ArgumentDescription
--platform, -pios, ios-simulator, android, macos, linux, windows, web.
--backend, -bRequired: apple, android, gtk4, hydrolysis.
--releaseOptimised build.
--distributionPackage for store submission.
--archAndroid architectures, comma-separated: arm64, x86-64, armv7, x86. Required for Android.
--pathProject directory (defaults to .).

ESP32 firmware is flashed by water run --platform esp32s3|esp32c3, not packaged.

water preview

Render a view function to PNG without launching the app.

water preview my_card --platform macos --path ./app
water preview dashboard --platform ios --frame 390x844
water preview login_screen --output login.png
water preview 'text("inline").bold()' --expr

The target is a #[preview] function path, or – with --expr – a WaterUI expression that evaluates to impl View:

use waterui::prelude::*;

#[preview]
fn my_card() -> impl View {
    text("Hello Preview!")
}
ArgumentDescription
target#[preview] function name or path, or an expression with --expr.
--exprTreat the target as an expression rather than a function path.
--platform, -pios, macos, android. Defaults to the native preview platform.
--backendapple, android, hydrolysis.
--themematerial3. Hydrolysis previews only.
--frame, -fWIDTHxHEIGHT (default 375x667).
--output, -oOutput file (default preview.png).
--scenario / --output-dirHydrolysis scenario TOML for interaction capture, and where to write its frames.
--pathProject directory (defaults to .).

Two subcommands share the same surface: water preview test runs semantic assertions against a preview (add --all to sweep every #[preview] in the crate), and water preview perf profiles it through the offscreen GPU pipeline. Preview symbols are waterui_preview_<crate_name>_<function_name>, so names must be unique within a crate.

water doctor

water doctor
water doctor --fix

The doctor probes the Apple toolchain (Xcode, iOS and macOS SDKs, installed simulators), the Rust toolchain and cross-compilation targets, the Android SDK and its components (platform-tools/adb, SDK platforms, build-tools, NDK, Rust Android targets, and at least one device or AVD), host tooling (CMake, Java, Kotlin, the wasm32-unknown-unknown target, wasm-pack), Linux system packages and GTK4 on Linux hosts, and sccache.

Checks are reported as [fixable] or [manual]. --fix installs the fixable ones; anything else prints manual instructions. Checks for platforms your host cannot serve are skipped rather than failed.

Tip: Run water doctor first whenever a build fails in a way that does not look like your code.

water devices

water devices
water devices --platform ios
water devices --platform android
water devices --platform esp32          # ESP32 boards on serial ports
water --json devices --platform all     # --json is a global flag

Output lists each device’s name, identifier, and state.

water device

Drive a running app for automation and screenshots:

water device capture --id <udid>
water device tap --id <udid> --x 100 --y 200
water device swipe --id <udid> --from 100,600 --to 100,200
water device text --id <udid> --input "hello"
water device describe --id <udid>       # dump on-screen UI elements

water backend

App-mode projects only:

water backend list
water backend add gtk4
water backend remove android --yes

Accepted names are apple, android, gtk4, hydrolysis, and esp32.

water clean

water clean                             # all backends in this project
water clean --backend apple
water clean --recursive --path ~/projects
water clean --recursive --yes
water clean --global-cache --yes        # wipe ~/.water/build_cache

--backend takes apple, android, gtk4, hydrolysis, or all (the default). In recursive mode the CLI finds every directory holding a valid Water.toml and clears each playground’s managed cache or each app project’s target/.

water gc

water gc build-cache

Removes stale entries from ~/.water/build_cache, keeping the project at --path (default .) marked active.

water inspector

Attach the inspector app to a running WaterUI runtime:

water inspector --target 127.0.0.1:9229

Next steps

Continue to Installation and Setup to configure a platform toolchain, or jump to Your First App if water doctor already passes.

Installation and Setup

In this chapter, you will:

  • Install Rust and the cross-compilation targets you need
  • Set up one platform toolchain: Apple, Android, or Linux
  • Verify the result with water doctor
  • Run a generated project end to end

You need one platform toolchain to get started. Pick the one you already have tooling for and skip the rest; water doctor skips checks your host cannot serve.

Step 1: install Rust

WaterUI requires Rust 1.95 or later on edition 2024. Install with rustup:

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

Confirm the version, and update if it is older:

rustc --version
rustup update stable

Cross-compilation targets

Add only the targets you plan to build for. water doctor --fix installs missing ones for you.

# iOS device
rustup target add aarch64-apple-ios

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

# Android
rustup target add aarch64-linux-android      # modern devices
rustup target add x86_64-linux-android       # Intel/AMD emulators
rustup target add armv7-linux-androideabi    # older 32-bit devices
rustup target add i686-linux-android

# Web (Hydrolysis backend)
rustup target add wasm32-unknown-unknown

ESP32 firmware targets are installed through the Espressif toolchain rather than plain rustup; water doctor and water run --platform esp32c3 report what is missing.

Step 2: editor setup

Any editor with Rust support works. For Visual Studio Code:

RustRover, Zed, and Helix are all fine alternatives.

Step 3: platform toolchains

Apple (iOS / macOS)

The Apple backend is a Swift 6.3 package with iOS 26 and macOS 26 deployment targets, so you need Xcode 26 or later on a Mac.

Install Xcode from the Mac App Store, then:

xcode-select --install
sudo xcodebuild -license accept

Verify:

xcodebuild -version
xcrun simctl list devices available

Warning: Skipping the licence acceptance produces a build failure whose error message never mentions the licence.

Android

The Android runtime compiles against SDK 37 with a minimum of API 26 (Android 8.0), NDK 29, and Java 21.

Install Android Studio, which bundles the SDK, NDK, and emulator. Then:

  1. Settings > Languages & Frameworks > Android SDK.
  2. Under SDK Platforms, install a recent API level.
  3. Under SDK Tools, install NDK (Side by side) and Android SDK Command-line Tools.

Export the paths from your shell profile:

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, and create an AVD if you have none:

adb --version
emulator -list-avds

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

Linux (GTK4)

WaterUI needs more than GTK4 itself: Pango, Wayland, ALSA, VA-API, GBM, XCB, Clang, and Fontconfig development packages all appear in the build graph.

# Debian / Ubuntu
sudo apt-get install -y pkg-config libgtk-4-dev libpango1.0-dev libwayland-dev \
  wayland-protocols libasound2-dev libva-dev libgbm-dev libxcb1-dev \
  libclang-dev libfontconfig-dev

# Fedora
sudo dnf install -y pkgconf-pkg-config gtk4-devel pango-devel wayland-devel \
  wayland-protocols-devel alsa-lib-devel libva-devel mesa-libgbm-devel \
  libxcb-devel clang-devel fontconfig-devel

# Arch
sudo pacman -S --needed pkgconf gtk4 pango wayland wayland-protocols \
  alsa-lib libva mesa libxcb clang fontconfig

water doctor --fix runs the right command for your package manager. Verify manually with:

pkg-config --modversion gtk4

The GTK4 backend runs on Linux hosts only. On macOS or Windows, use --backend hydrolysis for a desktop build instead.

Step 4: install the Water CLI

cargo install waterui-cli
water --help

To track the development branch instead, clone the repository and install from the checkout:

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

Step 5: verify with water doctor

water doctor

Every check is reported as passing, [fixable], [manual], or skipped:

Checking development environment...
  ✓ Xcode
  ✓ iOS SDK
  ✓ iOS Simulator SDK
  ✓ iOS Simulators
  ✓ macOS SDK
  ✓ Rust toolchain
  ⚠ Android SDK (Android SDK is missing) [fixable]
  ✓ Host CMake
  ✓ Java
  ✓ sccache

Install everything marked [fixable]:

water doctor --fix

Items marked [manual] print installation instructions instead. Blocked Android component checks resolve once the Android SDK check passes, so fix the SDK first and re-run.

Step 6: list your devices

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

Android
  ○ Pixel_9 (emulator)

macOS
  ● Current Machine

A filled circle means booted or connected; an open circle means water run will launch it for you. Add --platform esp32 to list ESP32 boards on serial ports.

Step 7: run a generated project

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

Substitute --platform ios or --platform android for a simulator or emulator. The generated src/lib.rs is a demo screen: a stepper-driven counter, a derived form, a slider, and progress indicators, all rendered with native widgets. If it launches, your environment is complete.

Optional: build caching with sccache

Cross-compiling for several architectures recompiles the same crates many times. sccache caches those results.

brew install sccache      # macOS
cargo install sccache     # Linux

The CLI detects sccache automatically and warns when it is absent:

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

Troubleshooting

“No iOS simulators available” – download a runtime in Xcode under Settings > Platforms.

“Android emulator not found” – check ANDROID_HOME and create at least one AVD (see the Android section above).

“GTK4 not found” – install the Linux development packages listed above, or run water doctor --fix.

water: command not found – put the cargo bin directory on your PATH:

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

Next steps

Continue to Your First App to build a counter and meet the patterns you will use in every WaterUI project.

Your First App

In this chapter, you will:

  • Build a counter application from an empty src/lib.rs
  • See how views, stacks, and reactive bindings fit together
  • Wire buttons to state through the State<T> extractor
  • Run the same code on macOS, iOS, Android, and Linux

Create the project

water create "Counter" --mode playground
cd counter

You get four files:

counter/
  Cargo.toml
  Water.toml
  src/lib.rs
  .gitignore

The generated src/lib.rs is a demo screen. Replace it as you work through the steps below.

Step 1: a minimal view

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

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

pub fn app(env: Environment) -> App {
    App::new(main, env)
}
  • use waterui::prelude::* brings in View, Environment, Binding, State, the layout and control constructors, and the macros.
  • fn main() -> impl View is the root view. &'static str implements View, so a bare literal renders as text.
  • pub fn app(env: Environment) -> App is the entry point. The backend hands you an Environment carrying theme tokens, locale, and platform services.

Your crate stops there. The water CLI generates a separate FFI companion crate – in the managed cache for playgrounds, at backends/ffi/ for app projects – which depends on your crate and exports the C entry points native backends load. Never write waterui_ffi::export!() in src/lib.rs.

water run --platform macos

Step 2: styled text

text() builds a Text view you can configure by chaining:

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

.title(), .headline(), .sub_headline(), .body(), .caption(), and .footnote() select semantic font presets that resolve against the platform’s type scale. Each one replaces the whole font, so apply the preset first and weight or size adjustments after – .bold().title() discards the bold.

.size(24.0), .italic(true), and .underline(true) accept signals as well as plain values, so any of them can be driven by a Binding.

Use text() for fixed strings and the text! macro whenever the content depends on reactive state.

Step 3: layout with stacks

vstack arranges children top to bottom, hstack left to right, and both take a tuple so each child keeps its own type – no boxing, no common trait object.

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

Nest them freely: an hstack inside a vstack is how most real layouts get built.

Step 4: reactive state

Binding<T> holds mutable state. Views that read it update when it changes – no refresh call, no diff pass.

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

    vstack((
        text("Counter App").title().bold(),
        text!("Count: {counter}"),
    ))
}
  • Binding::i32(0) creates a Binding<i32>. There are typed constructors for u32, u64, usize, i32, i64, isize, f32, f64, and bool. For anything else use Binding::container(value). There is no Binding::new.
  • text!("Count: {counter}") interpolates named placeholders that match a binding in scope, or an explicit alias such as text!("Count: {n}", n = counter). It subscribes to counter and rewrites only that string when the value changes.

Important: never call .get() on a signal inside a view body. That reads the value once and severs the dependency. Derive instead: text! for text, .map() / .zip() for computed values, and pass the resulting signal into whichever component input needs it.

Step 5: buttons and actions

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

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

Three pieces:

  1. button("Increment") takes an impl IntoLabel. A string literal becomes a semantic label that feeds both the visible text and the accessibility tree, so every button carries a label by construction.
  2. .action(...) runs on activation. Each State<T> parameter pulls a value of that type out of the button’s environment; repeated parameters of the same type are matched in injection order.
  3. .state(&counter) injects a value. Chain one .state(...) per State<T> parameter.

*c.get_mut() += 1 mutates through a guard and notifies watchers on drop – prefer it over c.set(c.get() + 1). Reading with .get() inside an action closure is fine; the rule against .get() applies to view bodies.

Beyond four injected states, bundle the values into one #[derive(Clone)] struct and inject that instead of stacking parameters.

Button styles

button("Submit").bordered_prominent().action(|| { /* ... */ });  // primary
button("Cancel").bordered().action(|| { /* ... */ });            // secondary
button("Learn more").link().action(|| { /* ... */ });            // hyperlink
button("Skip").plain().action(|| { /* ... */ });                 // no chrome
button("Subtle").borderless().action(|| { /* ... */ });

Style is an attribute, not a separate type: the same Button renders as a platform-appropriate control in each style. .disabled(signal) takes a bool signal and switches the button to its platform disabled appearance while reporting the state to assistive technology.

Async actions

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

Step 6: spacers

spacer() expands to absorb the free space in its stack:

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

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

The two spacers in the vstack pin the title to the top and the buttons to the bottom; the one in the hstack pushes the buttons to opposite edges. Use spacer_min(length) when you want a guaranteed minimum gap.

The complete counter

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

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

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

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

.padding() inserts platform-appropriate insets around the whole stack. #[preview] makes the function addressable without launching the app:

water preview main --output counter.png

Running on other platforms

water run --platform macos
water run --platform ios
water run --platform android
water run --platform linux     # GTK4

The buttons render as UIKit controls on iOS, Material components on Android, and GTK4 widgets on Linux, from the same source with no #[cfg] branches.

Next steps

Try adding a “Reset” button, or a second binding that controls the increment step. Then continue to Project Structure and Water.toml to see how a project is organised and configured.

Project structure and Water.toml

In this chapter, you will:

  • Compare the playground and app project layouts
  • Configure every section of the Water.toml manifest
  • Add assets and custom fonts
  • Decide when to switch from playground to app mode

Playground layout

water create --mode playground writes four files and initialises a git repository if you are not already inside one:

my-app/
  Cargo.toml           # Rust crate manifest
  Water.toml           # WaterUI project manifest
  src/lib.rs           # Your application code
  .gitignore

Generated native projects live outside your tree, in the global managed cache:

~/.water/build_cache/<absolute-project-path>/managed_backends/
  apple/               # Swift package
  android/             # Gradle project
  gtk4/                # GTK4 backend crate
  hydrolysis/          # Hydrolysis backend crate
  esp32/               # ESP32 firmware crate
  ffi/                 # FFI companion crate
  preview_ffi/         # Preview companion crate

Only the backends a command actually needs are generated. Every water run re-scaffolds the templates, so Water.toml changes – permissions, theme colours, the web engine – reach the native projects without a manual step; build outputs inside the cache survive that regeneration.

Two rules follow from the split: a playground manifest must have no [backends] section, and [permissions] is available only in playground mode.

Tip: To reclaim disk space across abandoned playgrounds, run water gc build-cache or water clean --global-cache --yes.

App layout

App mode checks the native projects into your repository, so you can edit Xcode settings, add Swift or Kotlin sources, and wire them into CI:

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

Only the backends you passed to --backends (or added later with water backend add) appear. [backends] in Water.toml tracks them, and permissions move to the native files – Info.plist and AndroidManifest.xml.

Water.toml

[package]

[package]
type = "playground"          # or "app"
name = "My Application"
bundle_identifier = "dev.waterui.myapp"
FieldTypeDescription
type"playground" or "app"Project mode.
namestringDisplay name shown by the OS.
bundle_identifierstringReverse-domain identifier: iOS bundle ID and Android application ID.
assets_pathstringAssets directory relative to the project root. Defaults to "assets"; omitted from the file at the default.
accessorybooleanBuild a headless macOS accessory app: no dock icon, no menu bar. Defaults to false.

[backends]

App projects only. Populated by water create --backends and water backend add:

[backends]
path = "backends"            # base directory, relative to the project root

[backends.apple]
[backends.android]
[backends.gtk4]
[backends.hydrolysis]

[backends.esp32]
chip = "esp32c3"
panel_width = 240
panel_height = 240
band_height = 16

The ESP32 entry is the single source of truth for the selected chip; the CLI derives the target triple and QEMU machine from it.

Warning: [backends] in a playground manifest, or [permissions] in an app manifest, makes the CLI reject the project outright. Each mode has one configuration path.

webview_backend

Selects the engine behind the WebView component:

webview_backend = "default"   # default | system | wpe | cef
  • default – bundled WPE on Linux, the system engine elsewhere.
  • system – the platform web view (WebKitGTK on Linux).
  • wpe – WaterUI’s bundled WPE WebKit runtime (Linux).
  • cef – WaterUI’s bundled Chromium Embedded Framework runtime (macOS, Linux, Windows), independent of the rendering backend. The Dew backend excludes CEF.

Setting this alone never adds a runtime: the CLI links an engine only if your app actually depends on waterui-webview.

waterui_path

waterui_path = "../waterui"

Points every generated backend at a local WaterUI checkout instead of published crates. water create --waterui-path sets it for you, and a CLI built from a local checkout sets it automatically.

[permissions]

Playground mode only. Declare a permission once and the CLI writes the matching Info.plist key and AndroidManifest.xml entry on the next water run:

[permissions.camera]
enable = true
description = "Scan barcodes on product labels"

[permissions.location]
enable = true
description = "Show stores near you on the map"

[permissions.microphone]
enable = true
description = "Record voice notes"

Each entry takes enable (boolean) and description (the text the system dialog shows the user; vague wording gets apps rejected from stores).

The permission keys are a closed set: internet, camera, microphone, location, coarse_location, storage, write_storage, photo_library, contacts, calendars, bluetooth, bluetooth_admin, vibrate, and wake_lock.

[theme]

Optional cross-platform colour slots, used to seed native launch screens and backend defaults:

[theme]
background = "#101014"
surface = "#1B1B20"
foreground = "#F2F2F7"
accent = "#3B82F6"

The available slots are background, surface, surface_variant, border, foreground, muted_foreground, accent, and accent_foreground.

[app.crates]

App mode only. Overrides the generated crate names when the defaults collide with something in your workspace:

[app.crates]
ffi = "myapp_ffi"
gtk = "myapp_gtk"
hydrolysis = "myapp_hydrolysis"

Cargo.toml

water create generates a plain library crate. The FFI companion owns the staticlib/cdylib output, so your crate stays a normal Rust library:

[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", "flow-markdown"] }

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

The scaffold sets default-features = false and names features explicitly, so non-wasm targets get assets, media (which implies video), and flow-markdown. The waterui crate’s own defaults are ["gpu", "assets", "media", "flow-markdown"]; add "gpu" to the list when you want GPU-backed drawing, filters, or SVG. Opt-in features include webview, chart, barcode, map, particle, and navigation-restoration.

Custom fonts

Fonts are declared in Cargo metadata so the CLI can bundle them for every backend:

[[package.metadata.waterui.assets.font]]
name = "Inter"

[[package.metadata.waterui.assets.font]]
name = "MyBrandFont"
local_path = "assets/fonts/MyBrandFont.ttf"

[[package.metadata.waterui.assets.font]]
name = "lucide"
remote_path = "https://github.com/lucide-icons/lucide/releases/download/0.562.0/lucide-font-0.562.0.zip"
required-feature = "webfont"

Give a local_path (relative to the crate root), a remote_path the CLI downloads on demand, or neither – names in the built-in registry (Inter, Roboto, JetBrainsMono, FiraCode, SourceCodePro, and the Noto Sans CJK families) resolve automatically. required-feature skips the font unless that feature is enabled on the declaring package, which is how icon-set crates ship their fonts without forcing them on every consumer.

The CLI scans your dependencies’ metadata too, so a font declared by a library crate is bundled without any change to your manifest.

Assets

Create the assets directory yourself; water create does not. Everything under assets/ (or whatever package.assets_path names) is discovered recursively and classified by file extension:

assets/
  Icon.png             # app icon
  logo.png             # ImageAsset
  intro.mp4            # VideoAsset
  theme.ttf            # FontAsset
  config.json          # DataAsset
  model.onnx           # LargeFileAsset (memory-mapped)
  icons/
    settings.png       # nested directories become nested modules

Extensions map to types: images (.png, .jpg, .webp, .avif, …), video, audio, fonts, large binaries (.onnx, .safetensors, .gguf, …), and everything else as data. Directory structure becomes module structure in the generated asset code, reached through the asset! macro.

A file named Icon.<image ext> at the top level of the assets root is the application icon. Declaring two of them, or pointing the name at a non-image, fails the build rather than silently picking one.

The application entry point

Every WaterUI crate needs two functions.

A root view returning impl View:

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

The name main is a convention; anything works.

A public app constructor:

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

App::new opens one window immediately. Give it a title, or install environment values before handing it off:

pub fn app(mut env: Environment) -> App {
    env.install(Theme::new().color_scheme(ColorScheme::Dark));
    App::new(main, env).title("My Counter App")
}

For several windows, build them yourself. The first window is the main one:

use waterui::app::App;
use waterui::prelude::*;
use waterui::window::{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::Closed), settings_view),
        ],
        env,
    )
}

A window’s WindowState binding controls whether it is open, so a window created as Closed appears when you flip its state to Normal.

There is no third piece. The FFI companion crate is generated and maintained by the CLI; waterui_ffi::export!() does not belong in your source.

A worked example

my-app/
  Cargo.toml
  Water.toml
  src/
    lib.rs             # main() and app()
    views/
      mod.rs
      home.rs
      settings.rs
  assets/
    Icon.png
    config.json
    images/
      logo.png
# Water.toml
[package]
type = "playground"
name = "My App"
bundle_identifier = "dev.waterui.myapp"
// src/lib.rs
use waterui::app::App;
use waterui::prelude::*;

mod views;

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

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

When to switch modes

Stay in playground mode for learning, prototypes, and small projects – anything where native build settings are not the point.

Move to app mode when you need custom native build settings, Swift or Kotlin code of your own, CI that drives Xcode or Gradle directly, store submission, or per-backend dependency control.

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

Next steps

Continue to The View System to learn how the View trait works, how views compose, and how the framework turns Rust types into platform-native UI.

The view system

In this chapter, you will:

  • Read the View trait and understand why body consumes self
  • Build views as plain functions and as structs
  • Know which standard Rust types are already views
  • Tell raw (leaf) views apart from composite views, and know when AnyView is worth its cost

Every piece of UI in WaterUI – a label, a button, a card, a whole page – is a View. A view is a description, not a widget: you build a value that says what the screen should contain, and the backend turns it into native widgets.

The View trait

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

body consumes the view and returns another view. The framework calls it recursively until it reaches a raw view – a leaf the backend knows how to render, such as Str, Color, or ButtonConfig.

Three consequences follow from that signature:

  • self by value. Views are cheap descriptors, created and consumed once. There is no persistent widget object to mutate.
  • &Environment. Every view receives the ambient context: theme tokens, locale, injected services. See the Environment chapter.
  • 'static. A view owns its data and cannot hold borrowed references. Share mutable data through a Binding instead.

Function views

Any FnOnce() -> V where V: View is itself a view, so the shortest component is a function:

use waterui::prelude::*;

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

Function views compose naturally and need no boilerplate:

use waterui::prelude::*;

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

Two things are happening here. text!("Count: {count}") captures the count binding by name and re-renders only that label when the value changes – no watch, no manual subscription. And .state(&count) injects the binding into the button’s environment so the handler can pull it back out with the State<T> extractor; .get_mut() returns a guard that writes back on drop, which is the idiomatic way to mutate a binding.

Start with function views. Most components never need to be anything else.

Struct views

Reach for a struct when a component has several named parameters or wants builder methods:

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

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

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

Destructuring self up front is the usual first line: body takes ownership, so you may as well move the fields out.

Types that are already views

You do not have to wrap everything:

TypeBehavior
()Renders nothing. A raw view, useful as a placeholder.
&'static str, String, Cow<'static, str>Convert to Str and render as text.
Option<V: View>Renders the inner view, or nothing for None.
Result<V: View, E: View>Renders whichever side is present.
(V,)A one-element tuple renders its content.
FnOnce() -> VCalls the closure and renders the result.

Option<V> is the cheapest conditional. For if/else-if/else, use when(...).or(...).otherwise(...) from waterui::widget::condition rather than branching into AnyView.

Note that a signal is not a view: Computed<T> does not implement View. Feed reactive values into signal-aware inputs (text!, .opacity(...), .background(...)) instead of trying to render a signal directly.

Passing several children

Layout containers do not take one child, they take a TupleViews:

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

It is implemented for tuples up to 15 elements, and for Vec<V> and [V; N]:

use waterui::prelude::*;

// Heterogeneous: every element may be a different type
vstack((
    text!("Title"),
    button("Click me").action(|| {}),
    Color::red().height(2.0),
));

// Homogeneous: one element type, so erase to AnyView if the types differ
let rows: Vec<_> = (0..5).map(|i| text!("Row {i}").anyview()).collect();
vstack(rows);

For a collection whose membership changes at runtime, neither of these is right – use ForEach or List so the framework can diff by identity. That is covered in Reactive state.

AnyView: type erasure

Rust requires both arms of an if to have the same type. AnyView boxes a view so heterogeneous arms unify:

use waterui::prelude::*;

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

AnyView::new unwraps a nested AnyView, so erasing twice costs nothing extra. The wrapper also supports inspection, which backends and tests use:

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

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

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

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

Each AnyView is a heap allocation plus dynamic dispatch. Prefer when(...).otherwise(...), which keeps the concrete types.

Raw views and composite views

Raw views are leaves. Their body() wraps the value in Native<T>, which the renderer intercepts before recursing, and the backend maps them onto a platform widget. Str, Color, Spacer, Divider, and configuration structs such as ButtonConfig are raw views.

The raw_view! macro implements NativeView and View for a type, optionally declaring how it stretches:

raw_view!(MyCustomLeaf);                  // content-sized (StretchAxis::None)
raw_view!(Color, StretchAxis::Both);      // fills available space
raw_view!(Spacer, StretchAxis::MainAxis); // fills along the stack axis

Composite views are everything else: their body() returns other views, and the framework expands them until only raw views remain. Every function view and every hand-written impl View is composite.

If it helps, think HTML: raw views are <input> and <img>, composite views are your own components.

Hookable views

Some raw views can be restyled globally without touching their call sites. Such a view implements ConfigurableView, and its configuration implements ViewConfiguration:

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 such a view’s body() runs, it extracts its Config, looks for Hook<Config> in the environment, and hands the configuration to the hook if one is installed; otherwise it falls through to the default native rendering. A theme is exactly a bundle of hooks for ButtonConfig, ToggleConfig, and friends – see Hooks.

The configurable! macro writes that boilerplate:

// Content-sized
configurable!(Button, ButtonConfig);

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

// Stretch axis derived from the configuration
configurable!(Progress, ProgressConfig, |config| match config.style {
    ProgressStyle::Linear => StretchAxis::Horizontal,
    ProgressStyle::Circular => StretchAxis::None,
});

// Resolve the configuration against the environment before it reaches the backend
configurable!(Toggle, ToggleConfig, StretchAxis::Horizontal, resolve |config, env| config.resolve(env));

That last form is how a control folds environment state – an enclosing .disabled(...) scope, for instance – into the configuration the backend receives.

You will rarely call configurable! in application code; it is for component libraries and backends.

Putting it together

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 {
    vstack((
        header("Shopping List"),
        ItemRow { label: "Apples".into(),  count: Binding::i32(3), highlighted: true  },
        ItemRow { label: "Bananas".into(), count: Binding::i32(7), highlighted: false },
    ))
}

Add an “Oranges” row and the layout absorbs it with no other change.

Next: Reactive state, where Binding and Computed make those counts change on screen.

Reactive state

In this chapter, you will:

  • Use Binding<T> for mutable state and Computed<T> for derived state
  • Learn the golden rule that keeps your UI updating, and the map/zip combinators that replace .get()
  • Format reactive text with s! and text!
  • Render changing collections with List<T>, ForEach, and #[derive(Identifiable)]

You change the data; the UI follows. WaterUI does that with fine-grained signals: a change to one value updates exactly the labels, colors, and attributes that read it, without rebuilding the surrounding view tree.

The reactivity engine is re-exported as waterui::reactive, and its main types (Binding, Computed, Signal, SignalExt) are in the prelude.

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 and returns a guard. Dropping the guard unsubscribes.
  • Context<T> carries the new value plus metadata such as an animation hint; ctx.into_value() unwraps it.

Everything reactive implements Signal, which is what makes the combinators below universal: any signal can be mapped, zipped, or combined with any other.

Binding<T>: mutable state

Binding<T> is a signal you can also write to.

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() for everything else
let name = Binding::container(String::from("Alice"));
let title = Binding::container(Str::from("Welcome"));

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

The typed constructors exist for i32, i64, isize, u32, u64, usize, f32, f64, and bool. Everything else – String, Str, Vec<T>, Option<T>, your own types – goes through Binding::container(value). Note the type parameter belongs to Binding, not to container: write Binding::<Option<String>>::container(None).

Writing

let count = Binding::i32(0);

count.set(42);

// Preferred for in-place mutation: the guard writes back when it drops
*count.get_mut() += 1;

// Arithmetic and bitwise helpers
count.add_assign(5);
count.mul_assign(3);
count.bitor_assign(0x10);

// Into conversion at the call site
let name = Binding::container(String::from("Alice"));
name.set_from("Bob");

// Extend a string-like or vec-like binding
name.append(" Smith");

*binding.get_mut() += 1 is the house idiom for mutating a binding in a handler. Keep it a one-liner: the guard commits the write and notifies watchers when it drops, so binding it to a variable delays the notification until the end of the scope.

For multi-step edits, with_mut is better – it mutates the container in place and notifies once, without the intermediate clone:

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

items.with_mut(|vec| {
    vec.push("c".into());
    vec.sort();
});

take() moves the value out and leaves T::default() behind.

The golden rule

Never call .get() on a signal inside a view body.

.get() returns a plain value – a snapshot with no subscription attached. The view renders once with that number and never hears about the next one. This is the single most common cause of “my UI is not updating”.

// BAD: n is a plain i32, the label freezes at its initial value
fn bad(count: Binding<i32>) -> impl View {
    let n = count.get();
    text!("Count: {n}")
}

// GOOD: text! subscribes to the binding it names
fn good(count: Binding<i32>) -> impl View {
    text!("Count: {count}")
}

// GOOD: derive a new signal and hand it to a signal-aware input
fn also_good(count: Binding<i32>) -> impl View {
    let is_high = count.map(|n| n > 10);
    text!("Count: {count}").opacity(is_high.map(|high| if high { 1.0 } else { 0.5 }))
}

.get() is correct everywhere a view body is not running: event handlers, watcher closures, async tasks, and tests.

Computed<T>: derived, read-only

Binding is state you own; Computed<T> is a type-erased read-only signal, useful when you need to store a signal in a struct field or accept one across an API boundary:

let count = Binding::i32(5);

let computed: Computed<i32> = count.computed();
let always_42 = Computed::constant(42);
let zero: Computed<i32> = Computed::default();

Most component inputs take impl IntoComputed<T> or impl Signal<Output = T>, so a Binding, a mapped signal, or a plain value all work without an explicit conversion.

Deriving signals

SignalExt is implemented for every signal. Two combinators carry most of the weight.

map transforms one signal:

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

zip combines two, emitting whenever either changes:

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

Chain zip for more inputs – a.zip(&b).zip(&c).map(|((a, b), c)| ...). If you are past three, the values probably belong in a struct with #[derive(Project)], covered below.

The rest of SignalExt is a shorthand layer over map, grouped by the output type you are working with:

GroupMethods
Comparisonequal_to, condition, gt, lt, ge, le
Booleannot, and, or, then_some, select
Numericabs, negate, sign, is_positive, is_negative, is_zero
Optionis_some, is_none, unwrap_or, unwrap_or_else, unwrap_or_default, some_equal_to, flatten, map_some, and_then_some
Resultis_ok, is_err, ok, err, unwrap_or_result, map_ok, map_err
String-likeis_empty, str_len, contains
Plumbingmap_into, inspect, distinct, cached, computed, with
Timing (timer feature, on by default)debounce, throttle

Two of those are worth calling out. distinct() suppresses emissions when the mapped value did not actually change – put it after an expensive map so downstream work does not re-run. And debounce(Duration) waits for a pause in input, which is what a search-as-you-type field wants, while throttle(Duration) caps the update rate for scroll and resize handlers.

use std::time::Duration;

let query = Binding::container(String::new());
let debounced = query.debounce(Duration::from_millis(300));

Binding-specific helpers

Binding adds helpers that stay writable, unlike the read-only SignalExt versions:

let dark_mode = Binding::bool(false);

dark_mode.toggle();
let light = dark_mode.reverse();      // Binding<bool>, always the opposite
let light2 = !dark_mode.clone();      // same thing via the Not operator
let theme = dark_mode.bidirectional_select("dark", "light");

let volume = Binding::container(0.5f32);
let checked = volume.range(0.0..=1.0);  // rejects out-of-range writes
let clamped = volume.clamp(0.0..=1.0);  // clamps out-of-range writes

let age = Binding::i32(25);
let valid = age.filter(|&a| (0..=150).contains(&a));

Use range for validation (bad writes are dropped) and clamp for correction (bad writes are pulled into bounds).

Binding::mapping builds a two-way derived binding when a simple helper is not enough:

let celsius = Binding::f64(0.0);

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

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

Constants

constant(value) lifts a plain value into the signal graph. Its watch() is a no-op, so it costs nothing:

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

Lazy::new(closure) is the deferred version: the closure runs on first get() and the result is cached.

s!: reactive string formatting

s! produces a signal of String, capturing reactive variables from scope by name:

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

let greeting = s!("Hello {name}, you are {age} years old");

Named placeholders are captured automatically; positional {} placeholders need explicit arguments (s!("Value: {}", count)); mixing the two forms is a compile error. Either form supports at most four reactive inputs.

text!: localized reactive text

text! builds a Text view and routes the string through the i18n catalog:

// Looked up in i18n/*.toml
text!("Hello, World!");

// Reactive placeholder captured from scope
let name = Binding::container("Alice".to_string());
text!("Hello, {name}");

// Plural: {#count} marks the plural source
let count = Binding::i32(3);
text!("I have {#count} apple");

// Context disambiguation
text!("Right" @ "direction");

// Explicit alias when the local name is not the slot name
text!("Hello, {name}", name = current_user());

Placeholder names are translation slot keys, which is why text! accepts identifiers and explicit aliases but not arbitrary expressions.

Translations are TOML files under i18n/:

# 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}个苹果" }

Use text() for static strings and text! for anything reactive or localized.

#[derive(Project)]

A Binding<Struct> is awkward to hand to child views one field at a time. Project decomposes it into per-field bindings that stay connected in both directions:

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

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

let projected: PersonProjected = person.project();
// projected.name: Binding<String>
// projected.age:  Binding<u32>

projected.name.set_from("Bob");
assert_eq!(person.get().name, "Bob");

The derive generates a <Name>Projected struct with one Binding<T> per field, each built on Binding::mapping. Tuples implement Project natively:

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

This is what makes form editing pleasant: project the model once, pass each field binding to its input control.

Reactive collections

For a set of rows whose membership changes – todos, chat messages, search results – Binding<Vec<T>> is the wrong tool. It tells watchers that the vector changed but not how, so the whole list has to be rebuilt.

List<T> is a reactive vector that reports insertions, removals, and reorderings:

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

let items: List<String> = List::new();

items.push("first".to_string());
items.insert(1, "middle".to_string());
let removed = items.remove(0);
let last = items.pop();
items.sort();

// Swap the whole contents in one diffed update instead of N pushes
let previous = items.replace(vec!["a".into(), "b".into()]);

let snapshot: Vec<String> = items.snapshot();
let len = items.len();

List<T> is reference-counted: cloning gives you a second handle onto the same data, and writes through any handle notify every watcher. The Collection trait it implements also supports range-scoped watching (items.watch(1..4, |ctx| ...)), which is how virtualized backends observe only the visible window.

Note: the prelude also exports a List – the list component from waterui::component::list. An explicit use waterui::reactive::collection::List; shadows the glob import, so the two coexist, but keep the distinction in mind: one is data, the other is a view.

Rendering with ForEach

ForEach is a collection of views, not a view: it implements Views, so a container has to consume it. Lazy::for_each(data, generator) is the shorthand for Lazy::vstack(ForEach::new(data, generator)), and Lazy::hstack is the horizontal form. The list component has its own List::for_each.

use waterui::prelude::*;
use waterui::component::lazy::Lazy;
use waterui::reactive::collection::List;
use waterui::Identifiable;

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

fn todo_list(todos: List<TodoItem>) -> impl View {
    Lazy::for_each(todos, |item| {
        hstack((
            text(item.title),
            Spacer::flexible(),
            toggle("Completed", &item.completed),
        ))
    })
}

Items must implement Identifiable so the framework can match rows across updates and move, insert, or remove only what changed. #[derive(Identifiable)] writes the impl for you: mark exactly one field with #[id], and its type must be Hash + Ord + Clone (the generated id() clones it). The derive works on named fields, tuple fields, and generic id types; enums, unit structs, and zero or multiple #[id] markers are compile errors.

For a fixed, known set of children, do not reach for a collection at all – pass an array or build a tuple stack:

vstack((header(), body(), footer()));

Watching manually

Side effects that are not views – logging, analytics, syncing to disk – need an explicit watcher. The subscription lives exactly as long as its guard, and a view body’s locals are dropped as soon as it returns, so tie the guard to the view with .retain():

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

    text!("Count: {count}").retain(guard)
}

Forgetting .retain() is a classic bug: the watcher unsubscribes immediately and the side effect silently never fires.

Updating from another thread

Binding<T> uses Rc internally and is therefore !Send. To drive UI state from a background task, take a mailbox:

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

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

    // Or enqueue a mutation without awaiting a reply
    mailbox.handle(|binding| binding.add_assign(10));
}

The mailbox owns a local task that applies queued jobs sequentially on the UI thread. get_as::<T2>() converts while it reads, which is how a Binding<Str> becomes an owned String on the other side of an await.

When the view’s shape changes

Everything above updates values in place. Occasionally the semantic structure itself has to change – a loading screen becomes a detail screen. That is what Dynamic::watch is for, and it is a deliberate exception:

use waterui::prelude::*;

watch(phase, |phase| match phase {
    Phase::Loading => loading_screen().anyview(),
    Phase::Ready => detail_screen().anyview(),
})

watch replaces the entire child subtree and discards its state. Reaching for it to update a number, a color, or a list is the most expensive mistake you can make in a WaterUI view:

You want to updateUse this, not watch
Text contenttext!("{status}")
A view attributethe signal-taking modifier, e.g. photo.blur(amount)
Collection membershipLazy::for_each(rows, row_view) / List::for_each

Next: The Environment, which shares themes, locales, and services across the view tree without threading parameters through every function.

The Environment

In this chapter, you will:

  • Store and read values in WaterUI’s type-indexed dependency injection container
  • Reach shared configuration from any view with use_env and extractors
  • Scope values to a subtree with .with() and .install()
  • Replace the rendering of built-in components globally with hooks and plugins

A deeply nested button needs the current theme. A form field needs the locale. A detail screen needs the API client. Threading all of that through every view function’s parameter list does not scale.

Environment is the alternative: a type-indexed container that flows down the view tree automatically. Every view receives one in body(), every view can read from it, and any view can extend it for its own descendants. If you know React Context or SwiftUI’s @Environment, this is the same idea with Rust’s type system as the key.

How it works

Each type can hold at most one visible value, so there are no string keys and no registration step – the type is the key. Internally the container is a structurally shared overlay chain, which makes cloning an Rc bump and extending an O(1) overlay rather than a map copy.

Inserting the same type twice replaces the earlier value. If you genuinely need two values of one type, see Store.

Seeding an environment

use waterui::prelude::*;

let mut env = Environment::new();

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

// with() mutates in place and returns &mut Self, so it chains
env.with(String::from("hello"))
   .with(42i32);

env.extending(value) is the non-mutating variant: it returns a fresh Environment overlaying the new value on the original, leaving the original untouched.

Note: a bare Environment::new() has no theme installed. Theme tokens fail fast rather than falling back – resolving a color slot that was never installed panics with WaterUI color token ... is not installed in the environment. Backends and Theme::install set these up; if you build an environment by hand to render themed views, install a theme first.

Namespaced keys with Store

Store<K, V> pairs a value with a zero-sized marker type so the same V can appear in several roles. Unlike with, store consumes the environment and returns it:

use waterui::env::Store;
use waterui::prelude::*;

struct PrimaryColor;
struct AccentColor;

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

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

Reading and removing

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

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

env.remove::<MyTheme>();

Reaching the environment from a view

Extractors

A type is readable from a view once it implements Extractor. The impl_extractor! macro writes that impl for any type stored directly in the environment:

use waterui::impl_extractor;

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

impl_extractor!(AppConfig);

Several extractors exist already:

TypeBehavior
EnvironmentClones the whole environment
Option<T: Extractor>Extraction failure becomes None instead of an error
State<T>Pulls state injected with ViewExt::state, for action handlers
(A, B, ...)Extracts each element, up to 8-tuples

use_env

use_env builds a view from extracted values:

use waterui::env::use_env;
use waterui::prelude::*;

let view = use_env(|config: AppConfig| {
    let base_url = config.base_url.clone();
    text!("API: {base_url}")
});

Extraction is fast-fail: if the value is missing, the view panics with a message naming the type. Wrap the parameter in Option when absence is legitimate:

let view = use_env(|config: Option<AppConfig>| {
    match config {
        Some(config) => {
            let base_url = config.base_url.clone();
            text!("API: {base_url}").anyview()
        }
        None => text("Not configured").anyview(),
    }
});

Extract several values with a tuple:

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

Scoping a value to a subtree

ViewExt::with injects a value for a view and everything below it:

settings_form().with(MyConfig { debug: true })

That is how you give the settings page a different theme without touching the rest of the app. ViewExt::install does the same for a plugin:

settings_form().install(HighContrastPlugin)

Keeping guards alive with retain

Anything RAII-scoped – a signal watcher guard, a subscription, a task handle – dies at the end of the body() that created it unless you tie it to the view:

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

Retain several values by chaining .retain(a).retain(b) or by passing a tuple. If a side effect “does not work”, check that its guard is retained – an unretained watcher unsubscribes the moment the body returns.

Hooks: intercepting view configuration

Hooks are how a theme replaces the rendering of every button in an app without editing a single call site. A Hook<C> is a function from an environment plus a view configuration to a view:

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

Install one with insert_hook. render() comes from the ViewConfiguration trait, so bring it into scope:

use waterui::component::button::ButtonConfig;
use waterui::view::ViewConfiguration;

env.insert_hook(|env: &Environment, config: ButtonConfig| {
    config.render()
        .padding()
        .background(Color::blue())
});

When a configurable view’s body() runs it extracts its Config, looks up Hook<Config> in the environment, and calls the hook if one is present; otherwise the default native rendering is used. See ConfigurableView for the view side of that contract.

The hook is invoked with the hook itself removed from the environment. That is deliberate: it means config.render() inside a hook produces the default rendering instead of recursing forever, so a hook can wrap the platform control rather than having to replace it.

Plugins

A Plugin bundles related setup – values, hooks, nested plugins – behind one call:

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 just stores the plugin. Override it to do real work:

use waterui::component::button::ButtonConfig;
use waterui::prelude::*;
use waterui::shape::RoundedRectangle;
use waterui::view::ViewConfiguration;

struct RoundedButtonPlugin;

impl Plugin for RoundedButtonPlugin {
    fn install(self, env: &mut Environment) {
        env.insert(self);
        env.insert_hook(|env: &Environment, config: ButtonConfig| {
            config.render()
                .padding()
                .background(Color::blue())
                .clip(RoundedRectangle::new(0.2))
        });
    }
}

RoundedRectangle::new takes a normalized corner radius in 0.0..=0.5, not points, so 0.2 looks the same on a small button and a large one.

Install at the root for the whole app, or on a subtree for part of it:

// App-wide
pub fn app(mut env: Environment) -> App {
    env.install(RoundedButtonPlugin);
    App::new(main, env)
}

// One screen only
fn my_screen() -> impl View {
    vstack((
        button("Save").action(|| {}),
        button("Cancel").action(|| {}),
    ))
    .install(RoundedButtonPlugin)
}

Install the plugin on one section and compare it with the rest of the app: only that subtree’s buttons change.

Worked example: a custom theme

use waterui::impl_extractor;
use waterui::env::use_env;
use waterui::prelude::*;

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

impl_extractor!(AppTheme);

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 {
    vstack((
        themed_card("Welcome"),
        themed_card("Settings"),
    ))
    .with(AppTheme::light())
}

Each card re-extracts AppTheme from its own environment, so swapping the value at the root swaps every card.

To switch themes at runtime, scope the two variants behind a condition – when(dark_mode, || content().with(AppTheme::dark())).otherwise(|| content().with(AppTheme::light())) – so the framework rebuilds only that subtree when the flag flips. For colors that should update without any rebuild, use theme tokens instead: they are signals, and the backend tracks them per property.

Metadata

Some rendering instructions ride along with a view rather than living in the environment. Metadata<T> is the mandatory form: a renderer that meets a Metadata<T> it does not understand panics, which is what keeps environment overrides and lifecycle hooks from being silently dropped. IgnorableMetadata<T> is the optional form, discarded by renderers that do not implement it – accessibility hints use it.

You rarely construct either directly. The ViewExt modifiers do it for you: .with(...), .retain(...), .on_appear(...), and .shadow(...) produce Metadata, while .a11y_label(...) and .a11y_role(...) produce IgnorableMetadata. The types themselves live in the internal waterui_core foundation crate; the facade exposes the modifiers, not the wrappers.

Next: Modifiers and ViewExt, the chainable methods that style, position, and add behavior to any view.

Modifiers and ViewExt

In this chapter, you will:

  • Understand how modifier chaining builds a nested type, and why order matters
  • Size, position, and align views with layout modifiers
  • Apply backgrounds, borders, shadows, transforms, and GPU filters
  • Add taps, gestures, hover, and drag-and-drop, and disable a whole subtree correctly

Modifiers are chainable methods that add styling, layout, and behavior to any view. Instead of a constructor with twenty parameters, you describe the view once and layer on the rest:

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

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

How modifiers work

Every method on ViewExt consumes self and returns a new type that wraps it:

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

The result is a nested type, not a runtime property bag, so mistakes surface at compile time and the compiler can see through the whole chain.

ViewExt is blanket-implemented for every view:

pub trait ViewExt: View + Sized { /* ... */ }

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

It is in the prelude, so use waterui::prelude::*; is all you need.

Layout modifiers

padding

// 14.0 points on every side
text!("Hello").padding();

// Explicit insets: top, bottom, leading, trailing
text!("Hello").padding_with(EdgeInsets::new(10.0, 10.0, 20.0, 20.0));

// EdgeInsets: From<f32>, so a scalar means uniform padding
text!("Hello").padding_with(16.0);

Size and constraints

Color::red().width(100.0);
Color::red().height(50.0);
Color::red().size(100.0, 50.0);

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

text!("Bounded").min_size(80.0, 40.0).max_size(300.0, 200.0);

All of these return a Frame, which is itself chainable – and Frame’s own methods accept any IntoSignalF32, so a reactive width really does re-run layout:

let column_width = Binding::f32(200.0);

text!("Hello")
    .width(200.0)                 // ViewExt -> Frame
    .min_width(column_width)      // Frame method, reactive
    .alignment(Alignment::Center)

alignment

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

ignore_safe_area

Extend past the safe-area insets, for full-bleed backgrounds:

Color::red().ignore_safe_area(EdgeSet::ALL);
header_view.ignore_safe_area(EdgeSet::TOP);

Visual modifiers

background

text!("Hello").background(Color::red());
text!("Hello").background(Material::Regular);           // platform blur
text!("Hello").background(hstack((Color::red(), Color::blue()))); // any view

The content determines the layout size; the background stretches to fill it. Material rendering is best-effort: Apple platforms map it to native visual-effect views, other backends approximate or ignore it.

foreground

vstack((
    text!("Hello"),
    text!("World"),
)).foreground(Color::red())

This injects a foreground override into the environment, so it reaches every descendant that does not set its own.

opacity

Accepts any IntoSignalF32 – a constant or a signal:

text!("Faded").opacity(0.5);

let alpha = Binding::f32(1.0);
text!("Dynamic").opacity(alpha);

opacity maps to compositor-native operations (CALayer.opacity, View.alpha, a Vello layer) rather than an offscreen GPU pass.

overlay

Draw content on top without affecting the base view’s layout:

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

Unlike a ZStack, an overlay never influences the size of what is underneath, which makes it the right tool for badges and status dots.

shadow

Shadow::new(color, offset, blur_radius):

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

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

border

text!("Bordered").border(Color::red(), 2.0);

let custom = Border::new(Color::blue(), 2.0)
    .corner_radius(12.0)
    .edges(EdgeSet::HORIZONTAL);

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

clip

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

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

avatar_view.clip(Circle);
card_view.clip(RoundedRectangle::new(0.1));

floating

floating() promotes a view onto the elevated surface layer: themed container color, clip radius, and a pair of ambient and key shadows.

button("Compose").action(|| {}).floating()

The tokens come from a FloatingStyle in the environment, which Theme::install provides. Calling .floating() without one panics – there is no silent fallback. Override the tokens for a subtree with .floating_with(style) or by installing your own FloatingStyle, which is a Plugin with a Default impl.

Presentation stays an attribute here: a floating button is still semantically a button, with the same identity and accessibility.

visible

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

visible composes three things: opacity goes to 0.0, hit testing turns off, and the accessibility state reports the view as hidden – so a hidden view also disappears for screen readers.

Transform modifiers

Transforms are purely visual. They change how a view is drawn without touching layout, which is what makes them cheap to animate.

// Scale around the center; both axes take IntoSignalF32
star_view.scale(1.5, 1.5);
text!("Stretched").scale(2.0, 1.0);

let s = Binding::f32(1.0);
heart_view.scale(s.clone(), s);

// Scale or rotate around an explicit anchor
star_view.scale_from(0.5, 0.5, Anchor::TOP_LEFT);
dial_view.rotation_from(90.0, Anchor::TOP_LEFT);

// Rotation in degrees, positive = clockwise
arrow_view.rotation(45.0);

// Translation
badge.offset(10.0, -5.0);

Anchor lives in waterui::style.

Interaction modifiers

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

text!("Double-tap me").on_tap_gesture_count(2, || { /* ... */ });

text!("Press and hold").on_long_press_gesture(500, || { /* ... */ });

gesture attaches any recognizer:

use waterui::gesture::TapGesture;

text!("Triple tap").gesture(TapGesture::repeat(3), || { /* ... */ })

hittable controls whether a view receives pointer events at all, without changing how it looks:

overlay_decoration.hittable(false);

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

disabled

disabled is not a visual dimming shortcut. It installs a Disabled scope into the subtree’s environment:

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

// Reactive, applied to a whole form
let is_submitting = Binding::bool(false);
vstack((
    field("Name", &name),
    button("Submit").action(|| {}),
)).disabled(is_submitting);

Three things follow from that. Controls inside the subtree render their platform-correct disabled appearance instead of a blanket 50% opacity. The subtree stops hit-testing and reports the disabled state to assistive technologies. And nested scopes OR-combine: a control stays disabled while any enclosing .disabled(...) is disabled, tracked reactively without rebuilding the subtree.

Controls fold the inherited scope into their own configuration through Disabled::resolve, so a per-control .disabled(...) and an ancestor scope both take effect. Button, Toggle, and Slider implement that today. Stepper, TextField, and Picker are not wired into the scope yet – they still stop receiving events, but they do not render a disabled appearance.

Drag and drop

use waterui::drag_drop::DragData;

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

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

Stateful event handlers

Handlers are extractor-based, exactly like use_env. ViewExt::state injects a cloneable value into the subtree’s environment, and the handler pulls it back out with State<T>:

use waterui::State;
use waterui::prelude::*;

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.get_mut() += 1;
            hovered.set(true);
        },
    )
    .on_hover_exit(|State(hovered): State<Binding<bool>>| {
        hovered.set(false);
    })

One .state(&value) per injected value, one State<T> parameter per value you want back. A missing value is a clear runtime error, not a silent default. If a handler needs four or more pieces of state, bundle them into one #[derive(Clone)] struct and inject that instead.

Feedback modifiers

use waterui::cursor::CursorStyle;

// Haptic tap at the default (medium) intensity
text!("Haptic tap").on_tap_haptic_default(|| { /* ... */ });

// Cursor style while hovering (desktop and trackpad platforms)
text!("Click me").cursor(CursorStyle::PointingHand);

// Numeric badge overlay, typically for unread counts
let unread = Binding::i32(5);
inbox_icon().badge(unread);

on_tap_haptic also takes an explicit Intensity, but that type comes from the waterkit-haptic crate rather than the waterui facade, so on_tap_haptic_default is the portable choice.

Filter modifiers

Filters are GPU effects from FilterViewExt, which is in the prelude when the gpu feature is enabled – it is on by default, and off in embedded builds.

photo_view.blur(10.0);
photo_view.brightness(0.2);     // negative darkens
photo_view.contrast(1.5);
photo_view.saturation(0.0);     // fully desaturated
photo_view.grayscale(1.0);
photo_view.hue_rotation(90.0);  // degrees

Every filter takes an impl IntoSignalF32, so a Binding<f32> animates the effect without rebuilding the view:

let blur_amount = Binding::f32(0.0);
background_content.blur(blur_amount)

Blurring the background as a modal appears is the canonical use.

Lifecycle modifiers

text!("Hello").on_appear(|| tracing::info!("visible"));
text!("Hello").on_disappear(|| tracing::info!("removed"));

body() running does not mean the view is on screen – a lazy container may resolve views ahead of time. Use on_appear for work that should start when the view is actually displayed.

on_change watches a signal and runs a handler on every change, managing the watcher’s lifetime for you:

let query = Binding::container(Str::from(""));

field("Search", &query)
    .on_change(&query, |value: Str| {
        tracing::info!("Search changed to: {value}");
    })

The handler receives the source signal’s Output. on_change subscribes before caching its first value, so a change that lands during subscription is delivered rather than swallowed.

task spawns an async task bound to the view’s lifetime:

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

Other modifiers

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

// Keep a guard alive for the view's lifetime
text!("Watching").retain(guard);

// Wrap in a navigation view with a title
content_view.title(text!("Settings"));

// Focus a field when the binding matches
let focus: Binding<Option<Field>> = Binding::container(None);
field("Name", &name).focused(&focus, Field::Name);

// Block screenshots of sensitive content
sensitive_content.secure();

// Identify a view for selection and navigation
text!("Item").tag(42);

context_menu attaches a menu shown on long press (mobile) or right click (desktop). Ordinary buttons are valid menu content:

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

Accessibility attributes are modifiers too:

use waterui::accessibility::AccessibilityRole;

icon_view.a11y_label("Favorite");
icon_view.a11y_role(AccessibilityRole::Button);

Always label icon-only controls. Screen readers have nothing else to announce.

Modifier order

Each modifier wraps the previous result, so order changes the outcome:

// Background covers the padded area
text!("Hello")
    .padding()
    .background(Color::red());

// Padding sits outside the background
text!("Hello")
    .background(Color::red())
    .padding();

The same applies to transforms:

view.rotation(45.0).offset(100.0, 0.0);  // rotate in place, then translate
view.offset(100.0, 0.0).rotation(45.0);  // translate, then rotate about the original center

Rules of thumb: layout modifiers before visual ones; gestures after both, so the hit area matches what the user sees; lifecycle hooks anywhere, since they do not affect rendering.

Modifier index

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, opacity, overlay, shadow, border, border_with, clip, floating, floating_with, 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
Filter (gpu)blur, brightness, contrast, saturation, grayscale, hue_rotation, invert
Lifecycleon_appear, on_disappear, on_change, task
Eventon_hover_enter, on_hover_exit, event
Othertag, anyview, retain, title, focused, secure, context_menu, a11y_label, a11y_role, a11y_hidden, with, install

Next: Building UIs, which puts views, state, environment, and modifiers to work on text, layout, controls, forms, and navigation.

Text and typography

In this chapter, you will:

  • Display text with text() and text!, and know which one consults the translation catalog
  • Style text through theme font tokens, weights, colors, and decorations
  • Compose rich text with StyledStr and add syntax highlighting
  • Render Markdown three ways: inline, block, and streaming

The text API is two entry points. text() converts a value into a Text; the text! macro interpolates named bindings and looks the format string up in the translation catalog. Fonts, colors, and decorations are builder methods on the Text you get back.

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

What text() localizes

text() accepts anything implementing IntoText, and that conversion decides whether the content is translated:

InputBehavior
&'static strLooked up in the translation catalog (Text::localized)
String, StrRendered verbatim, never translated (Text::verbatim)
StyledStrRendered verbatim, keeping its per-chunk styling
Computed<T> / Binding<T> where T: IntoTextReactive; re-resolves when the signal changes and when the locale changes
use waterui::prelude::*;

fn greeting(name: &Binding<String>) -> impl View {
    vstack((
        text("Hello, World!"), // catalog key
        text(name.clone()),    // reactive, verbatim
    ))
}

Text sizes itself to its content and never stretches, so it takes only the space it needs inside a stack. When a parent constrains its width, it wraps to multiple lines.

Reactive text with text!

text! captures named placeholders from the surrounding scope and re-evaluates when the captured signals change:

use waterui::prelude::*;

fn counter_label(count: &Binding<i32>) -> impl View {
    text!("Count: {count}")
}

Only named placeholders are accepted. 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. text!("Count: {}", count) will not compile.

Format specs work as in format!text!("Value: {value:.2}") rounds to two decimals and stays reactive. Reaching for .get() and format! instead reads the value once at construction time and freezes the output; the whole point of text! is that the framework records the dependency for you.

Displaying and formatting values

Text::display renders any signal whose output implements Display:

use waterui::prelude::*;

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

For presentation that depends on the active locale — dates, currency, measurement units — implement the Formatter<T> trait and pass it to Text::format:

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

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

Formatter has one method, fn format(&self, value: &T) -> Str.

Translation catalogs

Place TOML files under i18n/ in the crate root. The keys are the exact format strings you passed to text() or text!:

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

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

The active Locale in the environment selects the file. A missing catalog or a missing key is not an error — the format string itself is used as the fallback.

Plural placeholders are written {#count} and resolve against a TOML table keyed by the CLDR categories zero, one, two, few, many, and other. Only other is required. Two plural placeholders in the same key form a dual plural, whose table keys combine both categories (one_other, other_other, …). The full grammar lives in waterui/macros/src/locale.rs.

Font tokens

Six semantic font tokens are available as builder methods on Text, and as values (Body, Title, Headline, Subheadline, Caption, Footnote) you can pass to .font():

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

Each token resolves through the environment, so a platform “Larger Text” accessibility setting cascades into your screen without per-call ceremony.

Important: Font tokens carry no built-in sizes. The active theme installs them, and resolving a token that was never installed panics with "<Token> font token is not installed in the environment". Backends and Theme::install do this for you; a bare Environment::new() does not. If you render text against a hand-built environment, install a theme first.

Install your own metrics through FontSettings, which takes a ResolvedFont (or a signal of one) per token:

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

fn compact_fonts() -> FontSettings {
    FontSettings::new()
        .body(ResolvedFont::new(15.0, FontWeight::Normal))
        .title(ResolvedFont::new(22.0, FontWeight::SemiBold))
}

ResolvedFont::with_typography_metrics(line_height, letter_spacing) sets absolute line height and tracking; leaving line_height as None uses the font’s own preferred metrics.

Direct font overrides

For fixed layouts — posters, splash screens, hero headlines — build a Font and pass it to .font(). Direct overrides escape theme-driven scaling, which is exactly why they exist and exactly why product UI should prefer the tokens:

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")
            .line_height(24.0)
            .letter_spacing(0.5),
    )
}

FontWeight covers the nine standard weights from Thin (100) through Black (900), with Normal (400) as the default.

.size(), .weight(), .italic(), and .font() all accept signals as well as constants, so any of them can react:

use waterui::prelude::*;

fn highlight(emphasized: &Binding<bool>) -> impl View {
    vstack((
        text("Large bold text").size(28.0).bold(),
        text("May be italic").italic(emphasized.clone()),
    ))
}

Color and alignment

Text::color and Text::background_color take impl IntoSignal<Color>, so pass a Color value — a palette constructor, a hex/sRGB value, or a signal of one:

use waterui::prelude::*;

fn status(highlight: &Binding<Color>) -> impl View {
    vstack((
        text("Error message").color(Color::red()),
        text("Success").color(Color::green()),
        text("Highlighted").background_color(Color::yellow()),
        text("Themed").color(highlight.clone()),
    ))
}

The palette constructors follow the Material color names: red, pink, purple, deep_purple, indigo, blue, light_blue, cyan, teal, green, light_green, lime, yellow, amber, orange, deep_orange, brown, grey, blue_grey. Each resolves from the environment when the theme overrides it and falls back to its built-in sRGB value otherwise.

Theme tokens are constant signals, so they can be passed to .color() directly — and they are what product UI should use, because they track light/dark and any installed palette:

use waterui::prelude::*;
use waterui::theme::color::{Accent, MutedForeground};

fn labelled(caption: &str) -> impl View {
    vstack((
        text("Continue").color(Accent),
        text(caption.to_string()).color(MutedForeground).caption(),
    ))
}

Note: .color() sets the foreground for that one text view. The .foreground() modifier from ViewExt sets the inherited foreground for a whole subtree, so children pick it up through the cascade. .foreground() takes impl Into<Color> rather than a signal, which is why the bare palette markers (Red, Grey) work there but need Color::red() in .color().

.text_align() controls paragraph alignment for multi-line text and also takes a signal:

use waterui::prelude::*;
use waterui::layout::HorizontalAlignment;

fn centred_paragraph(body: &Binding<String>) -> impl View {
    text(body.clone()).text_align(HorizontalAlignment::Center)
}

Decorations

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

use waterui::prelude::*;

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

Strikethrough is a StyledStr attribute rather than a Text builder:

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

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

Concatenating and composing

Text implements Add and AddAssign, and each side keeps its own styling:

use waterui::prelude::*;

fn name_row(name: &Binding<String>) -> impl View {
    text("Name: ").bold() + text(name.clone())
}

The right-hand side takes anything that implements IntoText, including reactive signals and catalog keys, so a concatenation stays reactive and localized.

For finer control, build a StyledStr chunk by chunk. Each chunk carries a Style holding font, foreground, background, 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, three ways

The right tool depends on whether you need inline styling, a full document, or a document that is still arriving.

Inline: StyledStr::from_markdown

Parses emphasis, strong, strikethrough, inline code, and headings into a single styled run. Block structure collapses into text with blank lines; there is no layout involved, so it fits anywhere a Text fits — a label, a table cell, a list row:

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

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

Block: RichText

RichText::from_markdown parses a document into a tree of RichTextElement values — paragraphs, lists, quotes, images, links, code blocks, and tables — and lays them out as real views. Tables get proper per-column alignment and shared column origins across rows, so they render as aligned grids rather than ragged stacks:

use waterui::prelude::*;
use waterui::widget::RichText;

fn changelog(source: &str) -> impl View {
    RichText::from_markdown(source)
}

For Markdown that ships with your binary, include_markdown! reads the file at compile time and produces the same RichText:

use waterui::prelude::*;

fn about() -> impl View {
    include_markdown!("../docs/about.md")
}

Streaming: flow_markdown

When Markdown arrives token by token — an LLM response, a log tail — flow_markdown renders it as a reactive list of blocks, patching only the block that changed instead of rebuilding the document. It takes any IntoComputed<Str> and lives behind the flow-markdown feature, which is on by default:

use waterui::prelude::*;

fn assistant_reply(source: &Binding<Str>) -> impl View {
    flow_markdown(source.clone())
        .preset(FlowAnimationPreset::AssistantDefault)
        .stream(FlowStreamMode::AppendOnly)
}

FlowAnimationPreset offers AssistantDefault, Minimal, and None. .override_animation(kind, policy) swaps the policy for one FlowElementKind — heading, list item, code block, table — where FlowAnimationPolicy is None, Fade(Animation), or a Typewriter reveal. .max_pending_bytes, .table_policy, and .token_fade_in tune buffering and entry timing. The same builders exist on FlowMarkdownConfig, and .configuration(signal) swaps the whole configuration reactively.

Syntax highlighting

highlight_text turns source code into a StyledStr using a syntect-backed highlighter:

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

Language covers 39 languages including Rust, Swift, Kotlin, Python, TypeScript, and Zig, and implements FromStr (with aliases such as c++, objc, shell, yml) so a fenced-code-block info string maps straight onto it.

For a finished code block — highlighting, a language caption, and a copy button that reports through the window’s snackbar — use the code widget instead:

use waterui::prelude::*;
use waterui::text::highlight::Language;
use waterui::widget::code;

fn snippet() -> impl View {
    code(Language::Rust, "fn main() {}")
}

Clipboard access is a no-op on espidf targets; everywhere else the copy button writes to the system clipboard.

Quick reference

Method / FunctionPurpose
text("...")Static text; &'static str is localized
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() / .headline() / .sub_headline() / .body() / .caption() / .footnote()Apply a theme font token
.font(f)Apply a Font (accepts signals)
.size(s) / .weight(w) / .bold()Direct font overrides (accept signals)
.italic(sig) / .underline(sig)Toggle decorations reactively
.color(c) / .background_color(c)Text foreground / background (accept signals)
.text_align(a)Paragraph alignment for multi-line text

Now that you can display and style text, it is time to arrange views on screen. The next chapter covers 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
  • Drive spacing and frame dimensions from reactive signals
  • Build grids, scroll regions with programmatic control, and free-form absolute layouts

WaterUI resolves layout through a proposal protocol: a parent proposes a size to each child, the child reports the size it wants, and the parent places it. You compose that behaviour from stacks, spacers, frames, and grids. All values are logical pixels (points/dp) — the same unit as Figma and Sketch. Native backends convert to physical pixels.

A Hydrolysis preview of WaterUI stack layout primitives. Example source.

Stacks

Three stacks cover most interfaces: vstack (top to bottom), hstack (leading to trailing), and zstack (layered back to front).

vstack — vertical layout

vstack accepts a tuple of views and lays them out in tuple order:

use waterui::prelude::*;

fn profile_card() -> impl View {
    vstack((
        text("Alice").title(),
        text("Software Engineer"),
        text("San Francisco"),
    ))
}

Default spacing is 10pt; default horizontal alignment is centre. Set both with the struct constructor, or chain builder methods onto vstack:

use waterui::prelude::*;

fn left_aligned() -> impl View {
    VStack::new(HorizontalAlignment::Leading, 16.0, (
        text("Left-aligned"),
        text("Also left-aligned"),
    ))
}

fn trailing_8pt() -> impl View {
    vstack((text("Item 1"), text("Item 2")))
        .alignment(HorizontalAlignment::Trailing)
        .spacing(8.0)
}

HorizontalAlignment provides three guides — Leading, Center (the default), and Trailing. Leading is the left edge in left-to-right locales and the right edge in right-to-left ones.

hstack — horizontal layout

use waterui::prelude::*;

fn toolbar() -> impl View {
    hstack((
        text("WaterUI"),
        spacer(),
        button("Settings").action(|| {}),
    ))
}

Same defaults, applied to the other axis: 10pt spacing, vertically centred.

use waterui::prelude::*;

fn top_aligned() -> impl View {
    HStack::new(VerticalAlignment::Top, 20.0, (
        text("Top-aligned"),
        text("Also top"),
    ))
}

VerticalAlignment provides Top, Center (the default), Bottom, FirstBaseline, and LastBaseline. The baseline guides line up the text baselines of children set in different sizes, rather than their boxes.

When a row does not fit

If the children of an hstack are wider than the space available, the stack does not crush whichever child it reaches first. It solves for a single width cap shared by every child — the largest cap where the clamped widths still fit — so children already narrower than the cap keep their intrinsic width, and equal-width children (a calendar’s day columns, a segmented row of buttons) shrink by equal amounts. Clamped children are then re-measured at the cap, so wrapping text reports the height it actually needs. Compression never squeezes a child below 20pt.

zstack — overlay layout

zstack layers children on top of each other. The last child in the tuple renders on top, and the stack sizes itself to fit its largest child:

use waterui::prelude::*;

fn badge() -> impl View {
    zstack((
        Blue,
        text("Overlay").color(Color::yellow()),
    ))
}

Pass an Alignment to control where children sit inside the stack:

use waterui::prelude::*;

fn corner_badge(image: impl View, dot: impl View) -> impl View {
    ZStack::new(Alignment::TopTrailing, (image, dot))
}

Alignment pairs a horizontal guide with a vertical one. Nine constants cover the grid of edges and centres — TopLeading, Top, TopTrailing, Leading, Center (the default), Trailing, BottomLeading, Bottom, BottomTrailing — and Alignment::new(horizontal, vertical) builds any other pairing, including the baseline guides.

Reactive spacing and sizing

Stack spacing, grid spacing, every Frame dimension, and every PinConstraints edge accept either a plain number or a signal. Passing a signal keeps the value live: when it changes, only that container’s layout runs again — the subtree is not rebuilt and no state inside it is lost.

use waterui::prelude::*;

fn adjustable_row() -> impl View {
    let gap = Binding::container(8.0_f32);
    let widen = gap.clone();

    vstack((
        button("Loosen").action(move || widen.add_assign(4.0)),
        hstack((text("Alice"), text("Bob"), text("Carol"))).spacing(gap),
    ))
}

Every numeric type converts through the same IntoSignalF32 conversion, so .spacing(8), .spacing(8.0), .spacing(a_computed), and .spacing(a_binding) are all accepted.

Spacer

Spacer is a flexible gap that expands to push views apart. It adapts to its parent: inside an hstack it expands horizontally, inside a vstack vertically.

use waterui::prelude::*;

fn pushed_to_the_edge() -> impl View {
    hstack((
        text("Title"),
        spacer(),
        button("Done").action(|| {}),
    ))
}

spacer_min(20.0) behaves the same but never shrinks below 20pt when space runs short.

Divider

Divider draws a hairline between sections, oriented by the stack it sits in — horizontal inside a vstack, vertical inside an hstack:

use waterui::prelude::*;

fn sectioned() -> impl View {
    vstack((
        text("Section 1"),
        Divider,
        text("Section 2"),
    ))
}

Padding

.padding() applies a 14pt inset on every side; .padding_with(EdgeInsets) takes exact values:

use waterui::prelude::*;

fn padded() -> impl View {
    vstack((
        text("Default").padding(),
        text("Padded").padding_with(EdgeInsets::all(16.0)),
        text("Symmetric").padding_with(EdgeInsets::symmetric(8.0, 16.0)),
        text("Custom").padding_with(EdgeInsets::new(10.0, 20.0, 15.0, 25.0)),
    ))
}
ConstructorDescription
EdgeInsets::all(v)Equal inset on every edge
EdgeInsets::symmetric(vertical, horizontal)Vertical and horizontal insets
EdgeInsets::new(top, bottom, leading, trailing)Explicit edges, in that order

Frame

Frame overrides the proposal a child receives, clamping it to the constraints you set:

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)
        .alignment(Alignment::BottomTrailing)
}
MethodDescription
.width(w)Fixed width — sets the minimum, ideal, and maximum at once
.height(h)Fixed height, the same way
.min_width(w) / .max_width(w)Lower / upper bound on width only
.min_height(h) / .max_height(h)Lower / upper bound on height only
.alignment(a)Where the child sits inside the resolved frame

Each dimension takes a number or a signal, so a frame can grow and shrink from a Binding without a rebuild.

Tip: Reach for Frame only when you need an explicit constraint. Most views have a sensible natural size, and stacks already distribute the surplus.

Scrolling

Wrap content that can outgrow its space in a scroll view:

use waterui::prelude::*;

fn long_list() -> impl View {
    scroll(vstack((
        text("Item 1"),
        text("Item 2"),
        text("Item 3"),
    )))
}
FunctionDirectionPath
scroll(content)Vertical onlyin the prelude
scroll_horizontal(c)Horizontal onlywaterui::layout::scroll
scroll_both(c)Both directionswaterui::layout::scroll

Scrolling programmatically

A ScrollController<Point> lets code move the scroll position — a “back to top” button, jumping to a search result, restoring an offset after a refresh:

use waterui::prelude::*;

fn jump_to_top(rows: impl View) -> impl View {
    let scroller = ScrollController::new(Point::zero());
    let jump = scroller.clone();

    vstack((
        button("Back to top").action(move || jump.scroll_to(Point::zero())),
        scroll(rows).scroll_controller(&scroller),
    ))
}

ScrollController::new takes the initial target. scroll_to stores a new target and bumps a request generation, so asking for an offset the view is nominally already at still scrolls once the user has dragged away from it. target() and generation() hand both values back as read-only signals if you want to derive state from them.

Grid

Grid distributes children into a fixed number of columns, one row at a time. The grid functions live in waterui::layout::grid, so import them explicitly — the prelude’s row is the list row from the Lists chapter:

use waterui::prelude::*;
use waterui::layout::grid::{grid, row};

fn settings_grid() -> impl View {
    grid(2, [
        row((text("Name"), text("Alice"))),
        row((text("Age"), text("30"))),
        row((text("City"), text("SF"))),
    ])
}

Columns are sized equally from the available width; each row is as tall as its tallest item. Default spacing is 8pt in both directions and default alignment is centre:

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

Grid::new panics if you ask for zero columns.

Overlay and background

overlay layers content on top of a base view, and background puts it behind. In both cases the base child alone determines the size, so decorations never disturb the surrounding layout:

use waterui::prelude::*;

fn avatar_with_badge(avatar: impl View, dot: impl View) -> impl View {
    overlay(avatar, dot).alignment(Alignment::TopTrailing)
}

fn highlighted() -> impl View {
    background(text("Foreground content"), Blue)
}

Use zstack instead when both children should contribute to the overall size.

Absolute positioning

For layouts that stacks and grids cannot express — floating action buttons, custom popovers, canvas-like surfaces — put children in an absolute container and position them with the PositionExt methods:

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,
        ),
    ))
}
MethodDescription
.position(x, y)Centre at absolute coordinates
.position_anchor(anchor, x, y)Anchor point at absolute coordinates
.position_in(unit)Centre 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 uses normalised parent coordinates, where (0.0, 0.0) is the top-leading corner and (1.0, 1.0) the bottom-trailing one. The nine constants are TOP_LEADING, TOP, TOP_TRAILING, LEADING, CENTER, TRAILING, BOTTOM_LEADING, BOTTOM, and BOTTOM_TRAILING; UnitPoint::new(x, y) covers everything else, including values outside 0.0..=1.0, which position outside the parent’s bounds.

Pin constraints

Pinning positions a child by its distance from the parent’s edges:

use waterui::prelude::*;

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

Setting both leading and trailing computes the width; setting both top and bottom computes the height. Explicit .width() and .height() override the computed dimension. Like frame dimensions, every constraint accepts a signal.

StretchAxis

Every view reports a StretchAxis telling its parent whether it wants surplus space:

VariantMeaningExamples
NoneContent-sizedText, Button, zstack, hstack
HorizontalFills the width, keeps its intrinsic heightTextField, Slider, Toggle, vstack
VerticalFills the height, keeps its intrinsic width
BothFills the space it is givenScrollView, absolute, colours
MainAxisFills along the parent stack’s main axisSpacer
CrossAxisFills along the parent stack’s cross axis

Stacks use this to decide who absorbs leftover space. Spacer reports MainAxis, which is why the same spacer() pushes horizontally in an hstack and vertically in a vstack.

Tip: When a view refuses to fill or refuses to shrink, check its stretch axis first — it usually explains the result on its own.

Dynamic children with for_each

A stack whose children come from data uses for_each instead of a tuple. You give it a reactive collection and a generator returning one view per element, and membership changes diff by identity rather than rebuilding the stack:

use waterui::prelude::*;
use waterui::Identifiable;
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)
}

Wrap that in collection_transition and items animate instead of popping: an item fades and grows in when it appears, fades and collapses out when it disappears.

use waterui::prelude::*;
use waterui::animation::Animation;
use core::time::Duration;

fn animated_list(rows: impl View) -> impl View {
    collection_transition(rows, Animation::ease_out(Duration::from_millis(200)))
}

The transition is scoped through the environment, so every reactive collection inside the wrapped subtree picks it up. Backends without support for it render the collection normally, just without the animation.

Stacks are the lightweight case. For platform-styled, sectioned, editable collections, see Lists and collections.

Next: buttons and controls, where these layouts get something to arrange.

Buttons and controls

In this chapter, you will:

  • Give every control a semantic Label, and hide it visually when the design calls for it
  • Wire button actions to reactive state with .action() and the State<T> extractor
  • Use Toggle, Slider, Stepper, and TextField for primary user input
  • Build menus from commands, dividers, and nested submenus
  • Disable a single control or an entire subtree from a signal

Every WaterUI control has the same shape: a constructor that demands a semantic label, builder methods for configuration, and either a reactive binding carrying values in and out or an action closure fired on activation.

A Hydrolysis preview of WaterUI controls rendered from real bindings. Example source.

Labels come first

A control’s label is not decoration. Screen readers, voice control, and command palettes all read it to announce and activate the control, so Label sits in the constructor signature rather than in a builder method you might forget.

Anything that converts into semantic text is a label — &str, String, Str, Text, StyledStr, and any Binding or Computed of those. That is the IntoLabel trait, and the convenience constructors accept it directly:

use waterui::prelude::*;

fn save_button() -> impl View {
    button("Save").action(|| {})
}

For anything richer, build the label explicitly. The label(...) free function creates a semantic text label you can decorate with an icon:

use waterui::prelude::*;
use waterui::icon::system_icon;

fn add_button() -> impl View {
    button(label("Add item").icon(system_icon::plus())).action(|| {})
}

Label::new(spoken_text, content) is the general constructor: it takes arbitrary visual content plus the separate text that assistive technology should announce. Reach for it only when the two genuinely differ.

use waterui::prelude::*;
use waterui::icon::system_icon;

fn account_button() -> impl View {
    button(Label::new(
        "Verified account",
        hstack((text("Account"), system_icon::checkmark())),
    ))
    .action(|| {})
}

The two label kinds are not interchangeable. .icon(), .system_icon(), .leading(), .trailing(), .spacing(), and .font() describe how a semantic label arranges its text and icon, so they panic on a Label::new label. Style arbitrary content inside the view you pass to Label::new instead.

Platform note: system_icon renders SF Symbols on Apple platforms and is intentionally unsupported on Android, Linux, and Web. For portable icons, pass an icon-pack view to .icon(...) from a crate such as waterui-icons-lucide or waterui-icons-material-icon.

Hiding a label without losing it

.hide_label() collapses the visible chrome to zero size while the semantic text stays in the accessibility tree. Use it for icon-only toolbars and for controls whose meaning is obvious from an adjacent icon:

use waterui::prelude::*;
use waterui::component::slider;
use waterui::icon::system_icon;

fn rating_row(rating: &Binding<f64>) -> impl View {
    hstack((
        system_icon::star(),
        slider("Rating", rating).hide_label(),
    ))
}

LabelDisplayMode covers the other presentations — TitleAndIcon, TitleOnly, IconOnly, Hidden — either per control with .label_style(...) or across a whole subtree as an installed plugin:

use waterui::prelude::*;
use waterui::icon::system_icon;

fn toolbar() -> impl View {
    hstack((
        button(label("Search").icon(system_icon::search())).action(|| {}),
        button(label("Settings").icon(system_icon::settings())).action(|| {}),
    ))
    .install(LabelDisplayMode::IconOnly)
}

Two constructors per control

Each control exposes a general constructor and an ergonomic one. Button::new, Slider::new, and Stepper::new take a fully built Label; the free functions button(...), slider(...), and stepper(...) take any IntoLabel and do the conversion for you. Prefer the free functions unless you already hold a Label.

Button

Simple action

use waterui::prelude::*;

fn dismiss() -> impl View {
    button("Dismiss").action(|| {
        // Handle click.
    })
}

Reactive state via .state() and State<T>

An action closure receives its arguments through extraction, not capture. Inject the binding into the button’s environment with .state(), then pull it back out inside the action with the State<T> extractor:

use waterui::prelude::*;

fn increment(counter: &Binding<i32>) -> impl View {
    button("Increment")
        .action(|State(count): State<Binding<i32>>| {
            *count.get_mut() += 1;
        })
        .state(counter)
}

get_mut() returns a guard that writes back on drop, so read-modify-write needs one statement instead of a get/set pair.

Chain .state() once per value the action needs. Each State<T> parameter is matched by its type:

use waterui::prelude::*;

fn discard_button(draft: &Binding<Str>, is_dirty: &Binding<bool>) -> impl View {
    button("Discard")
        .action(|State(draft): State<Binding<Str>>, State(dirty): State<Binding<bool>>| {
            draft.set(Str::default());
            dirty.set(false);
        })
        .state(draft)
        .state(is_dirty)
}

Note: .state() is a ViewExt method, so it wraps the button in a plain view. Call it after .action() and after any button-specific builder.

Environment extraction

Any value already in the environment can be extracted directly, with no State wrapper. The navigation controller injected by NavigationStack is the common case:

use waterui::prelude::*;
use waterui::navigation::NavigationController;

fn back_button() -> impl View {
    button("Go back").action(|nav: NavigationController| nav.pop())
}

Environment extractors and State<T> parameters mix freely in one closure.

Async actions

action_async spawns the returned future on the local executor, so the handler can await network or file I/O:

use waterui::prelude::*;

async fn fetch_from_server() -> Str { unimplemented!() }

fn fetch_button(result: &Binding<Str>) -> impl View {
    button("Fetch data")
        .action_async(|State(result): State<Binding<Str>>| async move {
            result.set(fetch_from_server().await);
        })
        .state(result)
}

Button styles

ButtonStyle sets visual emphasis, and the platform decides how each style is drawn.

StyleUse for
AutomaticPlatform default (the default)
PlainLow-emphasis actions, toolbar buttons
LinkText-based links and URL navigation
BorderlessNo border, but hover and press feedback
BorderedSecondary actions
BorderedProminentThe one primary action on a screen

Apply a style with .style(...) or one of the convenience methods:

use waterui::prelude::*;

fn cta_row() -> impl View {
    hstack((
        button("Continue").bordered_prominent().action(|| {}),
        button("Cancel").bordered().action(|| {}),
        button("Learn more").link().action(|| {}),
    ))
}

Toggle

Toggle is a boolean switch backed by a Binding<bool>. Toggle::new takes only the binding and starts with an empty label, so attach one with .label(...) — or use toggle(...), which does both:

use waterui::prelude::*;

fn settings(wifi: &Binding<bool>, dark_mode: &Binding<bool>) -> impl View {
    vstack((
        toggle("Wi-Fi", wifi),
        Toggle::new(dark_mode).label("Dark mode").switch(),
    ))
}

ToggleStyle chooses the presentation: Automatic (platform default), Switch (sliding pill), or Checkbox. .switch() and .checkbox() are shorthands for .style(...).

Slider

Slider selects a value from a continuous range. The default range is 0.0..=1.0; .range(...) overrides it. The free function is not in the prelude, so import it directly:

use waterui::prelude::*;
use waterui::component::slider;

fn volume_slider(volume: &Binding<f64>) -> impl View {
    slider("Volume", volume).range(0.0..=100.0)
}

.min_value_label(...) and .max_value_label(...) add captions at the ends of the track:

use waterui::prelude::*;
use waterui::component::slider;

fn brightness_slider(brightness: &Binding<f64>) -> impl View {
    slider("Brightness", brightness)
        .min_value_label("Dark")
        .max_value_label("Bright")
}

Stepper

Stepper drives an i32 with +/- buttons — quantities, seat counts, small numeric adjustments:

use waterui::prelude::*;

fn item_stepper(count: &Binding<i32>) -> impl View {
    stepper("Items", count).range(1..=10).step(1)
}

A stepper shows its label and nothing else until you add .value_formatter(...), which renders the formatted current value next to the buttons. The formatter never affects the semantic label:

use waterui::prelude::*;

fn temperature_stepper(temperature: &Binding<i32>) -> impl View {
    stepper("Temperature", temperature)
        .value_formatter(|v| format!("{v}°C"))
        .range(-20..=50)
        .step(5)
}

.range(...) accepts any RangeBounds<i32>, so 1..=10, 1..11, and 1.. all work.

TextField

TextField is a text input backed by a Binding<Str>. field(...) attaches the label; .prompt(...) sets the placeholder shown while the field is empty:

use waterui::prelude::*;

fn username_field(username: &Binding<Str>) -> impl View {
    field("Username", username).prompt("Enter your name")
}

For rich text editing, bind a StyledStr directly. TextField::new maps a plain Binding<Str> internally and panics if a backend writes styled text back into it, so use TextField::styled whenever styling is possible:

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

fn rich_field(value: &Binding<StyledStr>) -> impl View {
    TextField::styled(value).label("Notes")
}

.selection_menu(...) adds custom entries to the native text-selection menu. It accepts any MenuView — usually a tuple of buttons:

use waterui::prelude::*;

fn field_with_menu(value: &Binding<Str>) -> impl View {
    field("Snippet", value).selection_menu((
        button("Uppercase").action(|| {}),
    ))
}

.line_limit(n) caps the field at n lines and .disable_line_limit() removes the cap entirely; the default is a single line. A capped field refuses an edit that would push it past the limit rather than truncating what is already there, and a multi-line field reports the multi-line text-input role to assistive technology. .keyboard(...) picks the on-screen keyboard variant; platforms without a software keyboard ignore the hint.

Menu shows a popup of commands when its label is activated. The content is any MenuView: buttons, Command values, Divider, and nested Menus, most often written as a tuple.

use waterui::prelude::*;

fn options_menu(pinned: &Binding<bool>) -> impl View {
    Menu::new(
        "Options",
        (
            button("Copy").action(|| {}),
            Command::builder("Paste")
                .action(|| {})
                .shortcut(Shortcut::new("v").command()),
            Command::builder("Pin to top")
                .action(|State(pinned): State<Binding<bool>>| {
                    let mut pinned = pinned.get_mut();
                    *pinned = !*pinned;
                })
                .state(pinned)
                .selected(pinned.clone()),
            Divider,
            Menu::new("More", (button("Reset").action(|| {}),)),
        ),
    )
}

A plain Button converts into a menu command automatically, which is why the first entry works. Command is the direct form, and it carries metadata a button cannot: .shortcut(...) for a key equivalent, and .selected(signal) for a checked item. Command::state(&value) injects state for the command’s action, mirroring .state() on views.

Native menus draw each entry from its label’s semantic text, and only a SystemIcon carries through. A custom icon view still renders in the self-drawn popup menus but is dropped by the native ones.

Disabling controls

Disabled state is a property of the surrounding context, not of an individual control. .disabled(...) from ViewExt works on any view — there is no per-control disabled builder to learn, and no control can forget to honour it:

use waterui::prelude::*;

fn save_button(is_saving: &Binding<bool>) -> impl View {
    button("Save").action(|| {}).disabled(is_saving.clone())
}

The modifier installs a Disabled scope in the environment, stops the subtree from hit-testing, and reports the disabled state to assistive technology. Every control reads the state in force at its own position, the same way it reads a theme color. Nested scopes OR-combine: a control is disabled while any enclosing scope is true.

use waterui::prelude::*;
use waterui::component::slider;

fn audio_panel(locked: &Binding<bool>, muted: &Binding<bool>, volume: &Binding<f64>) -> impl View {
    vstack((
        toggle("Mute", muted),
        slider("Volume", volume),
    ))
    .disabled(locked.clone())
}

Flipping locked re-enables the panel without rebuilding it — the combined signal is tracked reactively.

A menu Command is the one place the state travels as data rather than as context: a menu is a list of command records handed to the platform’s menu API, not a rendered subtree, so there is no leaf environment to read from. Command::disabled(...) still combines with an enclosing scope when the command resolves.

Reference

ControlConstructorValueStretch axis
Buttonbutton(label) / Button::new(Label)action closureNone
Toggletoggle(label, &b) / Toggle::new(&b)Binding<bool>Horizontal
Sliderslider(label, &b) / Slider::new(Label, &b)Binding<f64>Horizontal
Stepperstepper(label, &b) / Stepper::new(Label, &b)Binding<i32>Horizontal
TextFieldfield(label, &b) / TextField::styled(&b)Binding<Str> / Binding<StyledStr>Horizontal
MenuMenu::new(label, items)action closuresNone

A control that stretches horizontally places its label on the leading edge and its interactive part on the trailing edge, with flexible space between. Button and Menu size themselves to their label.

Selection controls — Picker, DatePicker, ColorPicker, SecureField — live in the form crate. The next chapter covers them, along with the #[form] derive that generates an entire editing UI from a Rust struct.

Forms and data entry

In this chapter, you will:

  • Generate a whole form UI from a Rust struct with #[form]
  • Know exactly which Rust types map to which control
  • Reach for pickers, calendars, and secure fields when the mapping is not enough
  • Compose validators and understand what the validation surface does not yet cover
  • Build a registration form end to end

WaterUI generates form controls from your data structures. Derive one attribute and a struct becomes an editable form; every field gets a control chosen by its type, a label derived from its name, and a binding wired straight back into the struct.

A Hydrolysis preview of stable WaterUI data-entry controls used by forms. Example source.

The FormBuilder trait

FormBuilder maps a type to a view that edits 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()
    }
}

The derive macro implements it for your struct by projecting the struct binding into per-field bindings and calling FormBuilder::view on each field type.

The #[form] attribute

#[form] derives Default, Clone, Debug, FormBuilder, and Project in one step. Project is what supplies the per-field bindings, so FormBuilder cannot be derived without it:

use waterui::prelude::*;

#[form]
pub struct UserProfile {
    /// Display name
    pub name: String,
    /// Account active status
    pub active: bool,
    /// User's current level
    pub level: i32,
}

Render it with form():

use waterui::prelude::*;

#[form] pub struct UserProfile { pub name: String }
fn profile_editor() -> impl View {
    let profile = UserProfile::binding();
    form(&profile)
}

UserProfile::binding() starts from Default. To pre-fill, build the binding yourself:

use waterui::prelude::*;

#[form] pub struct UserProfile { pub name: String }
fn edit_profile(initial: UserProfile) -> impl View {
    let profile = Binding::container(initial);
    form(&profile)
}

Type-to-control mapping

FormBuilder is implemented for exactly these types:

Rust typeControlNotes
StringTextFieldDoc comment becomes the prompt
StrTextFieldWaterUI’s interned string type
boolToggle
i32StepperRange i32::MIN..=i32::MAX
f64SliderRange 0.0..=1.0
f32SliderMapped through f64, same range
ColorColorPickerPlatform-native color selector

Any other field type — u32, i64, Option<T>, an enum, a nested struct — has no FormBuilder impl and will not compile inside a derived form. Write a manual implementation for those, or narrow the field to one of the types above.

The macro converts each field name from snake_case to "Title Case" for the label, and joins the field’s doc comment into the placeholder argument. Only TextField currently uses the placeholder; the other controls ignore it.

Manual implementations

When you need a custom layout, a field type outside the table, or a control the mapping cannot express, implement FormBuilder yourself. Project still does the heavy lifting:

use waterui::prelude::*;
use waterui::component::TextField;
use waterui::form::secure::{Secure, SecureField, secure};
use waterui::layout::stack::VStack;

#[derive(Clone, Project)]
struct LoginForm {
    username: String,
    password: Secure,
}

impl FormBuilder for LoginForm {
    type View = VStack<((TextField, SecureField),)>;

    fn view<L: IntoLabel>(binding: &Binding<Self>, label: L, placeholder: Str) -> Self::View {
        let projected = binding.project();
        vstack((
            <String as FormBuilder>::view(&projected.username, label, placeholder),
            secure("Password", &projected.password),
        ))
    }
}

vstack(contents) returns VStack<(C,)>, which is why the associated type wraps the field tuple one level deeper than you might expect.

Controls beyond the mapping

These compose into derived forms as well as hand-built ones. Every one of them takes a label at construction, because assistive technology needs something to announce; use .hide_label() when the label should not be visible.

Picker

Each item is a text(label).tag(value) pair — the label is shown, the tag is written into the binding. The value type must be Ord + Clone:

use waterui::prelude::*;
use waterui::form::{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 takes impl IntoComputed<Vec<PickerItem<T>>>, so a reactive item list is a Computed<Vec<_>> rather than an array. Item labels re-resolve when the locale changes.

StyleAppearance
AutomaticPlatform default
MenuDropdown menu button
RadioVertical radio group
SegmentedHorizontal mutually exclusive segments

.segmented() is shorthand for .style(PickerStyle::Segmented).

Date picker

DatePicker::new(label, binding) dispatches on the binding’s type — jiff::civil::Date, Time, or DateTime — and picks a matching default layout. It stores a full DateTime internally so hidden components survive a round trip:

use waterui::prelude::*;
use waterui::form::picker::date::{DatePicker, DatePickerType};
use jiff::civil::Date;

fn birthday_picker(date: &Binding<Date>) -> impl View {
    DatePicker::new("Birthday", date).ty(DatePickerType::Date)
}

DatePickerType is Date, HourAndMinute, HourMinuteAndSecond, DateHourAndMinute (the default), or DateHourMinuteAndSecond. .range(start..=end) clamps the binding into the allowed span.

For a month grid instead of a spinner, Calendar::new(label, &date, &visible_month) renders a selectable calendar; .decorated(dates) marks days with a passive dot, and the caller owns the visible month so navigation state stays outside the view. MultiDatePicker covers multi-selection over a BTreeSet<Date>.

Color picker

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 and .with_hdr() enables HDR selection.

File picker

waterui::form::picker::file::FilePicker binds a Vec<Url>. FilePicker::open(label, &binding) references files in place; FilePicker::import(label, &binding) copies them into your app’s storage. .max_count(n) caps the selection.

Secure field

SecureField masks its input and stores it in a Secure, which zeroes its buffer on drop and redacts itself in Debug output:

use waterui::prelude::*;
use waterui::form::secure::{Secure, secure};

fn password_field(password: &Binding<Secure>) -> impl View {
    secure("Password", password)
}

Secure::expose() returns the raw &str and Secure::hash() produces a bcrypt hash at the default cost.

Warning: Never persist or transmit the exposed string. Hash it first.

A registration form

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(),
        form(&form_data),
        button("Register")
            .bordered_prominent()
            .action(|State(data): State<Binding<Registration>>| {
                let registration = data.get();
                waterui::log::info!(
                    username = %registration.username,
                    "registration submitted"
                );
            })
            .state(&form_data),
    ))
}

Reading data.get() inside the action handler is fine — handlers run in response to an event, not during body evaluation. Calling .get() in a view body is what breaks reactivity.

Reading form data

Project the binding to reach individual fields, and pass the projected bindings — not their current values — into views so they stay live:

use waterui::prelude::*;

#[form] pub struct Registration { pub username: String }
fn show_summary() -> impl View {
    let form_data = Registration::binding();
    let projected = form_data.project();

    vstack((
        text(projected.username.clone()),
        text!("Name: {username}", username = projected.username.clone()),
    ))
}

Validation

The Validator<T> trait has one method, validate(&self, value: T) -> Result<(), Self::Err>, plus .and() and .or() combinators. Three implementations ship with the crate:

ValidatorValidates
Range<T>start..end, exclusive end, for T: Display + Debug + Ord + Clone
RegexAny AsRef<str> matches the pattern
RequiredOption<T> is Some; &str/Str/String is non-blank
use waterui::prelude::*;
use waterui::form::valid::Validator;
use regex::Regex;

fn check() {
    let age = 18i32..100;
    assert!(age.validate(42).is_ok());

    let email = Regex::new(r"^[^@]+@[^@]+\.[^@]+$")
        .expect("email validator regex must compile");
    assert!(email.validate("[email protected]").is_ok());
    assert!(email.validate("not-an-address").is_err());
}

a.and(b) short-circuits on the first failure; a.or(b) succeeds if either passes and reports both errors otherwise.

Wiring a validator to a control

ValidatableView::new(view, validator) filters the view’s binding so invalid values are never committed, and renders the error message underneath. It requires the view to implement Validatable, which exposes the binding to be filtered:

pub trait Validatable: View + Sized {
    type Value;
    fn validable(&mut self) -> &mut Binding<Self::Value>;
}

TextField implements it, so a validated field is one call:

use waterui::prelude::*;
use waterui::component::text_field::TextField;
use waterui::form::valid::{Plain, Required, ValidatableView};

fn name_field(name: &Binding<Str>) -> impl View {
    ValidatableView::new(TextField::new(name).label("Name"), Plain(Required))
}

A text field is backed by StyledStr, while validators are naturally written against plain text. Plain(v) lifts any Validator<Str> onto a styled field, so one plain-text rule works on a text input without every validator needing a second styled implementation.

Where to go next

Forms collect structured data. Displaying collections back to the user is the next chapter, which covers lazy lists, sections, and reactive collections.

Lists and collections

In this chapter, you will:

  • Render dynamic collections lazily with List::for_each and #[derive(Identifiable)]
  • Compose static, heterogeneous, sectioned lists with List::content and Section
  • Drive fine-grained updates with waterui::reactive::collection::List
  • Jump to any row programmatically with a ScrollController<usize>
  • Animate items in and out with collection_transition

A collection grows and shrinks at runtime, so it cannot be written as a fixed tuple of views. WaterUI splits the job in two: List::for_each renders an identity-keyed collection lazily, and List::content composes a known set of rows with section chrome. Both produce the platform’s native list surface — an inset-grouped UITableView on iOS, an NSTableView with group rows on macOS, a Material list on Android.

A Hydrolysis preview of sectioned WaterUI lists. Example source.

A first dynamic list

List::for_each takes a collection and a generator returning one ListItem per element:

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListItem};

#[derive(Clone, Identifiable)]
struct TodoItem {
    #[id]
    id: i32,
    title: String,
    done: bool,
}

fn todo_list(items: [TodoItem; 3]) -> impl View {
    List::for_each(items, |item| {
        ListItem::new(hstack((
            text(item.title),
            spacer(),
            if item.done { text("Done") } else { text("Pending") },
        )))
    })
}

The list renders lazily: only rows inside the viewport are materialized, and that stays true after a programmatic jump into the middle of a hundred-thousand-row collection. The generator runs once per row that actually becomes visible, so keep it cheap — push expensive work into a Computed or an async task.

Prefer arrays for fixed collections. A known set of items is [a, b, c]; pass it straight into any API that accepts Collection or IntoIterator. Reach for vec! only when the length is decided at runtime, when the collection needs mutation, or when the API specifically requires a Vec.

Identity

for_each requires C::Item: Identifiable so the framework can diff membership by id instead of rebuilding every row:

use core::hash::Hash;
pub trait Identifiable {
    type Id: Hash + Ord + Clone;
    fn id(&self) -> Self::Id;
}

The derive marks exactly one field with #[id]:

use waterui::Identifiable;

#[derive(Clone, Identifiable)]
struct Contact {
    #[id]
    id: u64,
    name: &'static str,
}

#[derive(Clone, Identifiable)]
struct Article<Key> {
    #[id]
    slug: Key,
    title: &'static str,
}

#[derive(Clone, Copy, Identifiable)]
struct ContactId(#[id] u64);

Structs only — enums and unions are rejected. Exactly one field must carry #[id]; zero or two is a compile error, and so is putting #[id] on the type. The generated id() clones the field, so its type must be Hash + Ord + Clone; the derive adds that bound for you.

Warning: Identity must be stable. Changing an item’s id makes the framework treat it as a removal followed by an insertion, discarding that row’s view state instead of updating it in place.

Reactive collections

A plain array or Vec describes data that never changes. For data that does, use waterui::reactive::collection::List: every mutation emits a fine-grained change notification, and the rendered rows patch by id rather than rebuilding.

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.insert(0, TodoItem { id: 2, title: "Urgent".into(), done: false });
    items.remove(0);

    // One diffed update instead of N individual mutations.
    items.replace(vec![
        TodoItem { id: 3, title: "Write docs".into(), done: false },
    ]);
}

replace(Vec<T>) swaps the whole contents in a single diffed update and returns the old contents; snapshot() reads the current contents as a Vec without subscribing. sort(), pop(), clear(), and iter() behave as you would expect.

Note: The rendering surface and the data structure are both called List. This chapter aliases the data type as ReactiveList and keeps the bare List for the view.

Never watch a signal that holds a Vec and rebuild a stack from it. watch replaces the entire watched subtree and discards its state on every change; a reactive collection plus for_each patches only the rows that actually moved.

Sectioned and heterogeneous lists

List::for_each is homogeneous and section-free by design — that is what makes viewport-only rendering possible. When rows carry section headers, or when the rows are a fixed heterogeneous set, use List::content with Section:

use waterui::prelude::*;
use waterui::component::list::{List, Section, detail_row, row};

fn settings(status: &Binding<Str>, endpoint: &Binding<Str>) -> impl View {
    List::content((
        Section::new("Connection").content((
            row("Status", status.clone()),
            row("Endpoint", endpoint.clone()),
        )),
        Section::new("Activity")
            .footer("Updated every poll")
            .content((
                row("Polls", "128"),
                detail_row("Last error", "connection reset by peer"),
            )),
    ))
}

ListContent is a closed trait: it accepts ListItem, Row, Section<C>, tuples up to 15 elements, arrays, Vec<T>, Option<T>, and closures returning a ListItem. Nothing else can leak into a list row by accident.

row(label, value) builds a single-line label ……… value row; detail_row(label, value) stacks the value under the label at full width. Row exposes .detail(), .inline(), .value_color(token), and .deletable(false). Labels go through IntoLabel and values through IntoText, so both participate in localization and the accessibility tree.

Section::new(header) labels a group, Section::unlabeled() produces group chrome with no header, and .footer(text) appends a caption below it. A section that produces no items is dropped silently.

Under the hood a section is a marker on the first ListItem of the group (ListItem::section(ListSection)); backends translate it into UITableView section headers, NSTableView group rows, or Material dividers without any extra FFI surface.

Editing: delete and reorder

editing, on_delete, and on_move turn a list into an editable one. The handlers receive ListDelete and ListMove as extractor parameters, alongside any State<T> you attach:

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListDelete, ListItem, ListMove};
use waterui::reactive::collection::List as ReactiveList;

#[derive(Clone, Identifiable)] struct Contact { #[id] id: i32 }
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(|State(items): State<ReactiveList<Contact>>, ListDelete(index): ListDelete| {
            items.remove(index);
        })
        .on_move(|State(items): State<ReactiveList<Contact>>, ListMove(movement): ListMove| {
            let item = items.remove(movement.from());
            items.insert(movement.to(), item);
        })
        .state(&items)
}

Move exposes .from() and .to(). Per-row refinement goes on the item: ListItem::new(view).deletable(false) opts a single row out of swipe-to-delete.

Programmatic scrolling

A ScrollController<usize> targets item indices and pairs with List; a ScrollController<Point> targets coordinates and pairs with ScrollView. Both are plain values you own and can hold anywhere:

use waterui::prelude::*;
use waterui::component::list::{List, ListItem};
use waterui::component::scroll::ScrollController;

use waterui::Identifiable;
#[derive(Clone, Identifiable)] struct Entry { #[id] id: usize }
fn jump_list(entries: waterui::reactive::collection::List<Entry>) -> impl View {
    let scroll_to = ScrollController::<usize>::new(0);

    vstack((
        button("Jump to 50,000").action({
            let scroll_to = scroll_to.clone();
            move || scroll_to.scroll_to(50_000)
        }),
        List::for_each(entries, |entry| ListItem::new(text!("Row {id}", id = entry.id)))
            .scroll_controller(&scroll_to),
    ))
}

Because for_each is lazy, a jump of fifty thousand rows materializes only the rows that land in the viewport. The controller tracks a monotonically increasing generation alongside the target, so requesting the same index twice still scrolls after the user has moved away.

For coordinate scrolling, hand a ScrollController<Point> to scroll(...):

use waterui::prelude::*;
use waterui::component::scroll::ScrollController;

fn scrolled_content(body: impl View) -> impl View {
    let viewport = ScrollController::<Point>::new(Point::zero());
    scroll(body).scroll_controller(&viewport)
}

Animating membership changes

collection_transition(content, animation) scopes a request into the environment: every reactive collection inside content fades and grows items in as they appear, and fades and collapses them out as they leave. Backends without support ignore it and render the collection normally.

use waterui::prelude::*;
use waterui::animation::Animation;
use waterui::component::list::{List, ListItem};
use core::time::Duration;

use waterui::Identifiable;
#[derive(Clone, Identifiable)] struct Contact { #[id] id: i32, name: String }
fn animated(contacts: waterui::reactive::collection::List<Contact>) -> impl View {
    collection_transition(
        List::for_each(contacts, |c| ListItem::new(text(c.name))),
        Animation::ease_in(Duration::from_millis(200)),
    )
}

Lazy stacks without list chrome

List gives you platform list chrome. When you want a lazy scrolling stack with none of it, Lazy wraps a LazyContainer in a scroll view:

use waterui::prelude::*;
use waterui::component::lazy::Lazy;

use waterui::Identifiable;
#[derive(Clone, Identifiable)] struct Photo { #[id] id: u64, caption: String }
fn gallery(photos: [Photo; 4]) -> impl View {
    Lazy::for_each(photos, |photo| text(photo.caption))
}

Lazy::vstack, Lazy::hstack, and the spaced variants Lazy::vstack_spaced / Lazy::hstack_spaced take any Views implementation directly.

For a genuinely static, small set of views, collecting an iterator into a stack is enough — but there is no virtualization, so every item is laid out at once:

use waterui::prelude::*;

fn fruit_list() -> impl View {
    let stack: VStack<_> = ["Apple", "Banana", "Cherry"].into_iter().map(text).collect();
    stack
}

Building a complete list

use waterui::prelude::*;
use waterui::Identifiable;
use waterui::component::list::{List, ListItem};
use waterui::reactive::collection::List as ReactiveList;

#[derive(Clone, Identifiable)]
struct Contact {
    #[id]
    id: i32,
    name: String,
}

fn contacts_screen() -> impl View {
    let contacts = 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(),
        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.get_mut() += 1;
                },
            )
            .state(&contacts)
            .state(&next_id),
        List::for_each(contacts, |contact| ListItem::new(text(contact.name))),
    ))
}

Exercise: add deletion. Attach .on_delete(...) as shown above, then compare it with a per-row button that captures the contact’s id — the first gets platform swipe gestures for free, the second works in a plain Lazy::for_each stack too.

Choosing between the surfaces

You haveUse
An identity-keyed collection that changes at runtimeList::for_each over a ReactiveList
A fixed set of rows with headers, footers, or mixed shapesList::content with Section / row
A large collection with no list chromeLazy::for_each
A handful of views that never changevstack / collect()

Lists show data. Showing different views depending on a condition — a spinner while data loads, a login prompt when the user is signed out — is the next chapter.

Conditional rendering

In this chapter, you will:

  • Swap views reactively with when, .or(), and .otherwise()
  • Derive boolean conditions from signals without calling .get()
  • Understand that a branch switch destroys the old subtree — and why that is correct
  • Choose between when, .visible(), a signal-taking API, and a match + .anyview()

Rust’s if/else runs once, while the view tree is being built. when builds a reactive branch instead: the condition is a signal, and the rendered branch follows it.

Basic usage

when takes a reactive boolean and a builder closure for the true case:

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

With no .otherwise(), a false condition renders nothing.

Fallbacks and chains

.otherwise() supplies the false branch:

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

.or() adds further branches, and the chain must be closed 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"))
}

Conditions are checked in order and the first match wins. The chain compiles into a single combined Computed<Option<usize>> — the index of the matching branch — so adding branches costs one more zipped signal, not one more subscription per rendered view.

Building conditions

when accepts anything implementing IntoComputed<bool>.

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

fn examples(show: &Binding<bool>, count: &Binding<i32>, name: &Binding<Str>) -> impl View {
    vstack((
        // A boolean binding directly.
        when(show.clone(), || text("Visible")),
        // Negation: Binding<bool> implements Not and yields a new signal.
        when(!show.clone(), || text("Hidden content revealed")),
        // Any derived Computed<bool>.
        when(count.map(|n| n > 0).computed(), || text("Count is positive")),
        // SignalExt comparison helpers.
        when(name.is_empty(), || text("Please enter your name")),
        when(count.equal_to(42), || text("The answer")),
    ))
}

Never call .get() to build a condition. .get() reads a value once and drops the dependency, so the branch freezes at construction time. .map(), .equal_to(), .is_empty(), and the rest of SignalExt keep the dependency intact.

Static conditions fold away

A plain bool is also a signal. When every condition in a chain is a static bool, the matching branch is selected at construction time and the others are never built:

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

fn debug_only() -> impl View {
    when(cfg!(debug_assertions), || text("Debug mode"))
        .otherwise(|| text("Release mode"))
}

This is the pattern for feature flags and debug-only UI. Mixing one reactive condition into the chain disables the folding for the whole chain.

What a branch switch actually does

When lowers to Dynamic::watch over the combined branch-index signal. When the index changes:

  1. The previous subtree is removed.
  2. The new branch’s builder closure runs.
  3. The resulting view is inserted.

State owned inside a branch is discarded when that branch is replaced. This is deliberate, not a leak: a new branch is a new component instance, and WaterUI does not infer component identity from call position. Anything that must survive a toggle belongs to a Binding owned by the parent and passed in:

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

fn settings_panel() -> impl View {
    let show_advanced = Binding::bool(false);
    // Owned by the parent, so the value survives collapsing and re-expanding.
    let quality = Binding::f64(0.5);

    vstack((
        toggle("Show Advanced", &show_advanced),
        when(show_advanced.clone(), {
            let quality = quality.clone();
            move || {
                vstack((
                    text("Advanced Settings").headline(),
                    slider("Quality", &quality).range(0.0..=1.0),
                ))
            }
        }),
    ))
}

Accordion behaves the same way for the same reason — collapsing it discards the content’s state, because collapsing destroys the content.

Branch closures run every time their branch is entered, so keep them free of side effects and cheap to call.

When not to reach for when

when changes view structure. Most reactive UI does not.

The thing that changesUseNot
A displayed valuetext!("{status}")when / watch around two text() calls
A parameter of a live viewa signal-taking input, e.g. .blur(amount.clone())rebuilding the view
The membership of a collectionForEach / List over a reactive collectionwatch over a Vec
Whether a subtree is on screen but should keep its state.visible(signal)when
Which kind of view is on screenwhen / Dynamic::watch

watch(binding_of_vec, …) rebuilds and re-dispatches the entire watched subtree on every change, and can escalate into a full-window structural rebuild. A dynamic set of views is a collection, so render it with ForEach or List and let membership diff by id — see the lists chapter.

.visible() keeps the subtree alive

.visible(signal) from ViewExt does not swap anything. It drives opacity, hit-testing, and the accessibility hidden state from one signal, so the subtree stays mounted and keeps every piece of state it owns:

use waterui::prelude::*;

fn draft_banner(has_draft: &Binding<bool>) -> impl View {
    text("Draft saved").visible(has_draft.clone())
}

The trade-off is that the hidden subtree still costs layout and memory. Use .visible() for something that toggles often and must not lose state; use when for something that is genuinely absent.

Many branches: match plus .anyview()

Once each arm produces a different concrete view type, or the ladder grows past three or four rungs, a match over an enum reads better than a when chain. .anyview() erases the arms to a common type:

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

To make that reactive, wrap it in Dynamic::watch(mode_signal, render) — the same mechanism when uses, written directly. The state-loss rule is identical.

Quick reference

PatternPurpose
when(cond, || view)Show a view while the 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 while the binding is false
when(sig.equal_to(val), || view)Compare a signal to a value
view.visible(sig)Hide without unmounting
Dynamic::watch(sig, |v| …)Structural swap driven by any signal

The last piece of the UI puzzle is moving between screens. The next chapter covers navigation stacks, tabs, and split views.

Navigation

In this chapter, you will:

  • Push screens with NavigationStack, NavigationView, and NavigationLink
  • Model your screens as route data with NavigationPath and .destination(...)
  • Drive the stack from any handler with Navigator and observe destination lifecycle
  • Attach semantic chrome — titles, toolbar placements, search — and pick a transition
  • Lay out top-level structure with Tabs and NavigationSplitView

WaterUI keeps navigation state in Rust and projects it into the platform’s own container: UINavigationController on Apple platforms, fragments and Material chrome on Android, retained GPU pages in Hydrolysis. Your route list is the source of truth. Every change reaches the backend as one atomic transaction describing a retained prefix, a number of removals, and the destinations to insert — so the native stack cannot drift out of sync with your state.

A Hydrolysis preview of WaterUI navigation chrome and links. Example source.

The smallest stack

use waterui::prelude::*;

fn app() -> impl View {
    NavigationStack::new(NavigationView::new(
        "Library",
        NavigationLink::new("Open settings", || {
            NavigationView::new("Settings", text("Preferences"))
        }),
    ))
}

NavigationStack::new accepts a NavigationView, not an arbitrary view, because the root is a destination with its own bar. NavigationLink::new pairs a label with a builder closure that returns the destination; the closure runs only when the link is activated, so screens the user never opens cost nothing.

Note: NavigationLink needs a surrounding navigation context. A debug assertion fires if you place one outside a NavigationStack.

Any view becomes a destination through .title(...), which is NavigationView::new(title, content) with the content first:

use waterui::prelude::*;

fn detail(name: &'static str) -> NavigationView {
    vstack((text(name), text("Some detail content")))
        .title("Detail")
        .navigation_subtitle("Updated just now")
        .inline_title()
}

The title display mode controls how the bar renders that title:

Method / modeBehavior
NavigationTitleDisplayMode::Automatic (default)System decides — large on root, inline when pushed
.inline_title()Always a small inline title
.large_title()Large title that collapses on scroll

Set it explicitly with .navigation_title_display_mode(mode) when you hold the enum value in a variable. Following platform convention — large on root screens, inline on pushed detail screens — is what the automatic mode already does, so reach for the overrides only when your design departs from it.

Watch out: Text has its own inherent .title() with no arguments — that is the typography preset from the text chapter, not a navigation title. On a bare Text, use NavigationView::new("Title", text(…)) instead of text(…).title("Title").

Routes as data

Builder-closure links are fine for a one-off drill-down. Anything that needs deep links, “back to root”, or restoring where the user was should model destinations as values and let a NavigationPath<Route> hold them:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)]
enum Route {
    Article(u64),
    Settings,
}

fn app() -> impl View {
    let path = NavigationPath::<Route>::new();

    NavigationStack::with_path(
        path,
        NavigationView::new(
            "Library",
            vstack((
                NavigationLink::value("Read article 42", Route::Article(42)),
                NavigationLink::value("Open settings", Route::Settings),
            )),
        ),
    )
    .destination(|route| match route {
        Route::Article(id) => NavigationView::new("Article", text!("Article {id}")),
        Route::Settings => NavigationView::new("Settings", text("Preferences")),
    })
}

A route type must be Clone + PartialEq + 'static. PartialEq is what lets the stack compute the longest retained prefix between the old and new path, so pushing one route rebuilds one screen instead of the whole stack. The destination closure is total over your enum, so the compiler tells you when a new variant has no screen.

NavigationLink::value reads the surrounding Navigator out of the environment and pushes the value when tapped — no closure, no manual wiring.

NavigationPath is itself a shared reactive value. Clone it to hand the same path to another part of your app; do not wrap it in a Binding. To start deeper than the root, build it from a Vec:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)] enum Route { Article(u64), Settings }
fn resumed_stack() -> NavigationPath<Route> {
    NavigationPath::from(vec![Route::Settings, Route::Article(7)])
}

Driving the path

Mutate the path directly, or extract a Navigator<Route> inside any handler:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)] enum Route { Article(u64), Settings }
fn controls() -> impl View {
    vstack((
        button("Settings").action(|navigator: Navigator<Route>| {
            navigator.push(Route::Settings);
        }),
        button("Back").action(|navigator: Navigator<Route>| {
            let _ = navigator.pop();
        }),
        button("Home").action(|navigator: Navigator<Route>| navigator.pop_to_root()),
        button("Jump").action(|navigator: Navigator<Route>| {
            navigator.replace([Route::Settings, Route::Article(7)]);
        }),
    ))
}

pop returns Option<Route> — the route that was removed, or None at the root — and it is #[must_use], so discard it explicitly when you only want the side effect. replace is not a loop of pushes and pops: it diffs against the current path and emits a single transaction, which means one animation instead of a stutter of them.

The same operations exist on the path itself (push, pop, pop_n, truncate, clear, replace) for code that holds the path but is not inside a view handler.

Native back gestures

The iOS back swipe, the macOS back button, and Android’s predictive back all originate in the platform, not in your Rust code. The backend routes them through the navigation controller, which mutates your NavigationPath before or after the native transition — so after any native back, the path still describes what is on screen. There is no second, hidden copy of the stack to reconcile.

That gives you a place to intervene. A destination can refuse to be popped, and can observe the attempt either way:

use waterui::prelude::*;

fn checkout(has_unsaved_changes: Computed<bool>) -> NavigationView {
    let can_leave = has_unsaved_changes.map(|dirty| !dirty);

    NavigationView::new("Checkout", text("Review your order"))
        .navigation_pop_enabled(can_leave)
        .on_navigation_pop_attempted(|| tracing::debug!("user tried to leave checkout"))
}

Destination lifecycle

Four hooks fire on a NavigationView, and the distinction between the last two matters:

HookFires when
.on_navigation_appear(h)The destination becomes the active screen
.on_navigation_disappear(h)It stops being active — including when something is pushed above
.on_navigation_pop_attempted(h)A user or system pop is requested, even if it is then denied
.on_navigation_pop(h)A pop actually completed and removed this destination

Use disappear to pause work such as a video or a poll, and pop for teardown that must not run when the screen is merely covered:

use waterui::prelude::*;

fn editor() -> NavigationView {
    NavigationView::new("Editor", text("Draft"))
        .on_navigation_appear(|| tracing::debug!("editor active"))
        .on_navigation_disappear(|| tracing::debug!("editor covered or left"))
        .on_navigation_pop(|| tracing::debug!("editor closed for good"))
}

Toolbar content is declared by semantic placement rather than by position. You say what an item means; the backend decides where it goes. Cancellation resolves to the leading edge of the bar and Confirmation to the trailing edge on both Apple and Android, while BottomBar and Status move to a bottom toolbar — none of which you write twice:

use waterui::prelude::*;

fn compose(save: fn(), cancel: fn()) -> NavigationView {
    NavigationView::new("New message", text("Message body"))
        .navigation_toolbar(NavigationToolbar::new(vec![
            NavigationToolbarItem::action(
                NavigationToolbarPlacement::Cancellation,
                "Cancel",
                cancel,
            ),
            NavigationToolbarItem::action(
                NavigationToolbarPlacement::Confirmation,
                "Save",
                save,
            ),
        ]))
}

The available placements are Principal, PrimaryAction, SecondaryAction, Confirmation, Cancellation, BottomBar, Status, TopBarLeading, and TopBarTrailing. NavigationToolbarItem::action(placement, label, handler) builds a button for you; NavigationToolbarItem::new(placement, view) takes arbitrary content.

Three more bar modifiers, each taking a signal so the bar updates without rebuilding the screen:

use waterui::prelude::*;
use waterui::reactive::binding;

fn browser(immersive: Computed<bool>) -> NavigationView {
    let query: Binding<Str> = binding("");

    NavigationView::new("Browse", text("Results"))
        .searchable(&query, "Search the library")
        .navigation_bar_visibility(immersive.map(|immersive| !immersive))
        .navigation_bar_color(Color::new(theme_color::Surface))
}

navigation_bar_visibility takes visible, not hidden. Leave navigation_bar_color off unless you deliberately want to override the platform material — without it, each backend already uses the surrounding Surface theme token and its native treatment.

Transitions

A stack’s transition is set once, on the stack:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)] enum Route { Settings }
fn faded(path: NavigationPath<Route>, root: NavigationView) -> impl View {
    NavigationStack::with_path(path, root)
        .destination(|_| NavigationView::new("Settings", text("Preferences")))
        .transition(navigation_transition::fade())
}

navigation_transition provides automatic() (the default platform push/pop), fade(), none(), and zoom(id). A zoom is a matched-geometry transition, so it needs both ends tagged with the same Id:

use waterui::prelude::*;
use waterui::id::Mapping;

#[derive(Clone, PartialEq, Eq)] enum Route { Photo }
fn gallery(path: NavigationPath<Route>) -> impl View {
    let ids = Mapping::new();
    let hero = ids.register("hero");

    NavigationStack::with_path(
        path,
        NavigationLink::value("Open photo", Route::Photo)
            .navigation_transition_source(hero)
            .title("Gallery"),
    )
    .destination(move |_| {
        NavigationView::new(
            "Photo",
            text("Full size").navigation_transition_destination(hero),
        )
    })
    .transition(navigation_transition::zoom(hero))
}

NavigationTransition is a trait, not a closed enum: implement frame(progress, direction) to define your own motion. Be aware of the asymmetry, though — a custom transition has no native projection, so Apple and Android apply the stack change without animation and log it as unsupported. Retained renderers such as Hydrolysis run your frame directly.

Tabs

Tabs carry stable identifiers so the backend can keep each tab’s root alive across switches, and each tab’s content builder returns a NavigationView — giving every tab an independent stack.

use waterui::prelude::*;
use waterui::id::Mapping;
use waterui::navigation::{Tab, Tabs, tab_style};

fn root(unread: Computed<i32>) -> impl View {
    let ids = Mapping::new();
    let inbox = ids.register("inbox");
    let settings = ids.register("settings");
    let selection = Binding::container(inbox);

    Tabs::new(
        selection,
        vec![
            Tab::new(inbox, "Inbox", || {
                NavigationView::new("Inbox", text("No messages"))
            })
            .badge(unread),
            Tab::new(settings, "Settings", || {
                NavigationView::new("Settings", text("Preferences"))
            }),
        ],
    )
    .style(tab_style::automatic())
}

selection is a Binding<Id>: read it to know which tab is active, write it to switch tabs from anywhere. .badge(signal) and .enabled(signal) both take signals, so a count or a lock state updates in place.

Presentation is an attribute, not a different type. tab_style::automatic() lets the platform and window size pick between a tab bar, a sidebar, and a navigation rail; tab_style::tab_bar() and tab_style::sidebar() request one explicitly.

Split view

NavigationSplitView is the two- or three-column layout behind mail clients and settings apps. You own the selection binding; the split view builds the detail column from whatever is selected.

use waterui::prelude::*;

fn mailbox(
    selection: Binding<Option<u64>>,
    visibility: Binding<NavigationSplitColumnVisibility>,
) -> impl View {
    let sidebar_selection = selection.clone();

    NavigationSplitView::new(
        &selection,
        move || {
            button("Select message 7")
                .action(|State(selection): State<Binding<Option<u64>>>| {
                    selection.set(Some(7));
                })
                .state(&sidebar_selection)
        },
        |id| NavigationView::new("Message", text!("Message {id}")),
    )
    .placeholder(|| text("Select a message"))
    .sidebar_width(ColumnWidth::new(240.0, 320.0, 480.0))
    .column_visibility(visibility)
    .style(split_style::prominent_detail())
}

ColumnWidth::new(min, ideal, max) gives the platform real resize constraints instead of one fixed number, and panics if they are not ordered 0 < min <= ideal <= max. column_visibility takes a signal over Automatic, All, DoubleColumn, or DetailOnly, so a “hide the sidebar” button is a write to a binding rather than a rebuild. For a three-pane layout with independent sidebar and content selections, use NavigationSplitView::three_column(sidebar_selection, content_selection, sidebar, content, detail).

On compact windows the native containers collapse to stack-style navigation on their own; you do not branch on screen size.

NavigationRouter turns an incoming URL into a complete path in one atomic replacement:

use waterui::prelude::*;
use waterui::Url;

#[derive(Clone, PartialEq, Eq)] enum Route { Article(u64), Settings }
fn open_deep_link(path: &NavigationPath<Route>, url: &Url) -> bool {
    let router = NavigationRouter::new(path.clone())
        .route(|url| (url.path() == "/settings").then_some(vec![Route::Settings]));

    router.open(url)
}

open returns false when no resolver claims the URL, and panics if two resolvers claim the same one — ambiguous routing is a bug, not something to resolve by precedence.

Path restoration is a separate concern from URL routing. Enable the navigation-restoration feature on waterui (or serde on waterui-navigation directly) and a typed NavigationPath<Route> serializes through ordinary serde, so you can persist where the user was and restore it on next launch.

Where to go next

Navigation completes the UI toolkit: text, layout, controls, forms, lists, conditional rendering, and now movement between screens. Part IV: Rich Content picks up media, maps, and web views — the components you drop inside the screens you just learned to connect.

Media: photos, video, and audio

In this chapter, you will:

  • Display network images with Photo, including reactive URLs and progressive decoding
  • Play video in one line with video() and video_player()
  • Own playback state in a PlaybackSession and drive it through a PlayerController
  • Build playlists, custom transport controls, and Live Photos
  • Let users pick media with the platform-native MediaPicker

The media stack does two jobs. For images it handles async fetching, progressive decoding, and GPU texture upload. For video and audio it gives you a playback session: an owned object that holds the playlist, the position, the volume, and every other piece of playback state, separate from the view that displays it.

Crates and imports

media is a default feature of waterui, and it turns on video with it:

[dependencies]
waterui = "*"                       # media + video are on by default

# Video types only, without the photo/picker stack:
# waterui = { version = "*", default-features = false, features = ["video"] }
CratePathContents
waterui-mediawaterui::mediaPhoto, LivePhoto, Media, MediaPicker, Image, plus re-exports of the video types
waterui-videowaterui::videoVideo, VideoPlayer, PlaybackSession, PlayerController, Playlist, MediaItem

waterui::media re-exports the video types it needs, so one import covers most apps:

use waterui::prelude::*;
use waterui::media::{LivePhoto, Media, Photo, PlaybackSession, Playlist, Video, VideoPlayer};

The prelude also re-exports these names, but spelling them out keeps chapters and examples greppable.


Displaying images with Photo

use waterui::media::Photo;

fn avatar() -> impl View {
    Photo::new("https://static.rust-lang.org/logos/rust-logo-512x512.png")
}

Photo::new takes impl IntoComputed<Url>. That covers a string literal, a Url, and — the interesting case — a signal. Passing a signal swaps the image when the URL changes without rebuilding the view:

use waterui::prelude::*;
use waterui::media::{Photo, Url};

fn hero(selected: Computed<Url>) -> impl View {
    Photo::new(selected).resizable()
}

.resizable() lets the decoded image stretch to the bounds its parent proposes; without it the image keeps its intrinsic pixel size. For a local file, use Photo::from_path("/path/to/image.png").

Load events

Photo reports load outcomes through on_event. Both waterui::media and waterui::media::photo export a type named Event, so alias the one you mean:

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: PhotoEvent| match event {
            PhotoEvent::Loaded => tracing::info!("image loaded"),
            PhotoEvent::Error(message) => tracing::error!("image failed: {message}"),
        })
}

PhotoEvent has exactly two variants: Loaded and Error(String).

Progressive decoding

Photo feeds the HTTP response into an ImageStreamDecoder as chunks arrive. The first decode attempt happens at 24 KB, then every 96 KB after that, for up to ten attempts. When the format supports it — JPEG, PNG, GIF, WebP, BMP, ICO, TIFF — a low-quality preview appears before the full image lands. There is nothing to configure.

Filters take signals

Filter modifiers come from FilterViewExt (the gpu feature, on by default) and accept anything convertible to an f32 signal, so a Binding drives them live:

use waterui::prelude::*;
use waterui::component::slider;
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("Blur radius", &blur).range(0.0..=20.0),
        slider("Saturation", &saturation).range(0.0..=2.0),
    ))
}

The full catalog — blur, brightness, contrast, saturation, exposure, gamma, vibrance — and how they collapse into one GPU pass is in Filters and Visual Effects.

Pixels you already have

Image is the GPU-backed view underneath Photo. Build one directly when the pixels come from somewhere other than a URL:

use waterui::media::Image;

let pixels: Vec<u8> = vec![255, 0, 0, 255]; // one red RGBA pixel
let red_dot = Image::new(pixels, 1, 1);

HDR sources decode to RGBA16F and are tone-mapped at draw time when the output surface is SDR.


Playing video

ComponentControlsUse for
Videonone (raw surface)custom player UI, background clips
VideoPlayerplatform-appropriate controlsordinary playback

Each has a free-function constructor for the single-item case:

use waterui::media::video::{video, video_player};

fn trailer() -> impl View {
    // Paused, with controls. The user starts it.
    video_player("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
}

fn ambient_background() -> impl View {
    // Autoplays, loops, no controls.
    video("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4")
}

video() starts playing as soon as the surface is ready; video_player() stays paused. Video loops by default (.loops(false) to stop at the end), and both accept .aspect_ratio(AspectRatio::Fit | Fill | Stretch). VideoPlayer hides its controls with .show_controls(false).


Playback sessions

Video::new and VideoPlayer::new do not take a URL. They take a PlaybackSession — the object that owns the playlist, the position, the volume, the track selections, and the transport state:

use waterui::media::{PlaybackSession, Playlist, Video};

let session = PlaybackSession::new(Playlist::single(
    "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
));
let controller = session.controller();
let view = Video::new(session);

This split is the point. The view is a projection of the session, not the owner of it — so playback state lives at whatever level of your app actually owns it, and the view that renders it can be rebuilt, moved between containers, or swapped from Video to VideoPlayer without resetting the stream. WaterUI has no hidden per-view state slots to lose; if a value must survive, you hold it, the same way you hold a Binding.

A session is mounted exactly once. session.controller() hands out a PlayerController that is cheap to clone, so pass one copy to every control that needs it.

Driving your own transport controls

use waterui::prelude::*;
use waterui::media::{AspectRatio, PlaybackSession, Playlist, Video};

fn custom_player() -> impl View {
    let session = PlaybackSession::new(Playlist::single(
        "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
    ));
    let controller = session.controller();

    let muted = controller.muted();
    let elapsed = controller.position().map(|position| position.as_secs()).computed();

    let play = controller.clone();
    let pause = controller.clone();
    let rewind = controller;

    vstack((
        Video::new(session).aspect_ratio(AspectRatio::Fit).loops(false),
        hstack((
            button("Play").action(move || play.play()),
            button("Pause").action(move || pause.pause()),
            button("Back 10s").action(move || {
                if let Err(error) = rewind.seek_relative(-10.0) {
                    tracing::warn!("seek rejected: {error}");
                }
            }),
            toggle("Mute", &muted),
            text!("{elapsed}s"),
        ))
        .spacing(12.0),
    ))
}

muted() returns the session’s own Binding<bool>, so the toggle and the player read and write the same state — no synchronization code. position() returns a Computed<Duration>, which maps into whatever the label needs.

The controller surface

Commands that can fail return Result<_, PlaybackError>; the rest return ().

CommandNotes
play / pause / stopstop pauses and returns to the start of the item
step_forward / step_backwardpause and move one frame
seek(Duration)errors outside the duration or live window
seek_relative(f64)signed seconds, clamped to the item bounds
seek_to_live_edge()errors for finite media
next() / previous() / seek_to_item(id)playlist navigation
set_repeat(RepeatMode) / set_shuffle(bool)traversal policy
replace_playlist / add_item / remove_item(id) / move_item(id, index)playlist editing

Reactive state comes back as signals you can hand straight to views:

GetterType
phase()Computed<PlaybackPhase>Idle, Preparing, Ready, Playing, Paused, Buffering, Ended, Failed
position() / duration()Computed<Duration> (duration is zero until known)
current_item_id() / current_item_index()Computed<MediaItemId> / Computed<usize>
track_catalog() / live_window()Computed<TrackCatalog> / Computed<Option<LiveWindow>>
volume() / muted() / playback_rate() / preserve_pitch()shared Bindings
subtitle_selection() / audio_track_selection() / video_track_selection()shared Bindings
repeat_mode() / shuffle_enabled()shared Bindings

Volume and mute are separate

Volume is a validated linear level in 0.0..=1.0 — constructing one outside that range panics — and muting is its own boolean binding, so a single value can never encode two unrelated states:

use waterui::media::Volume;

controller.volume().set(Volume::new(0.35));
controller.muted().set(true);       // volume is still 0.35 when unmuted

Volume::SILENT and Volume::FULL are the constants; the default is 0.5.

Playlists

Playlist is non-empty by construction, so there is no “nothing is playing” state to defend against:

use waterui::media::{PlaybackSession, Playlist, VideoPlayer};
use waterui::video::{Delivery, MediaItem, MediaMetadata};

fn episode_queue() -> impl View {
    let live = MediaItem::new(
        "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8",
        Delivery::Hls,
    )
    .metadata(MediaMetadata::new().with_title("Episode 1").with_artist("WaterUI"));

    let playlist = Playlist::new(
        live,
        [MediaItem::from(
            "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
        )],
    );

    VideoPlayer::new(PlaybackSession::new(playlist).autoplay())
}

Playlist::new(first, remaining) takes the first item separately to keep the invariant in the type system; Playlist::single(item) is the one-item case. remove_item refuses the final item with PlaybackError::CannotRemoveFinalItem rather than leaving an empty session.

MediaItem::from covers plain URLs with progressive delivery. Use MediaItem::new(url, Delivery::Hls | Delivery::Dash) for adaptive streams, and MediaMetadata (title, artist, album, artwork URL, duration) to populate the platform’s system media session and now-playing UI.

Repeat and shuffle live on the controller: RepeatMode::{Off, One, All}, and set_shuffle(true) traverses a stable order derived from item identity without reordering the playlist itself.

Buffering policy

PlaybackSession::new uses PlaybackPolicy::vod_default(). For a live stream, pass the realtime policy explicitly:

use waterui::media::{PlaybackSession, Playlist};
use waterui::video::{Delivery, MediaItem, PlaybackPolicy, Url};

fn live_session(stream: Url) -> PlaybackSession {
    PlaybackSession::with_policy(
        Playlist::single(MediaItem::new(stream, Delivery::Hls)),
        PlaybackPolicy::live_default(),
    )
}

NetworkPlaybackPolicy under it makes every bound explicit — maximum manifest and segment bytes, initial bandwidth estimate, buffer thresholds, live catch-up rate limits — so a hostile manifest cannot make the player allocate without limit.


Playback events

Video and VideoPlayer share one Event type:

use waterui::media::Event as VideoEvent;
use waterui::media::video::video_player;

fn player_with_events() -> impl View {
    video_player("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4")
        .on_event(|event: VideoEvent| match event {
            VideoEvent::ReadyToPlay => tracing::info!("ready"),
            VideoEvent::Buffering => tracing::info!("buffering"),
            VideoEvent::BufferingEnded => tracing::info!("resumed"),
            VideoEvent::Ended => tracing::info!("ended"),
            VideoEvent::Error { message } => tracing::error!("playback error: {message}"),
            _ => {}
        })
}
EventMeaning
ReadyToPlaythe current item can start
PlaybackStateChanged { playing }media time started or stopped advancing
Buffering / BufferingEndedplayback stalled on data, then resumed
BufferLevel { buffered_ms }buffered duration ahead of the playhead
PlaybackMetrics { metrics }periodic diagnostics: A/V drift, dropped frames, rebuffer counts
PictureInPictureChanged { active } / ExternalPlaybackChanged { active }presentation route changed
TimedMetadata { metadata }a container metadata event reached its timestamp
NextRequested / PreviousRequestedsystem or player UI asked to change item
Endedthe current item reached its end
Error { message }load or playback failure

For state you want to render, prefer controller.phase() over counting events: it is a signal, so it updates the exact view that reads it.


Live Photos

LivePhoto is composed in Rust from a Photo, a muted Video, and a long-press gesture — no per-platform live-photo primitive is involved, so it behaves the same anywhere photos and video work:

use waterui::media::{LivePhoto, Url};
use waterui::media::live::LivePhotoSource;

fn memory() -> impl View {
    LivePhoto::new(LivePhotoSource::new(
        Url::from_file_path_str("beach.heic"),
        Url::from_file_path_str("beach.mov"),
    ))
    .activation_duration_ms(400)
}

Press and hold for activation_duration_ms (250 ms by default) and the motion clip plays once over the still image, then the still returns. LivePhoto::new accepts impl IntoComputed<LivePhotoSource>, so the pair can come from a signal — usually one the MediaPicker below produced, or files your app ships.


The unified Media enum

When your data model can hold any of the three kinds, Media implements View and picks the component for you:

use waterui::media::{Media, Url};
use waterui::media::live::LivePhotoSource;

let feed = vec![
    Media::Image(Url::from("https://static.rust-lang.org/logos/rust-logo-512x512.png")),
    Media::Video(Url::from(
        "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4",
    )),
    Media::LivePhoto(LivePhotoSource::new(
        Url::from_file_path_str("beach.heic"),
        Url::from_file_path_str("beach.mov"),
    )),
];
VariantRenders as
Media::Image(url)Photo
Media::Video(url)video_player(url) — paused, with controls
Media::LivePhoto(source)LivePhoto

For a feed whose membership changes, render it with ForEach/List over a reactive collection so items diff by identity — see Lists and collections.


Audio

There is no audio-only component at this pin. An audio track is a MediaItem in a PlaybackSession, driven by the same PlayerController — and because a session only starts once a view mounts it, you still place a Video (with your own controls, and no visible surface to speak of) to host the session. Fill in MediaMetadata so the platform’s now-playing UI and lock-screen controls show the right title, artist, and artwork.

For a live microphone visualization, the waterui-visualizer crate provides Waveform. It is not re-exported through waterui, so add it as its own dependency:

use waterui_visualizer::{AudioCapture, waveform};

fn microphone_meter() -> impl View {
    waveform(AudioCapture::new()).sensitivity(1.5)
}

Constructing an AudioCapture has no side effects; recording starts when the first visualizer using it finishes GPU setup, and clones share that one recorder and sample buffer. Request Permission::Microphone (from waterkit-permission) and confirm it was granted before you show the waveform.


Media picker

MediaPicker renders as a button that opens the platform’s media selection dialog. It needs the std feature, which is on by default.

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)
        .label(text("Choose a photo"))
}

filter accepts a signal, so the allowed types can change while the view is live. The default label is “Select Media”.

FilterSelects
MediaFilter::Image / Video / LivePhotoone kind
MediaFilter::Any(vec)any of the listed filters
MediaFilter::All(vec)all conditions must match
MediaFilter::Not(vec)everything except the listed filters

The binding fills with a Selected. Borrow the payload with media(), or take ownership with load():

use waterui::media::Media;
use waterui::media::media_picker::Selected;

fn describe(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"),
    }
}

Platform notes

  • Video realization. Apple platforms bridge AVPlayer/AVKit. Every non-Apple target gets WaterUI’s own GPU video player, installed by export!() at compile time. This is a cfg decision, not a runtime switch: there is no environment variable to flip and no silent fallback between the two.
  • HDR video. The backend negotiates HDR and tone-maps to SDR when the output surface cannot display it. Applications do not configure the pipeline; to observe it, read VideoTrackInfo::is_hdr() from controller.track_catalog().
  • Still images. Apple platforms decode HEIF and AVIF through the system decoder; other platforms use the software decoder, which includes an AVIF path on desktop.
  • Live Photos work everywhere, because they are a Rust-side composition. Whether the picker can return one depends on the platform dialog.

Next: Maps and Location — embedding an interactive map, dropping annotations, and following the user’s position.

Maps and location

In this chapter, you will:

  • Place a map, set its region, and drop annotations
  • Drive the camera and the user-location marker from reactive signals
  • Read the device location through waterkit-location, permission first
  • Configure the GPU vector map that non-Apple platforms render

Feature flag: Maps live behind the map feature on waterui. Enable it in Cargo.toml (waterui = { version = "...", features = ["map"] }) so waterui::map is available.

Two crates are involved. waterui-map gives you the Map view and its geographic types; waterkit-location provides device location. The map crate re-exports the location crate, so you rarely need a second dependency:

use waterui::map::location;                  // the whole waterkit-location crate
use waterui::map::{Latitude, Location, Longitude, OutOfRange, Timestamp};

Coordinates and regions

Latitude and longitude are validated newtypes, not bare f64. Build a coordinate from degrees and handle the range error:

use waterui::map::{Coordinate, OutOfRange};

fn landmarks() -> Result<(Coordinate, Coordinate), OutOfRange> {
    let manhattan = Coordinate::from_degrees(40.7580, -73.9855)?;
    let tokyo = Coordinate::from_degrees(35.6762, 139.6503)?;
    Ok((manhattan, tokyo))
}

Coordinate::new is the infallible constructor for values that are already Latitude and Longitude – which is what a Location from the device hands you, so converting one never fails:

use waterui::map::{Coordinate, Location};

fn to_coordinate(location: &Location) -> Coordinate {
    Coordinate::from_location(location)
}
// `From<Location>` and `From<&Location>` do the same thing.

A Region is a center plus a span in degrees. Smaller deltas mean a tighter zoom:

use waterui::map::{Coordinate, Region};

fn midtown(center: Coordinate) -> Region {
    Region::new(center, 0.030, 0.050)
}

fn close_up(center: Coordinate) -> Region {
    Region::from_coordinate(center) // 0.05 x 0.05 degrees
}

Region implements From<Coordinate>, so coordinate.into() gives you that default span in one step. Region::default() sits at 0,0 with a 0.1-degree span – useful in examples, useless in an app.


Displaying a map

use waterui::View;
use waterui::map::{Coordinate, Map, Region};

fn city_map() -> impl View {
    let paris = Coordinate::from_degrees(48.8566, 2.3522).expect("valid coordinate");
    Map::new(Region::new(paris, 0.1, 0.1))
}

Map stretches on both axes, so it fills whatever space its parent offers. Constrain it with .size(width, height), .width(...), or .height(...), or let it fill the window under an absolute layer.

Every constructor takes impl IntoComputed<_>, so a plain value and a signal are both accepted – pass a Binding<Region> and the camera follows it:

ConstructorFree functionInput
Map::new(region)map(region)Region
Map::centered_on(coordinate)map_centered_on(coordinate)Coordinate, default zoom
Map::centered_on_location(location)map_centered_on_location(location)Location, default zoom
use waterui::View;
use waterui::map::{Map, Region};
use waterui::reactive::binding;

fn zoomable(region: Region) -> impl View {
    let region = binding(region);
    // Writing to `region` moves the camera; the map view is never rebuilt.
    Map::new(region)
}

Annotations

use waterui::View;
use waterui::map::{Annotation, Coordinate, Map, Region};

fn annotated_map() -> impl View {
    let sf = Coordinate::from_degrees(37.7749, -122.4194).expect("valid coordinate");
    let la = Coordinate::from_degrees(34.0522, -118.2437).expect("valid coordinate");
    let center = Coordinate::from_degrees(36.0, -120.0).expect("valid coordinate");

    Map::new(Region::new(center, 5.0, 5.0)).annotations(vec![
        Annotation::new(sf, "San Francisco"),
        Annotation::new(la, "Los Angeles").subtitle("City of Angels"),
    ])
}

An Annotation carries a coordinate, a title: Str, and an optional subtitle: Option<Str>. Because .annotations() accepts impl IntoComputed<Vec<Annotation>>, search results or live vehicle positions can be pushed straight in from a binding:

use waterui::map::{Annotation, Map, Region};
use waterui::{Binding, View};

fn search_results(results: Binding<Vec<Annotation>>) -> impl View {
    Map::new(Region::default()).annotations(results)
}

Map styles

use waterui::map::{Map, MapStyle, Region};
use waterui::View;

fn satellite_view(region: Region) -> impl View {
    Map::new(region).style(MapStyle::Satellite)
}

MapStyle::Standard (the default) is a road map, Satellite is imagery, and Hybrid overlays roads on imagery.

Apple only. Satellite and Hybrid are honored by the native MapKit realization. The GPU vector realization used on other platforms panics on anything but Standard, because raster imagery needs a realization it does not have yet. On those platforms the map’s look comes from the MapLibre style you supply – see How your map is realized.


User location

Four builders touch the location marker, and they differ in who supplies the coordinates:

MethodEffect
.shows_user_location(true)Turns the marker on and lets the platform’s own location service feed it
.user_location(signal)Turns the marker on and draws Location values from your signal
.optional_user_location(signal)Same, but None draws no marker – the state to use while a permission prompt is pending
.follows_location(signal)Marker on, plus the camera re-centers on every new value
use waterui::map::{Location, Map, Region};
use waterui::{Binding, View};

fn tracking_map(location: Binding<Option<Location>>, region: Binding<Region>) -> impl View {
    Map::new(region).optional_user_location(location)
}

The signal is not optional off Apple. shows_user_location(true) alone leaves the location signal empty. MapKit fills that in from CoreLocation; the GPU realization has no platform location service to fall back on and simply draws nothing. Feed it user_location or optional_user_location if you want the marker everywhere.

Supplying the signal yourself is also what keeps camera following, the marker, and the horizontal-accuracy circle driven by one source instead of drifting apart.


Interaction and chrome

use waterui::map::{Map, Region};
use waterui::View;

fn thumbnail(region: Region) -> impl View {
    Map::new(region)
        .is_interactive(false) // no pan, no zoom -- good for a list cell
        .shows_compass(false)
        .shows_scale(false)
}

All three default to on. is_interactive(false) is respected everywhere: the GPU realization skips installing its drag and magnification gestures entirely. The compass and scale bar are MapKit chrome; the GPU realization draws neither, so treat them as an Apple refinement rather than a guarantee.


Reading the device location

Location::get() does not prompt. It assumes the permission is already granted, which means you ask first through waterkit-permission (a direct dependency – waterui does not re-export it):

use waterkit_permission::{Permission, request};
use waterui::map::Location;
use waterui::map::location::{LocationError, PermissionStatus};

async fn current_location() -> Result<Option<Location>, LocationError> {
    match request(Permission::Location).await {
        Ok(PermissionStatus::Granted) => Location::get().await.map(Some),
        Ok(status) => {
            tracing::warn!("location permission: {status:?}");
            Ok(None)
        }
        Err(error) => {
            tracing::error!("permission request failed: {error}");
            Ok(None)
        }
    }
}

request returns Granted, Denied, Restricted, or NotDetermined – on Android the last one persists until the host Activity applies the callback result, so treat “not granted” as a state to render, not an error to swallow.

A Location exposes its data through accessors. latitude() and longitude() return the Latitude/Longitude newtypes; call .get() for the underlying f64:

AccessorType
latitude() / longitude()Latitude / Longitude
altitude()Option<f64> meters above sea level
horizontal_accuracy() / vertical_accuracy()Option<f64> meters
timestamp()Timestamp

LocationError is #[non_exhaustive] with PermissionDenied, ServiceDisabled, Timeout, NotAvailable, InvalidCoordinate(OutOfRange), and Platform(String).


How your map is realized

You do not pick a map backend. Map is a semantic view, and whichever backend you build against realizes it:

  • Apple platforms bridge MKMapView from MapKit. Styles, compass, scale, and the CoreLocation-driven blue dot all come from the system.
  • Self-drawn backends (Hydrolysis and friends) install a GPU vector-map realization that fetches a MapLibre style and vector tiles and draws them with the same GPU pipeline as the rest of your UI. The backend installs it during bootstrap only when no native map hook is present, so app code neither imports it nor chooses it.

There is one seam you must handle: WaterUI hosts no tile service, so the GPU realization has nowhere to fetch from until you name a provider. Add waterui-map-gpu as a dependency – the waterui facade does not re-export it – and insert MapGpuOptions into the app environment with a MapLibre style URL:

use waterui::app::App;
use waterui::env::Environment;
use waterui::Url;
use waterui_map_gpu::MapGpuOptions;

pub fn app(mut env: Environment) -> App {
    env.insert(MapGpuOptions::new(Url::new(
        "https://tiles.openfreemap.org/styles/positron",
    )));
    App::new(root_view, env)
}

Realizing a GPU map without MapGpuOptions in the environment panics – fast failure by design, not a blank tile grid you have to debug. Insert it whenever your app targets a platform without a native map; on Apple the value is simply unused.

Beyond the style URL, MapGpuOptions is a builder over the resources the realization is allowed to consume: maximum_style_bytes, maximum_tilejson_bytes, maximum_tile_bytes, tile_cache_bytes, maximum_in_flight_tile_requests, request_timeout, network_retry_policy, and camera_animation. Defaults cover an ordinary app; reach for them when you are on a metered connection or a tight memory budget.

use std::num::NonZeroU64;
use std::time::Duration;
use waterui::Url;
use waterui_map_gpu::MapGpuOptions;

let options = MapGpuOptions::new(Url::new("https://tiles.openfreemap.org/styles/positron"))
    .tile_cache_bytes(NonZeroU64::new(32 * 1024 * 1024).expect("non-zero"))
    .request_timeout(Duration::from_secs(10));

MapNetworkRetryPolicy::new(attempts, initial_delay, maximum_delay) builds the backoff policy; the default is four attempts starting at 250 ms and capped at 4 s.


Putting it together

A map centered on Manhattan, with a button that requests permission and then moves the camera to the user:

use waterkit_permission::{Permission, request};
use waterui::map::location::PermissionStatus;
use waterui::map::{Coordinate, Location, Map, MapStyle, Region};
use waterui::prelude::*;
use waterui::reactive::binding;

fn located_map() -> impl View {
    let region = binding(Region::new(
        Coordinate::from_degrees(40.7580, -73.9855).expect("valid coordinate"),
        0.030,
        0.050,
    ));
    let user_location: Binding<Option<Location>> = binding(None);
    let status = binding(Str::from("Location not requested"));

    let map = Map::new(region.clone())
        .style(MapStyle::Standard)
        .optional_user_location(user_location.clone())
        .shows_compass(true)
        .shows_scale(true);

    let locate = button("Use my location")
        .action_async(
            |State(location): State<Binding<Option<Location>>>,
             State(region): State<Binding<Region>>,
             State(status): State<Binding<Str>>| async move {
                match request(Permission::Location).await {
                    Ok(PermissionStatus::Granted) => match Location::get().await {
                        Ok(value) => {
                            region.set(Region::from_coordinate(Coordinate::from(&value)));
                            location.set(Some(value));
                            status.set("Following your location".into());
                        }
                        Err(error) => {
                            tracing::error!("location request failed: {error}");
                            status.set("Location unavailable".into());
                        }
                    },
                    Ok(other) => status.set(format!("Permission: {other:?}").into()),
                    Err(error) => {
                        tracing::error!("permission request failed: {error}");
                        status.set("Permission request failed".into());
                    }
                }
            },
        )
        .state(&user_location)
        .state(&region)
        .state(&status);

    vstack((map, locate, text!("{status}").caption()))
}

Note what does not happen here: no watch, no rebuild. Writing to region moves the camera, writing to user_location moves the marker, and the Map view itself is constructed once. The repository’s examples/map is a fuller version of this, with floating zoom controls and a status panel.


What’s next

Next up: WebView, where you embed web content in your app – JavaScript bridges, cookie management, and navigation controls included.

WebView

In this chapter, you will:

  • Embed web content with the deferred, reactive WebView::open entry point
  • Drive navigation from a URL binding instead of imperative calls
  • Reach the imperative surface — refresh, history, JavaScript — through WebViewProxy
  • Bridge web code back into Rust with script injection and message handlers
  • Pick a browser engine in Water.toml, including the bundled Chromium runtime

Documentation pages, OAuth flows, and existing web apps are already written. waterui-webview embeds them in your native shell and gives you navigation, cookies, JavaScript execution, and a Rust bridge from the same view tree as the rest of your UI.

Feature flag: webview is not in the default feature set (gpu, assets, media, flow-markdown). Enable it explicitly:

waterui = { version = "0.2", features = ["webview"] }

The waterui::webview module — and its prelude re-export — only exist with that feature on.

Opening a web view

use waterui::prelude::*;
use waterui::webview::WebView;

fn docs_page() -> impl View {
    WebView::open("https://waterui.dev/docs")
}

WebView::open does not create a browser. It returns a WebViewOpen, a deferred description; the native handle is created when the view renders and a WebViewController can be pulled from the live environment. That deferral is what lets a web view be embedded in a gallery cell or a water preview run without an application-level hand-off.

The URL is a signal

open takes impl IntoComputed<Str>, so navigation is a state write rather than a method call:

use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::webview::WebView;

fn browser() -> impl View {
    let url: Binding<Str> = binding("https://waterui.dev");

    vstack((field("Address", &url), WebView::open(url)))
}

Writing a new URL navigates the existing native web view. The signal is retained for the native view’s lifetime, so nothing is torn down and no page state is lost — which is exactly why you must not wrap a web view in watch to change its address.

WebViewOpen carries two builders before it renders: .redirects_enabled(signal) for a reactive redirect policy, and .with_proxy(...), covered next.

Imperative controls with WebViewProxy

Refresh, stop, history navigation, and ad-hoc JavaScript have no natural reactive input. WebViewOpen::with_proxy renders your chrome above the web view and injects a WebViewProxy into that subtree’s environment, so any handler inside can take one as a parameter — the same extractor machinery that supplies State<T> to Button::action.

use waterui::prelude::*;
use waterui::webview::{WebView, WebViewProxy};

fn mini_browser() -> impl View {
    WebView::open("https://waterui.dev").with_proxy(|| {
        hstack((
            button("Back").action(|p: WebViewProxy| p.go_back()),
            button("Forward").action(|p: WebViewProxy| p.go_forward()),
            button("Refresh").action(|p: WebViewProxy| p.refresh()),
            button("Stop").action(|p: WebViewProxy| p.stop()),
        ))
    })
}

The proxy exposes go_back, go_forward, refresh, stop, run_javascript, inject_script, set_user_agent, set_redirects_enabled, set_cookie, get_cookies, and handle(). Extracting one outside a with_proxy scope fails with an error that names the fix, so a forgotten wrapper surfaces as a handler error instead of a silent no-op.

Owning the handle with WebViewController

Native backends install a WebViewController factory into the environment. Extract it when the surrounding view needs to hold the WebView value itself — to observe its event signal, or to hand it to several handlers as State<WebView>:

use waterui::env::use_env;
use waterui::prelude::*;
use waterui::webview::{WebView, WebViewController};

fn custom_browser() -> impl View {
    use_env(|controller: WebViewController| {
        let webview = controller.open();          // opens blank
        webview.go_to("https://book.waterui.dev");
        webview.set_user_agent("WaterUIBook/1.0");
        webview
    })
}

use_env panics when extraction fails, and backends without web support never install the controller. To degrade gracefully, look the controller up yourself inside a View::body and branch:

use waterui::prelude::*;
use waterui::webview::WebViewController;

struct Docs;

impl View for Docs {
    fn body(self, env: &Environment) -> impl View {
        match env.get::<WebViewController>().cloned() {
            Some(controller) => AnyView::new(controller.open()),
            None => AnyView::new(text("WebView is unavailable on this backend.")),
        }
    }
}

can_go_back() and can_go_forward() return Computed<bool> that track the native history, so they feed .disabled(...) directly:

use waterui::prelude::*;
use waterui::webview::WebView;

fn back_button(webview: &WebView) -> impl View {
    button("Back")
        .action(|State(w): State<WebView>| w.go_back())
        .state(webview)
        .disabled(webview.can_go_back().map(|ok| !ok))
}

Events

WebView::event() returns impl Signal<Output = WebViewEvent> covering the navigation lifecycle:

EventFieldsMeaning
NoneInitial state, before anything happens
WillNavigateurl: UrlNavigation is about to begin
Loadingprogress: f32Load progress, 0.0 to 1.0
LoadedThe page finished loading
Redirectfrom: Url, to: UrlA redirect occurred
ErrorWebViewErrorNavigation or loading failed

History-state changes are handled internally and never reach this signal; read can_go_back() / can_go_forward() for that.

WebViewError has three variants: Network(Str), Ssl { url, message }, and LoadFailed(Str).

Running JavaScript

run_javascript executes in the loaded page and resolves to Result<Str, Str>. It is async and main-thread affine, so drive it from action_async:

use waterui::prelude::*;
use waterui::webview::WebViewProxy;

fn title_probe(output: &Binding<Str>) -> impl View {
    button("Get title")
        .action_async(|p: WebViewProxy, State(out): State<Binding<Str>>| async move {
            match p.run_javascript("document.title").await {
                Ok(title) => out.set(title),
                Err(err) => out.set(Str::from(format!("JS error: {err}"))),
            }
        })
        .state(output)
}

Script injection

run_javascript runs after load. For code that must be present before the page’s own scripts, inject it instead — injected scripts re-run on every page load:

use waterui::webview::ScriptInjectionTime;

// Runs before the DOM exists: bridges, global setup, request interception.
proxy.inject_script(include_str!("bridge.js"), ScriptInjectionTime::DocumentStart);

// Runs after the document is ready: DOM edits, event listeners.
proxy.inject_script(
    "document.body.dataset.wateruiHost = 'native';",
    ScriptInjectionTime::DocumentEnd,
);

Keep non-trivial JavaScript in its own .js file and pull it in with include_str! rather than as an inline multi-line literal.

Calling Rust from JavaScript

Message handlers live on the type-erased handle, reachable as proxy.handle() or webview.handle():

webview.handle().add_handler("greet", Box::new(|data: &[u8]| {
    let name = String::from_utf8_lossy(data);
    tracing::info!(%name, "greet called from JavaScript");
    format!("Hello, {name}!").into_bytes()
}));

// Later:
webview.handle().remove_handler("greet");

The handler takes bytes and returns bytes. The JavaScript side differs per platform:

// Apple (WKWebView)
window.webkit.messageHandlers.greet.postMessage("World");

// Android
window.greet.postMessage("World");

Hide that asymmetry by shipping a small DocumentStart script that defines one API for your web code and dispatches to whichever host object exists.

Cookies

use waterui::webview::{Cookie, WebView};

fn set_session_cookie(webview: &WebView, session_token: String) {
    let cookie = Cookie::build(("session", session_token))
        .domain("book.waterui.dev")
        .path("/")
        .secure(true)
        .build();

    webview.set_cookie(cookie);
}

Cookie is the cookie crate’s type, re-exported as waterui::webview::Cookie (the crate itself is not re-exported, so import the type, not the module). Reading is asynchronous so the UI thread never blocks on the native cookie store:

for c in webview.get_cookies().await {
    tracing::info!(name = c.name(), value = c.value(), "cookie");
}

Choosing a browser engine

Water.toml selects the engine that backs WebView:

webview_backend = "default"   # default | system | wpe | cef
ValueEngineWhere it works
defaultBundled WPE on Linux, system engine everywhere else
systemPlatform web view (WKWebView, Android WebView, WebKitGTK)Apple platforms, Android, Linux + GTK4, web
wpeWaterUI’s bundled WPE WebKit runtimeLinux, with GTK4 or Hydrolysis
cefWaterUI’s bundled Chromium Embedded Framework runtimemacOS, Linux, Windows — any backend except Dew

CEF is independent of the WaterUI rendering backend: the engine choice and the renderer choice are separate axes. Dew excludes it deliberately, because a constrained-device target cannot carry the Chromium runtime.

An unsupported combination is a build error, not a silent downgrade. Selecting an engine also never adds a runtime on its own — the CLI links one only if your application actually links waterui-webview. On macOS the CLI generates the required waterui-cef-helper binaries and helper Info.plist for a CEF app automatically.

The shipped examples/webview-cef proves the point: it reuses examples/webview unchanged and differs only by webview_backend = "cef" in Water.toml.

When you want Chromium itself

waterui-chromium is a separate crate — not a WebView engine and not re-exported through the waterui facade. Depend on it directly when you need headless pages, screenshots, or the Chrome DevTools Protocol: it exposes Chromium, chromium(), ChromiumPage, ChromiumConfiguration, ChromiumProfile, ChromiumProxy, ScreenshotFormat, CdpSession, and typed CDP through the re-exported cdp / cdp_types modules. Keeping it out of waterui-webview keeps the Chromium dependency graph out of applications that only wanted an embedded page.

Sizing

WebView is a raw view declared with StretchAxis::Both, so it fills the space it is given. Constrain it with .size(width, height), .width(...), .height(...), or the surrounding layout.

Downcasting the handle

Backend authors and platform integrations can recover the concrete handle:

if let Some(native) = webview.handle().downcast_ref::<MyNativeHandle>() {
    // Configure platform-specific preferences.
}

Application code should not need this; if it does, the missing capability belongs on WebViewHandle.


Next: Barcodes and QR Codes, where the content is generated on the GPU instead of fetched from the network.

Barcodes and QR codes

In this chapter, you will:

  • Render QR codes and Code 128 barcodes from any string
  • Tint modules with solid colors, gradients, and live reactive colors
  • Fill a code with arbitrary GPU content through fill_gpu
  • Size a code so that scanners can actually read it

waterui-barcode encodes module data on the CPU once, packs it into a bit buffer, and rasterizes it in a fragment shader. There is no CPU rasterization path — every barcode is drawn on the GPU, so a code stays sharp at any size.

Feature flag: barcodes require the barcode feature on waterui (waterui = { version = "...", features = ["barcode"] }). The crate is then re-exported as waterui::barcode.

A QR code rendered from the pinned WaterUI barcode component. Example source.

Quick start

use waterui::prelude::*;
use waterui::barcode::Barcode;

fn share_link() -> impl View {
    Barcode::qr("https://book.waterui.dev").size(280.0, 280.0)
}

fn product_label() -> impl View {
    Barcode::code128("WATERUI-BOOK").size(250.0, 92.0)
}

Barcode::qr and Barcode::code128 both return a Barcode, which implements View. The free functions qr_code(content) and code128(content) are equivalent ergonomic entry points.

Give the code a size

A Barcode renders through a GPU surface, and GPU surfaces stretch to fill whatever size their parent proposes. Inside a stack that offers no bounded height, that means the code can collapse. Pin it with .size(width, height), and keep QR codes square — a stretched QR matrix is much harder for a scanner to lock onto.

Supported symbologies

SymbologyConstructorShapeTypical use
QR CodeBarcode::qr(content)2D matrixURLs, tokens, arbitrary text
Code 128Barcode::code128(content)1D barsAlphanumeric product and asset codes

BarcodeSymbology (Qr / Code128) is #[non_exhaustive], so any match you write against it needs a wildcard arm to survive future symbologies.

Encoding runs when the view builds its body. Content the encoder rejects — a payload too large for a QR symbol, or characters outside Code 128’s charset — panics with the encoder’s error instead of quietly rendering an empty code. Validate untrusted input before handing it to Barcode.

Coloring modules

A Hydrolysis preview of custom barcode colors and gradient fills. Example source.

Dark modules default to black, light modules and the quiet zone to white. dark_color and light_color override them:

use waterui::prelude::*;
use waterui::barcode::Barcode;

fn branded_qr() -> impl View {
    Barcode::qr("https://book.waterui.dev")
        .dark_color(Color::srgb(12, 26, 45))
        .light_color(Color::srgb(246, 250, 255))
        .size(280.0, 280.0)
}

Keep the contrast high. Scanners threshold the image, so a dark-on-dark palette that looks tasteful on screen may not decode at all.

Gradient fill

linear_gradient replaces the solid dark fill with a two-stop gradient:

use waterui::prelude::*;
use waterui::barcode::Barcode;

fn gradient_qr() -> impl View {
    Barcode::qr("https://book.waterui.dev")
        .linear_gradient(
            Color::srgb(0, 108, 255),   // start color
            Color::srgb(255, 62, 122),  // end color
            [0.0, 0.0],                 // start point: top-left
            [1.0, 1.0],                 // end point: bottom-right
        )
        .size(280.0, 280.0)
}

The two endpoints are UnitPoints normalized to the barcode square, so [0.0, 0.0] is the top-left corner and [1.0, 1.0] the bottom-right. The named constants work too: UnitPoint::TOP_LEADING, UnitPoint::CENTER, UnitPoint::BOTTOM_TRAILING, and friends.

Reactive colors

Every color argument takes impl IntoComputed<Color>, not a frozen Color. Pass a Binding or a Computed and the renderer re-tints in place — no view reconstruction, no re-encoding of the matrix:

use waterui::prelude::*;
use waterui::barcode::Barcode;

fn invertible_qr() -> impl View {
    let inverted = Binding::bool(false);

    vstack((
        Barcode::qr("https://book.waterui.dev")
            .dark_color(inverted.map(|inverted| {
                if inverted { Color::srgb(255, 255, 255) } else { Color::srgb(12, 26, 45) }
            }))
            .light_color(inverted.map(|inverted| {
                if inverted { Color::srgb(12, 26, 45) } else { Color::srgb(255, 255, 255) }
            }))
            .size(280.0, 280.0),
        toggle("Invert for dark mode", &inverted),
    ))
    .spacing(16.0)
}

SignalExt::map borrows the binding, so the same inverted still drives the toggle. Flipping it uploads two new colors to the shader’s uniform buffer and requests a redraw; the packed matrix buffer is untouched.

BarcodeFill

Solid and gradient fills are both values of the BarcodeFill enum, built with BarcodeFill::solid(color) and BarcodeFill::linear_gradient(start, end, from, to). dark_color and linear_gradient construct it for you — you only need it when driving BarcodeRenderer directly.

Filling a code with GPU content

fill_gpu swaps the flat fill for any GpuView: an animated shader, a particle system, a rendered scene.

use waterui::prelude::*;
use waterui::barcode::Barcode;
use waterui::graphics::GpuView;

fn artistic_qr(animated: impl GpuView) -> impl View {
    Barcode::qr("https://book.waterui.dev")
        .fill_gpu(animated)
        .light_color(Color::srgb(255, 255, 255))
        .size(280.0, 280.0)
}

This returns a BarcodeGpuFill<V>, which renders in two passes: the fill view draws into an offscreen texture, then BarcodeMaskEffect composites it — sampling the fill texture where a module is dark and painting the light color everywhere else. light_color is the only modifier left on BarcodeGpuFill, since the dark modules now come from your GPU view.

How it works

Matrix generation

QR matrices come from fast_qr; the matrix side length depends on payload length and error-correction level. Code 128 comes from barcoders, and its 1D bar pattern is repeated on every row so both symbologies feed the same square shader path. Both run once, when the BarcodeSource is constructed.

Bit packing

The matrix is packed into a Vec<u32> with one bit per module — 1 dark, 0 light. A 25×25 QR code is 625 modules, so 20 words. That buffer is uploaded once as a read-only GPU storage buffer.

Fragment shader

qr_render.wgsl binds the packed matrix plus a uniform block holding the matrix dimension, quiet-zone width, output resolution, and color or gradient parameters. Per fragment it maps the pixel to a module coordinate, reads that one bit, and emits the dark color, the gradient sample, or the light color. Because the lookup is resolution-independent, scaling the view resizes modules rather than resampling pixels.

Quiet zones

SymbologyQuiet zone (modules)
QR Code4
Code 12810

The quiet zone is drawn in the light color and added automatically — you do not need to pad the view yourself.

API reference

Barcode

MethodDescription
Barcode::qr(content)QR code from any impl Into<Str>
Barcode::code128(content)Code 128 barcode
.dark_color(color)Solid dark-module fill, reactive
.light_color(color)Light-module and quiet-zone color, reactive
.linear_gradient(start, end, from, to)Gradient across dark modules, reactive colors
.fill_gpu(gpu_view)Fill dark modules with GPU content, returns BarcodeGpuFill<V>

Free functions qr_code(content) and code128(content) mirror the two constructors.

BarcodeGpuFill<V>

MethodDescription
.light_color(color)Light-module and quiet-zone color, reactive

BarcodeRenderer

For direct GPU pipeline work. It implements GpuView, so wrap it in GpuSurface::new to place it in a view tree — which is exactly what Barcode does internally.

MethodDescription
BarcodeRenderer::new(source)Black modules on white, from a BarcodeSource
.with_fill(fill)Override with a BarcodeFill
.with_light_color(color)Override the light color, reactive

BarcodeSource

MethodDescription
BarcodeSource::qr(content)Encode a QR matrix now
BarcodeSource::code128(content)Encode a Code 128 matrix now
.symbology()The BarcodeSymbology this source carries
.quiet_zone()Quiet-zone width in modules
.set_size(pixels) / .size()Pixel size used when the source is rasterized offscreen (default 256)

The encoded matrix itself is internal: get pixels through Barcode or BarcodeRenderer rather than reaching for the buffer.

Complete example

use waterui::prelude::*;
use waterui::barcode::Barcode;

fn share_page() -> impl View {
    let url = "https://book.waterui.dev";

    vstack((
        text("Scan to join"),
        Barcode::qr(url)
            .dark_color(Color::srgb(38, 38, 38))
            .light_color(Color::srgb(255, 255, 255))
            .size(280.0, 280.0),
        text(url),
        spacer(),
    ))
    .spacing(12.0)
}

Scanning is not included

This crate generates codes; it does not read them. Decoding a barcode from the camera is not part of waterui-barcode at the pinned commit, and nothing in the current API exposes a scanner. If you need scanning today, drive the platform camera API yourself through your backend.

What’s next

Next comes Graphics, where you write the shaders and canvas drawing code that a barcode fill like fill_gpu consumes.

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 instead of rebuilding the view
  • Build a custom visualization like a clock face

Canvas is WaterUI’s 2D vector drawing view, powered by Vello. You hand it a closure that receives a DrawingContext; WaterUI runs the closure to build a Vello scene, and that scene renders on the GPU through wgpu.

waterui-canvas is a separate crate that the top-level waterui facade does not re-export, so add it explicitly:

[dependencies]
waterui-canvas = "0.1"

Every snippet below is marked rust,ignore because the book’s example crate does not pull that dependency in.

A WaterUI Canvas preview showing vector drawing primitives. Example source.

use waterui::prelude::*;
use waterui::graphics::color::Srgb;
use waterui::layout::{Rect, Size};
use waterui_canvas::{Canvas, DrawingContext};

fn my_canvas() -> impl View {
    Canvas::new(|ctx: &mut DrawingContext| {
        ctx.set_fill_style(Srgb::new(0.2, 0.5, 1.0));
        ctx.fill_rect(Rect::from_size(Size::new(200.0, 100.0)));
    })
}

Canvas::new takes any FnMut(&mut DrawingContext) + 'static. The view stretches to fill its parent on both axes; use .size(w, h) (from ViewExt) to give it a fixed footprint.

Drawing context

DrawingContext carries the current surface dimensions as public fields and exposes every drawing method.

Canvas::new(|ctx: &mut DrawingContext| {
    let width = ctx.width;      // f32
    let height = ctx.height;    // f32
    let center = ctx.center();  // Point
    let size = ctx.size();      // Size
})

Most setters and geometry arguments accept signals, not just plain values: fill_rect takes impl IntoSignal<Rect>, set_line_width takes impl IntoSignalF32, and so on. Passing a Binding registers it, which is what makes the reactive redraws in the last section work.

Shapes

Set a fill or stroke style, then call the matching draw method.

Canvas::new(|ctx: &mut DrawingContext| {
    let rect = Rect::new(Point::new(10.0, 10.0), Size::new(200.0, 100.0));

    ctx.set_fill_style(Srgb::new(0.2, 0.6, 1.0));
    ctx.fill_rect(rect);

    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 back to transparent
    ctx.clear_rect(Rect::new(Point::new(50.0, 30.0), Size::new(40.0, 40.0)));

    ctx.set_fill_style(Srgb::new_u8(242, 140, 168));
    ctx.fill_circle(Point::new(300.0, 60.0), 50.0);
    ctx.stroke_circle(Point::new(300.0, 60.0), 50.0);

    ctx.stroke_line(Point::new(10.0, 150.0), Point::new(200.0, 190.0));
})

Paths

ctx.begin_path() returns a Path builder that mirrors the HTML5 Canvas path API.

Canvas::new(|ctx: &mut DrawingContext| {
    let mut path = ctx.begin_path();
    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);
})

quadratic_to(control, end) and bezier_to(control1, control2, end) add curves:

let mut path = ctx.begin_path();
path.move_to(Point::new(10.0, 100.0));
path.quadratic_to(Point::new(100.0, 10.0), Point::new(200.0, 100.0));
path.bezier_to(
    Point::new(250.0, 10.0),
    Point::new(350.0, 190.0),
    Point::new(400.0, 100.0),
);

ctx.set_stroke_style(Srgb::new(1.0, 0.5, 0.0));
ctx.stroke_path(&path);

Arcs and ellipses take a center, radius (or radii), start and end angles in radians, and a direction flag. Path::arc_to(p1, p2, radius) is the equivalent of the HTML5 arcTo(), and Path::rect(rect) appends a closed rectangle.

let mut path = ctx.begin_path();

// center, radius, start_angle, end_angle, anticlockwise
path.arc(Point::new(100.0, 100.0), 50.0, 0.0, core::f32::consts::PI, false);

// center, radii, rotation, start_angle, end_angle, anticlockwise
path.ellipse(
    Point::new(250.0, 100.0),
    Size::new(80.0, 40.0),
    0.3,
    0.0,
    core::f32::consts::TAU,
    false,
);

ctx.set_stroke_style(Srgb::new(0.8, 0.2, 0.8));
ctx.stroke_path(&path);

ctx.set_fill_rule(FillRule::EvenOdd) switches self-intersecting paths from the default NonZero winding rule to even-odd.

Gradients

DrawingContext builds three gradient types. Each returns a builder; add stops, then pass it to set_fill_style (or set_stroke_style).

Canvas::new(|ctx: &mut DrawingContext| {
    // (x0, y0, x1, y1)
    let mut linear = ctx.create_linear_gradient(0.0, 0.0, 200.0, 200.0);
    linear.add_color_stop(0.0, Srgb::new(1.0, 0.0, 0.0));
    linear.add_color_stop(1.0, Srgb::new(0.0, 0.0, 1.0));
    ctx.set_fill_style(linear);
    ctx.fill_rect(Rect::from_size(Size::new(200.0, 200.0)));

    // Interpolates between two circles: (x0, y0, r0, x1, y1, r1)
    let mut radial = ctx.create_radial_gradient(300.0, 100.0, 10.0, 300.0, 100.0, 80.0);
    radial.add_color_stop(0.0, Srgb::new(1.0, 1.0, 1.0));
    radial.add_color_stop(1.0, Srgb::new(0.0, 0.0, 0.4));
    ctx.set_fill_style(radial);
    ctx.fill_circle(Point::new(300.0, 100.0), 80.0);

    // (start_angle, center_x, center_y)
    let mut conic = ctx.create_conic_gradient(0.0, 500.0, 100.0);
    conic.add_color_stop(0.0, Srgb::new(1.0, 0.0, 0.0));
    conic.add_color_stop(0.5, Srgb::new(0.0, 1.0, 0.0));
    conic.add_color_stop(1.0, Srgb::new(1.0, 0.0, 0.0));
    ctx.set_fill_style(conic);
    ctx.fill_circle(Point::new(500.0, 100.0), 80.0);
})

Images

CanvasImage decodes PNG, JPEG, AVIF, and TIFF, or wraps raw RGBA pixels.

use waterui_canvas::CanvasImage;

let image = CanvasImage::from_bytes(include_bytes!("assets/photo.png"))
    .expect("photo.png is a valid image");

// or from raw pixels
let image = CanvasImage::from_rgba_pixels(width, height, &pixel_data)?;

let (w, h) = (image.width(), image.height()); // image.size() returns a Size

Build the CanvasImage once outside the closure and move it in; decoding inside the draw callback stalls the render thread.

Canvas::new(move |ctx: &mut DrawingContext| {
    ctx.draw_image(&image, Point::new(10.0, 10.0));

    // scaled into a destination rectangle
    ctx.draw_image_scaled(&image, Rect::new(Point::zero(), Size::new(300.0, 200.0)));

    // sub-region, for sprite sheets
    ctx.draw_image_sub(
        &image,
        Rect::new(Point::zero(), Size::new(32.0, 32.0)),
        Rect::new(Point::new(50.0, 50.0), Size::new(64.0, 64.0)),
    );
})

Transforms

The context keeps a transform stack. Everything drawn after a transform is affected until you restore().

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.save();
    ctx.translate(ctx.width / 2.0, ctx.height / 2.0);
    ctx.rotate(core::f32::consts::FRAC_PI_4);
    ctx.scale(2.0, 2.0);

    ctx.set_fill_style(Srgb::new(0.4, 0.8, 0.2));
    ctx.fill_rect(Rect::new(Point::new(-25.0, -25.0), Size::new(50.0, 50.0)));

    ctx.restore();
})
MethodDescription
translate(x, y)Shift the origin
rotate(radians)Rotate clockwise
scale(x, y)Scale both axes independently
transform(affine)Concatenate an arbitrary Affine2
set_transform(affine)Replace the current transform
reset_transform()Reset to identity

save()/restore() clone the drawing state, so wrapping a transform-heavy section is cheaper and safer than undoing each setting by hand.

Strokes

ctx.set_line_width(4.0);
ctx.set_line_cap(LineCap::Round);   // Butt, Round, Square
ctx.set_line_join(LineJoin::Round); // Miter, Round, Bevel
ctx.set_miter_limit(10.0);
ctx.set_line_dash(vec![10.0, 5.0, 2.0, 5.0]);
ctx.set_line_dash_offset(3.0);

Clipping, layers, and shadows

Clip and alpha layers are pushed onto a stack and popped with pop_layer().

Canvas::new(|ctx: &mut DrawingContext| {
    ctx.push_clip_rect(Rect::new(Point::new(20.0, 20.0), Size::new(160.0, 160.0)));
    ctx.set_fill_style(Srgb::new(1.0, 0.0, 0.0));
    ctx.fill_circle(Point::new(100.0, 100.0), 120.0); // clipped to the rectangle
    ctx.pop_layer();

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

push_clip_path and push_alpha_path take an arbitrary Path instead of a rectangle.

Shadows are drawing state, not a layer:

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_global_alpha(0.5) applies an opacity multiplier to everything drawn afterwards.

Text

DrawingContext lays text out with Parley and rasterizes the glyphs through Vello. For body content and anything that needs localization, use the text() / text! views instead — canvas text is for chart labels, 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");

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

draw_text_in_rect(text, rect) width-constrains the layout and clips the overflow.

Reactive redraws

Canvas does not repaint every frame. It repaints when the surface resizes or when a signal it tracked during the last pass changes. Canvas::with_signal is the direct way to say what to track: it hands the current value to your closure and keeps the Canvas view itself alive across updates.

use waterui::prelude::*;
use waterui::graphics::color::Srgb;
use waterui::layout::Point;
use waterui_canvas::{Canvas, DrawingContext};

fn pulsing_dot(angle: Binding<f32>) -> impl View {
    Canvas::with_signal(angle, |ctx: &mut DrawingContext, angle: f32| {
        let r = 20.0 + 10.0 * angle.sin();
        ctx.set_fill_style(Srgb::new(0.4, 0.8, 1.0));
        ctx.fill_circle(ctx.center(), r);
    })
}

Inside a plain Canvas::new, any signal you pass to a setter is tracked the same way. Pass bindings directly; never call .get() to feed one in.

For an animation that no signal drives, call ctx.request_next_frame() to schedule exactly one more redraw after the current one.

Performance notes

  • The closure runs on every tracked-signal change and on every resize. Keep its cost proportional to what actually changed.
  • Build CanvasImage handles once and reuse them.
  • save()/restore() is cheap; hand-unwinding state is what gets expensive and wrong.

A clock face

Hour markers radiating from the center, with the second hand driven by a Binding<f32> so only the canvas repaints:

use core::f32::consts::{FRAC_PI_2, TAU};

fn clock(seconds: Binding<f32>) -> impl View {
    Canvas::with_signal(seconds, |ctx: &mut DrawingContext, seconds: f32| {
        let center = ctx.center();
        let radius = ctx.width.min(ctx.height) / 2.0 - 20.0;

        ctx.set_fill_style(Srgb::new(0.1, 0.1, 0.15));
        ctx.fill_circle(center, radius);
        ctx.set_stroke_style(Srgb::new(0.8, 0.8, 0.8));
        ctx.set_line_width(2.0);
        ctx.stroke_circle(center, radius);

        for i in 0..12 {
            let angle = (i as f32) * TAU / 12.0 - FRAC_PI_2;
            let (cos, sin) = (angle.cos(), angle.sin());
            ctx.stroke_line(
                Point::new(center.x + radius * 0.85 * cos, center.y + radius * 0.85 * sin),
                Point::new(center.x + radius * 0.95 * cos, center.y + radius * 0.95 * sin),
            );
        }

        let hand = seconds / 60.0 * TAU - FRAC_PI_2;
        ctx.set_stroke_style(Srgb::new(1.0, 0.3, 0.3));
        ctx.stroke_line(
            center,
            Point::new(
                center.x + radius * 0.8 * hand.cos(),
                center.y + radius * 0.8 * hand.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
  • Render offscreen for visual tests using an explicit GpuRuntime
  • Configure HDR and MSAA per surface

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.

A crate implementing GpuView needs wgpu, and the version has to be exactly the one WaterUI links — a mismatch produces confusing type-identity errors rather than a clear version complaint. Take it from the facade instead of hand-adding the dependency:

use waterui::graphics::wgpu;

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, and 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 behavior

GpuSurface stretches to fill its parent on both axes. 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};
use waterui::layout::{ProposalSize, Size, StretchAxis, ViewDimensions};

pub trait GpuView: 'static {
    async fn setup(&mut self, ctx: &GpuContext<'_>, env: &mut Environment);

    fn render(&mut self, frame: &mut GpuFrame);

    // Everything below has a default implementation.
    fn preferred_surface_hdr(&self) -> Option<bool> { None }
    fn measure(&self, proposal: ProposalSize) -> ViewDimensions { /* fills the proposal */ }
    fn stretch_axis(&self) -> StretchAxis { StretchAxis::Both }
    fn priority(&self) -> i32 { 0 }
}

Only setup and render are required. The layout hooks are defaults on the trait itself — earlier releases required a separate SubView implementation plus an impl_gpu_subview! macro call, and both are gone. Override measure when your content has an intrinsic size (an image respecting its aspect ratio, for example) rather than filling whatever it is offered.

setup is async, so awaitable initialization — asset loading, shader-source fetching — is allowed. The future is not required to be Send: it is created and awaited on the same thread. Push heavy CPU work onto a thread pool (smol::unblock) rather than blocking it.

Lifecycle

  1. Setup runs once, after the wgpu device is ready. Build pipelines, buffers, bind groups, and owned textures here. Clone ctx.redraw_handle if you need to wake the surface from outside the render loop.
  2. Resize is implicit: every render call carries the current frame.width/frame.height. Detect a size change there and recreate size-dependent resources.
  3. Render runs whenever the surface is dirty. Submit your wgpu commands through frame.queue, and call frame.request_redraw() to ask for another frame.

There is no separate needs_redraw callback. A frame happens because the surface dirtied (size, input, theme), the renderer requested one, or a RedrawHandle was poked.

GpuContext

GpuContext is the setup-time payload:

pub struct GpuContext<'a> {
    pub adapter: &'a wgpu::Adapter,
    pub device: &'a wgpu::Device,
    pub queue: &'a wgpu::Queue,
    pub surface_format: wgpu::TextureFormat,
    pub msaa_samples: u32,
    pub redraw_handle: RedrawHandle,
}
  • adapter is always present. Use it for get_texture_format_features when you need to know what the hardware supports.
  • surface_format may be Rgba16Float when the platform supports HDR. Call ctx.is_hdr() and gate your blend state on it — with HDR active, use blend: None rather than BlendState::REPLACE.
  • msaa_samples is the sample count the backend selected for this surface. Use it for both pipeline configuration and any MSAA attachments you create.
  • redraw_handle is a cheap, thread-safe handle. Clone and stash it; call request_redraw() whenever new state should drive a frame. It also exposes is_dirty(), take_dirty(), and set_waker(...) for hosts driving their own frame loop.

There is no pipeline_cache field. WaterUI’s own shaders are compiled ahead of time at build time (see Shaders), and the wgpu pipeline-cache plumbing that used to be threaded through here was removed along with the runtime pre-warm system. Pass cache: None in your pipeline descriptors.

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() is the accumulated animation time since the surface started rendering; frame.delta() is the frame-to-frame time step.
  • frame.is_hovering() and frame.pointer_normalized() give quick access to pointer state.
  • frame.gesture carries pinch/pan/double-tap state forwarded by the backend.
  • frame.request_redraw() schedules another frame; frame.was_redraw_requested() lets nested helpers read the flag back.

Triangle example

A complete “hello triangle”. The shader lives in its own file — WGSL does not belong in a string literal.

// triangle.rs
use waterui::{Environment, prelude::*};
use waterui::graphics::{GpuContext, GpuFrame, GpuSurface, GpuView};

#[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: &[],
            immediate_size: 0,
        });

        let blend = (!ctx.is_hdr()).then_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_mask: None,
            cache: None,
        }));
    }

    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,
                    depth_slice: None,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
            pass.set_pipeline(pipeline);
            pass.draw(0..3, 0..1);
        }

        frame.queue.submit([encoder.finish()]);
    }
}

pub fn triangle_view() -> impl View {
    GpuSurface::new(TriangleRenderer::default())
}

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 anything that changes outside the frame loop — a timer, a Binding, an incoming network message — clone ctx.redraw_handle during setup and call request_redraw() from wherever the change lands. Keep the watch guard alive on the renderer; dropping it unsubscribes.

async fn setup(&mut self, ctx: &GpuContext<'_>, env: &mut Environment) {
    let redraw = ctx.redraw_handle.clone();
    self.guard = Some(self.signal.watch(move |_| redraw.request_redraw()));
    // ...
}

This is how MeshGradient tracks its color signal without the surface being torn down and rebuilt.

Offscreen rendering

GpuSurface renders headlessly for visual tests and snapshots. The entry points are async and take a &GpuRuntime — an explicitly constructed GPU context, which replaced the process-global one that earlier releases reached for implicitly.

use waterui::graphics::{
    GpuRuntime, GpuSurface, OffscreenRenderConfig, OffscreenSize,
};

let runtime = GpuRuntime::new().await?;
let mut env = waterui::Environment::new();

let size = OffscreenSize::try_from_pixels(1024, 768)?;
let config = OffscreenRenderConfig::new(size).format(wgpu::TextureFormat::Rgba8Unorm);

let output = GpuSurface::new(MyRenderer::default())
    .render_offscreen(&runtime, config, &mut env)
    .await?;

assert_eq!(output.rgba8.len(), 1024 * 768 * 4);
output.save_png("snapshot.png")?;

RGBA readback accepts Rgba8Unorm and Rgba8UnormSrgb; anything else returns OffscreenRenderError::UnsupportedReadbackFormat. render_offscreen_frames(..., frame_count) runs several frames before the readback, which is what you want for a renderer that animates from frame.elapsed() — offscreen frames advance at a fixed 1/60 s step, so the frame count maps directly to simulated time.

Never read a swapchain texture back to the CPU in a runtime path — offscreen readback exists for tests and snapshot generation.

For HDR, use the Rgba16Float entry point. It returns half-float pixels in output.rgba16f:

let config = OffscreenRenderConfig::new(size).format(wgpu::TextureFormat::Rgba16Float);

let output = GpuSurface::new(MyHdrRenderer::default())
    .render_offscreen_hdr(&runtime, config, &mut env)
    .await?;

output.save_png("hdr_snapshot.png")?;     // PQ-coded HDR PNG when headroom is detected
output.save_sdr_png("sdr_snapshot.png")?; // tone-mapped SDR version

OffscreenRenderConfig also carries the simulated input used for hover and gesture tests:

use core::num::NonZeroU32;
use waterui::layout::Point;
use waterui::graphics::{GestureState, PointerState};

let config = OffscreenRenderConfig::new(size)
    .format(wgpu::TextureFormat::Rgba8Unorm)
    .msaa_samples(NonZeroU32::new(4).unwrap())
    .pointer(PointerState {
        position: Some(Point::new(512.0, 384.0)),
        hit: None,
    })
    .gesture(GestureState::new());

MSAA

Each surface carries a maximum sample count, defaulting to 4. Backends clamp it to what the adapter and format actually support.

use core::num::NonZeroU32;

GpuSurface::new(MyRenderer::default())
    .msaa_max_samples(NonZeroU32::new(8).unwrap())

msaa_sample_limit() reads the configured cap back; ctx.msaa_samples in setup is the resolved value you should build pipelines against.

HDR preference

By default a surface follows the surrounding platform style. Override it per surface:

GpuSurface::new(MyRenderer::default()).prefer_hdr_surface();
GpuSurface::new(MyRenderer::default()).prefer_sdr_surface();

A renderer can also express the preference itself by overriding GpuView::preferred_surface_hdr, which the surface falls back to when no explicit builder call was made. resolved_hdr_preference() reports the combined answer, where None means “follow the platform”. Whatever the outcome, gate your blend state on ctx.is_hdr() so one renderer 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
GpuRuntimeExplicitly constructed GPU context for headless work
OffscreenRenderConfigHeadless render configuration
OffscreenRenderOutputRGBA8 pixel output with PNG encoding
OffscreenRenderOutputHdrRGBA16F output with PQ + tone-mapped PNG

Next

For the most common GPU use case — a single fragment shader over a 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 the built-in uniforms for time, resolution, and aspect-ratio correction
  • Load shader files at compile time with the shader! macro
  • Build animated effects like plasma and procedural noise
  • 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; WaterUI supplies the vertex stage, the uniform buffer, the pipeline, and the render loop.

Quick start

use waterui::prelude::*;
use waterui::graphics::shader;

fn my_effect() -> impl View {
    shader!("shaders/plasma.wgsl")
}

shader! reads the WGSL source at compile time with include_str! and builds a ShaderSurface labeled with the path. The path is resolved against your crate’s src/ directory, not against the file that calls the macroshader!("shaders/plasma.wgsl") loads <your-crate>/src/shaders/plasma.wgsl.

A WGSL fragment shader rendered through ShaderSurface. Example source.

Creating a ShaderSurface from a string

When the source is not a fixed file path — generated WGSL, a shader assembled at runtime — construct the surface directly:

use waterui::graphics::ShaderSurface;

fn gradient_effect(source: String) -> impl View {
    ShaderSurface::new(source)
}

ShaderSurface::new accepts anything convertible into Cow<'static, str>, so a &'static str from include_str! works too. Prefer shader! when you have a file: it keeps the path in one place and labels the shader for graphics diagnostics.

include_fragment_shader!("shaders/plasma.wgsl") gives you the same compile-time load as a value — a ShaderSource { label, source } — when you want to hold the source before deciding what to do with it.

Built-in uniforms

Every ShaderSurface shader is prefixed with a fixed prelude, so you never declare this yourself:

struct Uniforms {
    time: f32,             // seconds since the surface was set up
    resolution: vec2<f32>, // surface size in pixels
    _padding: f32,
}

@group(0) @binding(0)
var<uniform> uniforms: Uniforms;

The prelude also declares a VertexOutput struct and a vs_main vertex shader that emits a six-vertex full-screen quad. Your file only defines the fragment stage, and the entry point must be named main:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    // uv: (0,0) at bottom-left, (1,1) at top-right
    return vec4<f32>(uv.x, uv.y, sin(uniforms.time) * 0.5 + 0.5, 1.0);
}

The exact prelude text is available as the waterui::graphics::shader_surface::PRELUDE constant if you need to reproduce the environment in a standalone WGSL tool.

Writing WGSL

Time-based animation

uniforms.time ticks up continuously, which is all you need for pulsing, rotating, and morphing:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let t = uniforms.time;
    let dist = distance(uv, vec2<f32>(0.5, 0.5));
    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);
}

Aspect-ratio correction

uv is normalized to the surface, so circles turn into ellipses on a non-square surface unless you correct with uniforms.resolution:

@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;

    let p = vec2<f32>((uv.x - 0.5) * aspect, uv.y - 0.5);
    let ring = smoothstep(0.01, 0.0, abs(length(p) - 0.3));

    return vec4<f32>(ring, ring, ring, 1.0);
}

Procedural noise

A hash-based value noise, the building block for fire, clouds, and terrain:

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 cell = floor(uv * 10.0);
    let n = hash21(cell + vec2<f32>(uniforms.time * 0.1, 0.0));

    return vec4<f32>(n, n, n, 1.0);
}

How ShaderSurface behaves

ShaderSurface wraps a GpuSurface around an internal GpuView:

  1. Setup concatenates the prelude with your fragment, compiles it into a wgpu::ShaderModule, and builds a 24-byte uniform buffer, a bind group, and a render pipeline against the current surface format. Blending is REPLACE on SDR surfaces and disabled on HDR ones.
  2. Render rewrites the uniform buffer with the latest time and resolution, clears to transparent, and draws the six-vertex quad.
  3. Every frame requests the next one. ShaderSurface is unconditionally animated — there is no static mode. If your effect does not use uniforms.time, you are paying for frames you do not need; write a GpuView that only requests redraws when something changes.
  4. The surface format is fixed at setup. If it changes afterwards (an HDR toggle, for instance) the renderer panics rather than silently rendering into a mismatched target.

Compile time vs. run time

WaterUI’s own built-in shaders — the mesh gradients, the image generator, the scene blit — are compiled ahead of time during cargo build and shipped in the binary as shaderloom::CompiledShader constants under waterui::graphics::shaders. That replaced the previous approach of compiling lazily on first use and hiding the stall behind a disk-persisted pipeline cache; the pre-warm module and the cache are both gone.

Shaders you author through shader! or ShaderSurface::new are still compiled when the surface sets up. That is a one-time cost per surface, and it is why the format is captured at setup rather than re-checked per frame.

Accessing the inner GpuSurface

into_inner() returns the GpuSurface, so you can apply per-surface settings like the MSAA cap or the HDR preference:

use core::num::NonZeroU32;

let surface = shader!("shaders/plasma.wgsl")
    .into_inner()
    .msaa_max_samples(NonZeroU32::new(4).unwrap());

When to drop down to GpuView

ShaderSurface binds exactly one uniform buffer with time and resolution in it. The moment you need extra uniforms, textures, samplers, storage buffers, or a compute pass, write a GpuView and wrap it in a GpuSurface — see GPU rendering with GpuSurface. AnimatedMeshGradient is the shipped example: it carries a 16-entry color palette as a uniform, which ShaderSurface has no way to express.

Two smaller notes for shader authors:

  • GPUs prefer uniform control flow. Reach for select(), step(), and smoothstep() before if.
  • WGSL floats are 32-bit. For pixel-precise work, multiply uv by uniforms.resolution rather than chasing precision in normalized space.

Example: a plasma effect

// src/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 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);
}
fn plasma_background() -> impl View {
    shader!("shaders/plasma.wgsl").size(400.0, 300.0)
}

Next

Shaders compose visual content 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 dozens of other filters to any view
  • Chain filters so consecutive color operations fuse into a single GPU pass
  • Drive filter parameters with reactive signals and animate them
  • Build custom effects with the Effect and EffectRenderer traits
  • Choose an HDR policy for a filter chain

WaterUI’s filter system captures the rendered output of a view, runs it through one or more GPU passes, and displays the result. Blurred photo galleries, frosted-glass cards, dramatic black-and-white portraits: all of it is one method call on an existing view.

Quick start

FilterViewExt is in the prelude whenever the default gpu feature is on, so there is nothing extra to import:

use waterui::prelude::*;

fn frosted_card() -> impl View {
    text("Hello, World!")
        .blur(10.0)
        .brightness(0.1)
        .contrast(1.2)
}

Three filters on a text view, executed as two GPU passes: the blur, then brightness and contrast fused together.

FilterViewExt applied to a WaterUI view snapshot. Example source.

How a filter reaches the GPU

view.blur(10.0)
    -> Filtered<V, FilterAdapter<Blur>>
        -> AppliedFilter metadata on the view
            -> backend captures the child view to a texture
            -> Effect::encode_render(input, output)
            -> backend displays the output texture

FilterViewExt methods return Filtered<V, FilterAdapter<F>>. FilterAdapter is the bridge: it takes a pure-data Filter (a filter kind plus its parameters, from the filtrate crate), watches any reactive parameters, plans the passes, and presents the whole thing to the backend as one Effect. Effect is the low-level GPU trait, and it is also what you implement for a custom effect.

Built-in filters

Every parameter is impl IntoSignalF32, so a literal 10.0, a Binding<f64>, a Computed<f32>, or any signal yielding a number all work in the same slot.

Color filters run per pixel and fuse with their neighbors:

MethodParametersDescription
.brightness(amount)10.0 leaves the image unchanged; positive brightens
.exposure(ev)1Exposure in photographic stops
.contrast(amount)11.0 unchanged, above 1.0 increases contrast
.gamma(gamma)1Gamma adjustment
.saturation(amount)11.0 unchanged, 0.0 fully desaturated
.vibrance(amount)1Saturates muted colors more than saturated ones
.grayscale(intensity)10.0 full color, 1.0 fully gray
.sepia(intensity)10.0 no effect, 1.0 full sepia
.hue_rotation(angle)1Rotate hue, in radians
.invert()0Invert all channels
.temperature_tint(temp, tint)2White-balance adjustment
.highlights_shadows(hi, lo)2Recover highlights, lift shadows
.color_matrix(m)[[f32; 4]; 3]Arbitrary 3×4 color transform
.white_point(r, g, b)3Color balance from an explicit white point
.vignette(radius, softness)2Darken toward the edges

Spatial filters sample neighboring pixels and therefore need a pass of their own:

MethodParametersDescription
.blur(radius)1Blur with the given pixel radius
.gaussian_blur(sigma)1Gaussian blur by standard deviation
.motion_blur(radius, angle)2Directional blur
.zoom_blur(amount, x, y)3Radial blur around a focal point
.sharpen(amount)1Edge sharpening
.unsharp_mask(radius, amount)2Classic unsharp mask
.bloom(radius, intensity, threshold)3Glow around bright regions
.sobel() / .prewitt()03×3 edge detection
.median3x3()0Salt-and-pepper denoise
.convolution3x3(kernel) / .convolution5x5(kernel)kernel arrayArbitrary convolution
.pixellate(size) / .crystallize(size)1Mosaic effects
.kaleidoscope(...) / .mirror_tile(...)4 / 2Tiling and reflection
.bump_distortion(...), .pinch_distortion(...), .twirl_distortion(...), .vortex_distortion(...)4 eachGeometric warps around a center

There are also eight presets that need no parameters at all — .photo_effect_mono(), .photo_effect_noir(), .photo_effect_chrome(), .photo_effect_instant(), .photo_effect_fade(), .photo_effect_process(), .photo_effect_tonal(), .photo_effect_transfer() — plus multi-input filters that take a second image (.blend_with_image, .masked_blur, .lut_color_grade, the *_transition_to_image family, and more). The complete list lives on the FilterViewExt trait.

Chaining and fusion

Keep calling filter methods on the result. Consecutive calls extend one chain rather than nesting a second capture:

use waterui::prelude::*;
use waterui::media::Photo;

fn warm_vintage(url: waterui::Url) -> impl View {
    Photo::new(url)
        .brightness(0.05)
        .sepia(0.3)
        .contrast(1.1)
        .vignette(0.7, 0.5)
}

Every color-only filter in a run is compiled into a single fragment shader pass, so those four calls cost roughly what one costs. Spatial filters break the run: blur -> brightness -> contrast -> sharpen produces three passes — blur, the fused brightness+contrast pair, then sharpen.

Ordering therefore matters for performance. Group your color adjustments together instead of interleaving them with blurs.

Reactive filters

Pass the binding, not its value. A slider bound to Binding<f64> drives a filter directly:

use waterui::prelude::*;
use waterui::media::Photo;

fn interactive_blur(url: waterui::Url) -> impl View {
    let blur_radius = Binding::f64(0.0);

    vstack((
        Photo::new(url).blur(blur_radius.clone()),
        slider("Blur radius", &blur_radius).range(0.0..=30.0),
    ))
}

FilterAdapter watches the signal and repaints the filtered texture. The view itself is never rebuilt, so nothing below it loses state.

Animating a parameter

Wrap the signal with an animation and the adapter interpolates between the old and new values, keeping the surface dirty until it settles:

use core::time::Duration;
use waterui::prelude::*;
use waterui::animation::{Animation, AnimationExt};
use waterui::media::Photo;

fn animated_blur(url: waterui::Url) -> impl View {
    let blur = Binding::f64(0.0);

    vstack((
        Photo::new(url).blur(blur.clone().with_animation(Animation::spring(180.0, 22.0))),
        button("Toggle blur")
            .action(|State(blur): State<Binding<f64>>| {
                let target = if blur.get() > 0.0 { 0.0 } else { 20.0 };
                blur.set(target);
            })
            .state(&blur),
    ))
}

Animation::spring(stiffness, damping) runs until the spring settles; Animation::ease_in_out(Duration::from_millis(250)) and the other bezier curves run for a fixed duration. .animated() is shorthand for the default curve.

HDR policy

Filter chains adapt to HDR-capable surfaces on their own. Override the choice per chain:

fn hdr_aware_filter(url: waterui::Url) -> impl View {
    Photo::new(url)
        .blur(10.0)
        .prefer_hdr()   // HDR intermediates when available (default)
    //  .require_hdr()  // fail setup if HDR is unavailable
    //  .force_ldr()    // always use LDR intermediates
}

.hdr_policy(HdrPolicy::PreferHdr | RequireHdr | ForceLdr) is the same thing spelled out. When the surface is HDR (Rgba16Float), the scratch textures between passes are Rgba16Float too; otherwise they are 8-bit.

ViewEffect: post-processing a view

For work that is not a filter — distortion, overlays, custom post-processing — ViewEffect gives you the captured texture and an output texture, and stays out of the way:

use core::future::Future;
use waterui::graphics::{
    EffectRenderer, ViewEffect, ViewEffectContext, ViewEffectInput, ViewEffectOutput,
};

#[derive(Default)]
struct WaveDistortion {
    pipeline: Option<wgpu::RenderPipeline>,
    sampler: Option<wgpu::Sampler>,
}

impl EffectRenderer for WaveDistortion {
    fn setup(&mut self, ctx: &ViewEffectContext) -> impl Future<Output = ()> {
        // ctx.device, ctx.queue, ctx.input_format, ctx.output_format
        // input and output formats may differ.
        async {}
    }

    fn render(&mut self, input: &ViewEffectInput, output: &ViewEffectOutput) {
        // sample input.view, draw into output.view.
        // input and output may have different dimensions.
    }
}

fn distorted_content() -> impl View {
    ViewEffect::new(text("Wavy text"), WaveDistortion::default())
}

By default the output texture matches the captured view. OutputSize changes the GPU processing resolution without touching layout:

use waterui::graphics::OutputSize;

ViewEffect::new(my_view(), effect).output_size(OutputSize::Scale(2.0));
ViewEffect::new(my_view(), effect).output_size(OutputSize::Fixed { width: 1920, height: 1080 });

Custom Effect

Effect is what .filter(...) accepts, and it is the trait the built-in filters compile down to. Two things about its shape are worth knowing before you write one:

  • encode_render is the required method, not render. You write your commands into a caller-supplied encoder so a host rendering several effects in one frame can submit once. render has a default implementation that creates an encoder, calls encode_render, and submits.
  • The result types carry meaning. EffectSetupResult is Result<(), &'static str>; EffectRenderResult is Result<bool, &'static str>, where the bool answers “does this need another frame?”
use core::future::Future;
use waterui::prelude::*;
use waterui::graphics::{
    Effect, EffectContext, EffectInput, EffectOutput, EffectRenderResult, EffectSetupResult,
};

#[derive(Default)]
struct CustomEffect {
    pipeline: Option<wgpu::RenderPipeline>,
    animating: bool,
}

impl Effect for CustomEffect {
    fn setup(&mut self, ctx: &EffectContext) -> impl Future<Output = EffectSetupResult> {
        // build pipelines from ctx.device, ctx.input_format, ctx.output_format
        async { Ok(()) }
    }

    fn encode_render(
        &mut self,
        input: &EffectInput,
        output: &EffectOutput,
        encoder: &mut wgpu::CommandEncoder,
    ) -> EffectRenderResult {
        // encode a pass reading input.view and writing output.view
        Ok(self.animating)
    }
}

fn custom_filtered() -> impl View {
    text("Hello").filter(CustomEffect::default())
}

Two optional hooks round it out: output_size(input_w, input_h) when the effect wants a different output resolution, and redraw_hint() so a backend doing on-demand rendering knows the effect has pending state.

Performance notes

  • Consecutive color-only filters fuse into one fragment pass. Five of them cost about what one costs.
  • Each spatial filter is its own pass with its own intermediate texture.
  • The backend captures the child view to a texture before filtering. When the child is already a GpuSurface, it samples that texture directly and skips the capture.
  • Multi-pass chains ping-pong between two scratch textures, allocated lazily and resized only when the surface changes size.
  • An animating parameter keeps the surface dirty until it settles. Springs settle on their own; bezier curves run for their declared duration.

Next

Filters transform existing content. To generate new visual content on the GPU, continue to Particle systems.

Particle systems

In this chapter, you will:

  • Describe a GPU particle effect as a single builder chain
  • Aim emitters, motion, and collisions in normalized coordinates
  • Drive live parameters from bindings so an effect follows app state
  • Render frames offscreen for visual review

No platform ships a particle primitive, so ParticleSystem is one of WaterUI’s self-drawn components: a compute shader simulates every particle and one instanced draw call renders them, the same way on every backend.

Feature flag: particles live behind the particle feature. Add waterui = { version = "...", features = ["particle"] } to Cargo.toml — the waterui::particle module does not exist without it.

Quick start

use waterui::prelude::*;
use waterui::particle::ParticleSystem;
use core::f32::consts::PI;

fn rain() -> impl View {
    ParticleSystem::new(8_000)
        .emit_from_rect(1.4, 0.08)
        .at(0.5, -0.04)
        .rate(480_000.0)
        .life(0.6, 0.8)
        .speed(2.4, 4.2)
        .angle(PI * 0.49, PI * 0.51)
        .size(0.0008, 0.0015)
        .color(
            Color::srgb_hex("#D5E8FF").with_opacity(0.45),
            Color::srgb_hex("#E8F5FF").with_opacity(0.0),
        )
        .gravity(0.0, 5.0)
        .stretch_with_velocity()
}

Ranges take two arguments, not a Rust range: life(0.6, 0.8) means “somewhere between 0.6 and 0.8 seconds”, drawn per particle when it spawns. particles(8_000) is the free-function equivalent of ParticleSystem::new(8_000).

A confetti emitter rendered by WaterUI’s preview pipeline. Example source.

Coordinates and units

Every spatial value is normalized to the system’s own frame: [0.0, 0.0] is the top-left corner and [1.0, 1.0] the bottom-right. Positions, particle sizes, emitter extents, gravity, and collider bounds all share that space, so one configuration looks the same at any output resolution. Emitting slightly outside the frame (at(0.5, -0.04)) is how you get particles that drift in from off-screen.

Angles are radians from the +x axis with y pointing down: 0.0 aims right, PI * 0.5 down, PI left, PI * 1.5 up. Durations are seconds.

The emitter

ModifierMeaning
at(x, y)Emitter center
rate(per_second)Emission rate (see below)
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

ParticleSystem::new(max_particles) allocates a fixed pool of slots once, and particles only ever recycle dead slots. Each frame, every free slot independently has a rate * dt / max_particles chance of respawning, so emission throttles itself as the pool fills: with the pool empty the system emits about rate particles per second, and at 90% occupancy roughly a tenth of that. Saturated effects therefore ask for far more than max_particles / average_life — the rain above requests 480,000/s from an 8,000-slot pool purely to keep it full. Tune rate by eye against the pool size rather than treating it as an exact count.

Particle properties

ModifierMeaning
life(min, max)Lifetime in seconds
speed(min, max)Initial speed magnitude
angle(min, max)Initial direction in radians
size(min, max)Sprite size in normalized units
spin(min, max)Rotation speed in radians per second
color(start, end)Tint at birth and at death
softness(value)Edge falloff, 0.0 hard to 1.0 soft
shape(ParticleShape)Circle (default) or Rect SDF sprite
stretch_with_velocity()Stretch the sprite along its velocity vector
use waterui::prelude::*;
use waterui::particle::{ParticleShape, ParticleSystem};
use core::f32::consts::{PI, TAU};

fn confetti() -> impl View {
    ParticleSystem::new(20_000)
        .emit_from_circle(0.05)
        .at(0.5, 0.5)
        .rate(1_200_000.0)
        .life(0.8, 1.5)
        .speed(0.5, 3.0)
        .angle(0.0, TAU)
        .size(0.003, 0.008)
        .spin(-PI, PI)
        .shape(ParticleShape::Rect)
        .softness(0.0)
        .gravity(0.0, 3.0)
}

Forces

life, speed, angle, size, and spin are fixed per particle at birth. Forces then act on every live particle each frame.

ModifierMeaning
gravity(x, y)Constant acceleration
wind(x, y)Constant acceleration added alongside gravity
turbulence(value)Random horizontal jitter
drag(factor)Velocity retained per 60 fps frame (1.0 = none)
ParticleSystem::new(2_000)
    .emit_from_rect(1.5, 0.2)
    .at(0.5, 1.1)
    .rate(40_000.0)
    .life(8.0, 12.0)
    .speed(0.02, 0.08)
    .gravity(0.0, -0.01)
    .wind(0.02, 0.0)
    .turbulence(0.2)
    .drag(0.98);

Blending

additive() makes overlapping particles brighten each other, which is what fire, sparks, and glow need. The default is BlendMode::Alpha.

use waterui::prelude::*;
use waterui::particle::ParticleSystem;
use core::f32::consts::PI;

fn flame() -> impl View {
    ParticleSystem::new(3_000)
        .emit_from_rect(0.05, 0.0)
        .at(0.5, 0.82)
        .rate(180_000.0)
        .life(0.4, 0.8)
        .speed(0.5, 1.2)
        .angle(PI * 1.4, PI * 1.6)
        .size(0.03, 0.06)
        .color(
            Color::srgb_hex("#FFB433").with_opacity(0.6),
            Color::srgb_hex("#FF2A0D").with_opacity(0.0),
        )
        .gravity(0.0, -1.0)
        .softness(0.6)
        .additive()
}

Collisions and interaction

ModifierMeaning
collide_with_viewport()Bounce inside the normalized [0,0]..[1,1] rectangle
collide_with_rect(x, y, width, height)Bounce inside an arbitrary rectangle
collide_with_circle_obstacle(x, y, radius)Bounce off a static disk
bounce(restitution)Normal velocity retained on impact
surface_friction(value)Tangential velocity retained on impact
collide_with_particles(radius, strength)Soft particle-to-particle repulsion
ParticleSystem::new(6_000)
    .emit_from_circle(0.02)
    .at(0.5, 0.18)
    .rate(90_000.0)
    .life(4.0, 6.0)
    .speed(0.5, 1.4)
    .gravity(0.0, 1.4)
    .collide_with_rect(0.08, 0.08, 0.84, 0.84)
    .collide_with_circle_obstacle(0.5, 0.36, 0.08)
    .bounce(0.82)
    .surface_friction(0.9)
    .collide_with_particles(0.01, 16.0);

Bounds and obstacles are the last thing applied to a particle’s new position each frame, so a fast particle can still tunnel through a thin collider — keep obstacles chunky relative to speed * dt. collide_with_particles adds a neighbor-grid build and lookup pass over the whole pool, so leave it off unless the clumping is visible.

Live parameters

Every builder argument is a signal. Literals become constants; a Binding or Computed stays live. The renderer samples each signal once per frame and requests a redraw whenever one changes, so the surrounding view is never rebuilt and particles already in flight keep their trajectories.

use waterui::prelude::*;
use waterui::component::slider;
use waterui::particle::ParticleSystem;
use core::f32::consts::PI;

fn snowfall() -> impl View {
    let density = Binding::container(0.25_f64);
    let drift = Binding::container(0.0_f64);

    vstack((
        ParticleSystem::new(4_000)
            .emit_from_rect(1.4, 0.05)
            .at(0.5, -0.03)
            .rate(density.map(|value| value * 400_000.0))
            .life(4.0, 7.0)
            .speed(0.05, 0.12)
            .angle(PI * 0.45, PI * 0.55)
            .size(0.004, 0.01)
            .gravity(0.0, 0.05)
            .wind(drift.clone(), 0.0)
            .softness(0.8),
        slider("Snowfall density", &density),
        slider("Wind drift", &drift).range(-0.3..=0.3),
    ))
}

density.map(...) derives the emission rate without reading the binding, which is what keeps the dependency visible to the renderer. Numeric signals convert freely, so a Binding<f64> from a slider feeds an f32 parameter directly.

Which parameters respond, and when:

  • Immediately, for all live particles: at, rate, emitter shape, gravity, wind, turbulence, drag, collider bounds and obstacle positions, bounce, surface_friction, interaction radius/strength, color, softness.
  • At the next spawn only: life, speed, angle, size, spin — particles already alive keep the values they were born with.
  • Fixed when the system is built: max_particles, shape, additive, stretch_with_velocity, the number of circle obstacles, and whether collision or particle interaction is switched on at all. Changing one of these means constructing a new ParticleSystem.

Earlier versions accepted the same signals but sampled them once while the builder chain ran, so a bound parameter froze after the first frame. The builder signatures did not change — code written against them now animates.

Placing a system in a layout

ParticleSystem is a View built on GpuSurface, so it stretches to fill whatever it is given and composes like any other view:

use waterui::prelude::*;
use waterui::particle::ParticleSystem;

fn celebration_card() -> impl View {
    zstack((
        ParticleSystem::new(2_000)
            .emit_from_rect(1.0, 0.0)
            .at(0.5, -0.05)
            .rate(120_000.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(),
    ))
}

Because it takes the size it is offered, a parent that proposes nothing measures it as zero. Wrap it in a Frame to pin dimensions — ParticleSystem::size sets the particle sprite size range, not the view’s bounds:

use waterui::layout::frame::Frame;

Frame::new(celebration_card()).width(320.0).height(180.0)

Rendering offscreen

All four render_offscreen* methods are async and take a &GpuRuntime. Create the runtime once — it owns the GPU device and queue — and reuse it across renders.

use core::f32::consts::TAU;
use core::num::NonZeroU32;
use waterui::Environment;
use waterui::graphics::{GpuRuntime, OffscreenRenderConfig, OffscreenSize};
use waterui::particle::ParticleSystem;

async fn export_burst(path: &str) {
    let runtime = GpuRuntime::new().await.expect("GPU runtime should initialize");
    let size = OffscreenSize::try_from_pixels(600, 600).expect("size must be non-zero");
    let frames = NonZeroU32::new(8).expect("frame count must be non-zero");
    let mut env = Environment::new();

    let output = ParticleSystem::new(20_000)
        .emit_from_circle(0.05)
        .at(0.5, 0.5)
        .rate(1_200_000.0)
        .life(0.8, 1.5)
        .speed(0.5, 3.0)
        .angle(0.0, TAU)
        .size(0.003, 0.008)
        .render_offscreen_frames(&runtime, OffscreenRenderConfig::new(size), &mut env, frames)
        .await
        .expect("offscreen render should succeed");

    output.save_png(path).expect("png write should succeed");
}

Offscreen frames advance at a fixed 1/60 s step, so frames maps directly to simulated time — 8 frames is roughly 133 ms in. One frame shows almost nothing, because a pool that starts empty needs several frames to fill. render_offscreen_hdr and render_offscreen_hdr_frames read back 16-bit float pixels for additive effects that clip in 8-bit; they reject any OffscreenRenderConfig whose format is not Rgba16Float, so set it with OffscreenRenderConfig::format before calling them.

Emission is seeded randomly each frame, so two runs of the same configuration produce visibly similar but not identical images. Review these snapshots by eye rather than comparing hashes.

Next

Animated gradients covers the other end of the GPU component range: full-surface color fields that animate without any per-element simulation.

Animated gradients

In this chapter, you will:

  • Tell the two gradient layers apart and know which one is a View
  • Draw linear, radial, angular, and mesh gradients on the GPU
  • Drive mesh gradient colors from a reactive signal
  • Drop in the self-animating AnimatedMeshGradient and FlowingGradient

WaterUI ships two gradient layers with overlapping names, and picking the wrong one is the most common way to get stuck.

  • waterui::gradient holds semantic gradient descriptions: LinearGradient, RadialGradient, AngularGradient, MeshGradient, ColorStop, MeshVertex, UnitPoint. They carry reactive Computed<Color> stops, and the unified Gradient enum wraps any of them. At the pinned revision none of these types implement View, so they cannot be handed to .background(...) or placed in a stack.
  • waterui::graphics holds the GPU views: Gradient (backed by GradientConfig), the reactive MeshGradient<C>, and the two self-animating views. These are what you put in the view tree.

The prelude re-exports the descriptive Gradient and MeshGradient, so import the GPU ones explicitly — the explicit use shadows the glob:

use waterui::prelude::*;
use waterui::graphics::Gradient; // shadows the prelude's gradient::Gradient enum

A mesh gradient rendered with waterui::graphics::Gradient. Example source.

The Gradient view

waterui::graphics::Gradient takes Vec<(f32, ResolvedColor)> color stops. Linear, radial, and angular variants resolve to backend-native gradient rendering; mesh variants go through a dedicated GPU shader.

ResolvedColor stores linear components, so build stops with ResolvedColor::from_srgb(...) rather than writing struct literals — the literal path skips gamma correction and gives you colors you did not intend.

use waterui::prelude::*;
use waterui::graphics::Gradient;
use waterui::graphics::color::{ResolvedColor, Srgb};

fn linear_bg() -> impl View {
    Gradient::linear(
        vec![
            (0.0, ResolvedColor::from_srgb(Srgb::new_u8(255, 0, 128))),
            (1.0, ResolvedColor::from_srgb(Srgb::new_u8(0, 76, 255))),
        ],
        [0.5, 0.0], // start point, normalized to the view bounds
        [0.5, 1.0], // end point
    )
}

Radial takes a center plus a start and end radius:

fn radial_bg() -> impl View {
    Gradient::radial(
        vec![
            (0.0, ResolvedColor::from_srgb(Srgb::new(1.0, 1.0, 1.0))),
            (1.0, ResolvedColor::from_srgb(Srgb::new(0.0, 0.0, 0.2))),
        ],
        [0.5, 0.5], // center
        0.0,        // start radius
        0.7,        // end radius
    )
}

Angular (conic) takes a center plus a start and end angle in radians:

use core::f32::consts::TAU;

fn conic_bg() -> impl View {
    Gradient::angular(
        vec![
            (0.0,  ResolvedColor::from_srgb(Srgb::new(1.0, 0.0, 0.0))),
            (0.33, ResolvedColor::from_srgb(Srgb::new(0.0, 1.0, 0.0))),
            (0.66, ResolvedColor::from_srgb(Srgb::new(0.0, 0.0, 1.0))),
            (1.0,  ResolvedColor::from_srgb(Srgb::new(1.0, 0.0, 0.0))),
        ],
        [0.5, 0.5],
        0.0,
        TAU,
    )
}

A gradient stretches to fill its parent, so zstack it under your content or constrain it with .size(w, h).

Mesh gradients

A mesh gradient interpolates across a vertex grid. Supply exactly width * height vertices in row-major order — Gradient::mesh asserts on any other count rather than silently rendering garbage.

fn mesh_bg() -> impl View {
    let red    = ResolvedColor::from_srgb(Srgb::new(1.0, 0.0, 0.0));
    let blue   = ResolvedColor::from_srgb(Srgb::new(0.0, 0.0, 1.0));
    let green  = ResolvedColor::from_srgb(Srgb::new(0.0, 1.0, 0.0));
    let yellow = ResolvedColor::from_srgb(Srgb::new(1.0, 1.0, 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 (cubic) color interpolation
    )
}

Building the config directly

Gradient::new(GradientConfig) takes the whole configuration when you want to compute it rather than pick a named constructor. Every field is public and GradientConfig implements Default:

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],
    ..GradientConfig::default()
};

let view = Gradient::new(config);

GradientConfig::linear / radial / angular / mesh mirror the Gradient constructors when you want the config without the view.

Reactive mesh gradients

waterui::graphics::MeshGradient<C> accepts any signal whose output iterates ResolvedColor values — a Binding<Vec<ResolvedColor>> is the usual choice. Positions come from the grid dimensions, so you only supply colors, again in row-major order. The renderer compares the incoming colors with the previous frame’s and skips the GPU upload when nothing changed.

use waterui::prelude::*;
use waterui::graphics::MeshGradient;
use waterui::graphics::color::ResolvedColor;

fn reactive_mesh(colors: Binding<Vec<ResolvedColor>>) -> impl View {
    MeshGradient::new(3, 3, colors).smooths_colors(true)
}

Updating the binding repaints the existing surface; the view is never rebuilt. into_surface() returns the underlying GpuSurface if you need to configure MSAA or the HDR preference.

Self-animating gradients

Two views animate on their own, with no host-side ticking.

AnimatedMeshGradient

A 4×4 color palette warped by GPU noise:

use waterui::graphics::AnimatedMeshGradient;

fn animated_background() -> impl View {
    AnimatedMeshGradient::default()
}

AnimatedMeshGradientConfig carries the speed, the warp amount, and the palette. Four palettes ship built in — aqua_bloom, pastel_lagoon, soft_blush, deep_blue:

use waterui::graphics::{AnimatedMeshGradient, AnimatedMeshGradientConfig};

fn bespoke_background() -> impl View {
    AnimatedMeshGradient::new(
        AnimatedMeshGradientConfig::aqua_bloom()
            .speed(0.8)
            .warp(0.3),
    )
}

.palette([...]) takes your own [ResolvedColor; ANIMATED_MESH_PALETTE_LEN], where that constant is 16 (a 4×4 grid). .speed(...) and .warp(...) both assert on negative values. speed(0.0) freezes the animation, which also stops the per-frame redraw request — the right move when the surface is offscreen or the app is backgrounded.

FlowingGradient

A procedural fBm-noise shader producing a slow, ocean-like flow. It has no configuration at all:

use waterui::graphics::flowing_gradient::FlowingGradient;

fn ambient_bg() -> impl View {
    FlowingGradient::default()
}

Composing with other views

use waterui::prelude::*;
use waterui::graphics::{AnimatedMeshGradient, AnimatedMeshGradientConfig};

fn welcome_card() -> impl View {
    zstack((
        AnimatedMeshGradient::default(),
        vstack((
            text("Welcome"),
            text("Gradient backgrounds"),
        ))
        .padding(),
    ))
}

fn banner() -> impl View {
    AnimatedMeshGradient::new(AnimatedMeshGradientConfig::deep_blue())
        .size(400.0, 200.0)
}

Performance notes

  • Linear, radial, and angular gradients resolve to native gradient primitives. Mesh and animated-mesh gradients each run a single full-screen quad through their own shader.
  • MeshGradient<C> re-uploads its vertex colors only when they actually differ from the previous frame.
  • AnimatedMeshGradient requests a redraw every frame while speed > 0.0, and FlowingGradient is built on ShaderSurface, which always animates. Neither one idles — do not leave one running behind an invisible screen.

Next

That completes the graphics part. The gradient views here, the filters from chapter 4, and a GpuSurface of your own compose like any other view, so the next part puts them into complete screens.

Animation

In this chapter, you will:

  • Attach animation metadata to a signal so the renderer interpolates it
  • Choose between bezier curves and spring physics
  • Animate transforms, shape geometry, and collection membership
  • Implement Animatable for your own types
  • Drive a timeline manually with AnimationTrack

WaterUI has no “start animation from A to B” call. You attach an Animation to a reactive value, and every later change to that value is interpolated instead of applied instantly. Because the metadata rides on the signal, any modifier that already accepts a signal animates for free.

Attaching an animation to a signal

SignalExt::with attaches metadata to a signal’s emissions. Pass an Animation and the value becomes animated:

use waterui::animation::Animation;
use waterui::prelude::*;

let scale = Binding::f32(1.0);
let animated_scale = scale.with(Animation::spring(300.0, 15.0));

Blue.size(80.0, 80.0)
    .scale(animated_scale.clone(), animated_scale)

Setting scale to 1.5 now springs the box to its new size. The binding itself is unchanged — with returns a wrapper, so the same binding can feed several views with different timings.

For the common case, .animated() applies WaterUI’s default timing (ease-in-out over 250 ms):

use waterui::prelude::*;

let opacity = Binding::f64(1.0);
let fade = opacity.animated();

Note: .animated() comes from the prelude. Importing waterui::animation::AnimationExt additionally brings .with_animation(animation), a named alias for .with(animation).

Bezier curves

Animation::Bezier is a timed curve through two control points, running from (0, 0) to (1, 1). Four constructors 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

Animation::bezier takes the control points directly:

use core::time::Duration;
use waterui::animation::Animation;

let bounce = Animation::bezier(Duration::from_millis(400), 0.25, 0.1, 0.25, 1.0);

x1 and x2 must lie in [0.0, 1.0]; y1 and y2 are unclamped so curves can overshoot. Out-of-range or non-finite values panic inside Animation::bezier.

Spring physics

Drag releases and toggles feel wrong on a fixed-duration curve. Springs take stiffness (how hard the spring pulls) and damping (how fast oscillation dies):

use waterui::animation::Animation;

let springy = Animation::spring(100.0, 10.0);

The damping ratio damping / (2 * sqrt(stiffness)) decides the character: below 1.0 the value overshoots and oscillates, at 1.0 it arrives as fast as possible without overshoot, above 1.0 it eases in slowly. Start at (100.0, 10.0), lower the damping for more bounce, raise the stiffness to make it snappier. Animation::spring panics on a non-positive stiffness or a negative damping.

Springs have no natural end time. Animation::duration() reports 600 ms for scheduling purposes, but how long the motion looks like it lasts is decided by the physics parameters.

Different curves, one state change

Each signal carries its own metadata, so a single state change can drive several properties on different timings:

use core::time::Duration;
use waterui::animation::Animation;
use waterui::prelude::*;

fn card(revealed: &Binding<bool>) -> impl View {
    let opacity = revealed.select(1.0, 0.0)
        .with(Animation::ease_in_out(Duration::from_millis(300)));
    let offset_y = revealed.select(0.0, 100.0)
        .with(Animation::spring(100.0, 10.0));
    let scale = revealed.select(1.0, 0.8)
        .with(Animation::ease_out(Duration::from_millis(250)));

    text("Now you see me")
        .padding()
        .opacity(opacity)
        .offset(0.0, offset_y)
        .scale(scale.clone(), scale)
}

Flipping revealed starts all three at once, and each settles on its own schedule. A derived signal animates the same way — attach the metadata after the map or zip:

use core::time::Duration;
use waterui::animation::Animation;
use waterui::prelude::*;

let count = Binding::i32(0);
let opacity = count.map(|n: i32| if n > 5 { 1.0 } else { 0.5 }).animated();

let width = Binding::f32(0.0);
let height = Binding::f32(0.0);
let area = width
    .zip(&height)
    .map(|(w, h)| w * h)
    .with(Animation::ease_in_out(Duration::from_millis(250)));

What animates, and what only updates

Transforms and opacity are compositor properties: .scale(), .rotation(), .offset(), and .opacity() hand the animation metadata to the platform animator, so the value is interpolated frame by frame.

Layout parameters are different. Stack spacing and the Frame dimensions accept signals and re-run layout when the signal changes, but the change is applied in one step — the layout invalidation carries no animation metadata:

use waterui::prelude::*;

fn toolbar(compact: &Binding<bool>) -> impl View {
    // Reactive: the stack re-lays out when `compact` flips.
    // Not interpolated: the gap jumps from 20 to 4.
    hstack((text("Cut"), text("Copy"), text("Paste")))
        .spacing(compact.select(4.0, 20.0))
}

To animate a size change, animate a transform on top of a fixed layout rather than animating the layout itself.

Animating collection membership

Rows appearing and disappearing in a ForEach or List pop in by default. Wrap the subtree in collection_transition to fade and grow entering items and fade and collapse exiting ones:

use core::time::Duration;
use waterui::animation::Animation;
use waterui::prelude::*;

collection_transition(
    List::for_each(rows.clone(), row_view),
    Animation::ease_out(Duration::from_millis(220)),
)

The transition is scoped through the environment, so every reactive collection inside content picks it up. Backends without support render the collection correctly, just without the transition.

Shape morphing

Geometry morphing is not a native transform, so WaterUI renders it on the GPU through its own interpolation pipeline:

use core::time::Duration;
use waterui::prelude::*;
use waterui::shape::{Capsule, Circle, Rectangle, RoundedRectangle, ShapeExt};

hstack((
    Circle
        .morph_to(RoundedRectangle::new(0.22), Color::srgb_hex("#3B82F6"))
        .duration(Duration::from_millis(1100))
        .size(90.0, 90.0),
    Rectangle
        .morph_to(Capsule, Color::srgb_hex("#10B981"))
        .duration(Duration::from_millis(900))
        .autoreverse(true)
        .size(128.0, 72.0),
))

Morphing supports the SDF-backed built-ins: Rectangle, Circle, Ellipse, RoundedRectangle, UnevenRoundedRectangle, and Capsule.

Making your own types animatable

Interpolation is defined by the Animatable trait. A type exports a payload the animation system already knows how to blend, and reconstructs itself from the blended payload:

use waterui::animation::Animatable;

#[derive(Clone)]
struct Rgb {
    r: f32,
    g: f32,
    b: f32,
}

impl Animatable for Rgb {
    type AnimatableData = (f32, f32, f32);

    fn animatable_data(&self) -> Self::AnimatableData {
        (self.r, self.g, self.b)
    }

    fn from_animatable_data(data: Self::AnimatableData) -> Self {
        Self { r: data.0, g: data.1, b: data.2 }
    }
}

WaterUI ships Animatable for f32, f64, tuples up to four elements, and [T; N] where T: Animatable + Copy. Pick whichever of those shapes matches your field layout as AnimatableData and you never have to write interpolation math.

Driving a timeline yourself

Custom renderers sometimes own their frame loop. AnimationTrack<T> holds one value plus its in-flight animation, and you advance it by a frame delta:

use core::time::Duration;
use waterui::animation::{Animation, AnimationTrack};

let mut track = AnimationTrack::new(0.0_f32);
track.set_target(1.0, Some(Animation::ease_in_out(Duration::from_millis(120))));

// Once per frame:
let still_running = track.advance(Duration::from_millis(16));
let value = track.value();

advance returns false once the animation has landed on its target, and set_target with None (or a zero-duration animation) applies the value immediately. For a one-off sample without a track, Animation::interpolate, Animation::progress, and Animation::is_complete take the elapsed time directly:

use core::time::Duration;
use waterui::animation::Animation;

let anim = Animation::ease_in_out(Duration::from_millis(300));
let value = anim.interpolate(&0.0_f32, &100.0_f32, Duration::from_millis(150));
let progress = anim.progress(Duration::from_millis(150));
let done = anim.is_complete(Duration::from_millis(300));

What’s next

Animation reacts to state; gestures produce it. In the next chapter you will recognize taps, drags, pinches, and rotations, and feed them into the signals you just learned to animate.

Gestures and haptics

In this chapter, you will:

  • Predict which view receives a touch under WaterUI’s hit-testing model
  • Attach tap, long-press, drag, pinch, and rotation gestures to views
  • Inject reactive state and pull it back with the State<T> extractor
  • Compose gestures sequentially, simultaneously, and with priority
  • Add haptic feedback, and know what it costs in dependencies

Gesture descriptors in WaterUI are plain data. You describe what should be recognized; each backend translates that into a platform gesture recognizer. Nothing in the descriptor knows about touch coordinates or timers.

Hit-testing model

WaterUI passes touches through views that have no interaction of their own:

  • Non-interactive views (plain Text, Spacer, layout containers) never intercept a touch. It falls through to whatever is behind them in Z-order.
  • Interactive views (Button, anything carrying a GestureObserver) capture touches inside their bounds.

So in a ZStack or an overlay, the topmost interactive view at the touch point wins — not simply the topmost view.

use waterui::prelude::*;

zstack((
    video_player(url).show_controls(true),
    vstack((
        spacer(),                                 // transparent to touch
        button("Play").action(|| { /* ... */ }),  // captures touch
    )),
))

If a tap is not reaching the view you expect, look for an interactive element in the overlay above it.

Gesture types

The descriptors live in waterui::gesture.

use waterui::gesture::{
    DragGesture, LongPressGesture, MagnificationGesture, RotationGesture, TapGesture,
};

let single = TapGesture::new();                  // one tap
let double = TapGesture::repeat(2);              // two consecutive taps
let press = LongPressGesture::new(500);          // hold for 500 time units
let drag = DragGesture::new(5.0);                // 5pt minimum travel
let pinch = MagnificationGesture::new(1.0);      // initial scale factor
let rotation = RotationGesture::new(0.0);        // initial angle, radians

LongPressGesture’s duration is a raw u32 that each backend interprets in its own time unit — in practice, milliseconds. The .on_long_press_gesture() modifier below documents its argument as milliseconds explicitly.

Every descriptor converts into the Gesture enum, which also holds the composition variants:

use waterui::gesture::{Gesture, TapGesture};

let gesture: Gesture = TapGesture::new().into();

Event payloads

When a backend recognizes a gesture it puts a payload into the environment, which a handler reads with the Use<T> extractor:

Event typeFields
TapEventlocation: GesturePoint, count: u32
LongPressEventlocation: GesturePoint, duration: f32
DragEventphase, location, translation, velocity
MagnificationEventphase, center, scale, velocity

GesturePhase is Started, Updated, Ended, or Cancelled.

RotationEvent mirrors MagnificationEvent for the other two-finger transform gesture, carrying the phase, the pivot point, the current angle in radians, and the angular velocity.

Attaching a gesture

ViewExt::gesture is the general form. Its first argument is anything that implements Into<Gesture>; its second is any Handler<Args, ()> — a bare closure, or one that names extractors like State<T>, as described in Resolvers and hooks.

use waterui::gesture::TapGesture;
use waterui::prelude::*;

text("Tap me").gesture(TapGesture::new(), || tracing::info!("tapped"))

The shorthands cover the common cases:

ModifierEquivalent
.on_tap(action).gesture(TapGesture::new(), action)
.on_tap_gesture(action)alias for .on_tap
.on_tap_gesture_count(n, action).gesture(TapGesture::repeat(n), action)
.on_long_press_gesture(ms, action).gesture(LongPressGesture::new(ms), action)

There is deliberately no .simultaneous_gesture(...) or .high_priority_gesture(...). Both existed as silent aliases for .gesture(...) that changed no recognition precedence, which invited code that looked correct and was not; they were removed rather than shipped as no-ops. Compose the gestures explicitly (below) when precedence matters.

To reuse a descriptor plus its handler, build a GestureObserver and attach it with .gesture_observer(...):

use waterui::gesture::{GestureObserver, TapGesture};
use waterui::prelude::*;

let counter = Binding::i32(0);

text("Count taps")
    .state(&counter)
    .gesture_observer(GestureObserver::new(
        TapGesture::repeat(2),
        |State(counter): State<Binding<i32>>| *counter.get_mut() += 1,
    ))

Getting state into a handler

Handlers are resolved from the environment, not from captured variables. Inject a value with ViewExt::state, then name it in the handler signature through State<T>. Values are keyed by type, so one injection serves every handler in the subtree:

use waterui::gesture::TapGesture;
use waterui::prelude::*;

let count = Binding::i32(0);

text!("Tapped {count} times")
    .padding()
    .background(Color::srgb(200, 220, 255))
    .state(&count)
    .gesture(
        TapGesture::new(),
        |State(count): State<Binding<i32>>| *count.get_mut() += 1,
    )

Stack .state(...) calls to inject several values, and name one extractor per value:

use waterui::prelude::*;

let count = Binding::i32(0);
let status = Binding::container(Str::from("Ready"));

text("Interact")
    .state(&count)
    .state(&status)
    .on_tap(
        |State(count): State<Binding<i32>>, State(status): State<Binding<Str>>| {
            *count.get_mut() += 1;
            status.set(Str::from("Tapped"));
        },
    )

Keep this to three injected values or fewer. Beyond that, bundle the state in one #[derive(Clone)] struct and inject that instead — a handler with four State<Binding<T>> parameters is a sign the state belongs together.

Combining gestures

Every descriptor carries the three composition methods, and each produces a Gesture::Then, Gesture::Simultaneous, or Gesture::Exclusive.

use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};

// Sequential: the long press only starts after the tap completes.
let chained = TapGesture::new().then(LongPressGesture::new(300));

// Simultaneous: both may be recognized at once.
let combined = TapGesture::new().simultaneously_with(DragGesture::new(8.0));

// Exclusive: the tap has priority, the long press is the fallback.
let exclusive = TapGesture::new().exclusively_before(LongPressGesture::new(500));

.sequenced_before() is an alias for .then(). Compositions nest, so a.then(b).simultaneously_with(c) is valid.

Haptic feedback

Haptics are behind the non-default std feature, which pulls in the waterkit-haptic crate:

waterui = { version = "0.2", features = ["std"] }

With that enabled, .on_tap_haptic_default(action) fires a medium impact before running the action:

use waterui::prelude::*;

text("Save").on_tap_haptic_default(|| tracing::info!("saved"))

.on_long_press_haptic_default(ms, action) is the long-press equivalent. The explicit-intensity forms, .on_tap_haptic(intensity, action) and .on_long_press_haptic(ms, intensity, action), take a waterkit_haptic::Intensity (LOW, MEDIUM, HIGH, MAX, or Intensity::new(value)). That type is not re-exported through waterui, so using them means adding waterkit-haptic to your own Cargo.toml.

Haptics are implemented for iOS, macOS, Android, Windows, and Linux. Where the platform cannot deliver one, the failure is logged at debug level and the action still runs — a gesture never silently stops working because a device has no haptic engine.

Putting it together

use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};
use waterui::prelude::*;

fn gesture_demo() -> impl View {
    let taps = Binding::i32(0);
    let presses = Binding::i32(0);
    let drags = Binding::i32(0);
    let chained = Binding::container(Str::from("Waiting"));

    scroll(vstack((
        text("Gesture demo").title(),

        text!("Taps: {taps}")
            .padding()
            .background(Color::srgb(33, 150, 243).with_opacity(0.3))
            .state(&taps)
            .gesture(
                TapGesture::new(),
                |State(c): State<Binding<i32>>| *c.get_mut() += 1,
            ),

        text!("Long presses: {presses}")
            .padding()
            .background(Color::srgb(255, 152, 0).with_opacity(0.3))
            .state(&presses)
            .gesture(
                LongPressGesture::new(500),
                |State(c): State<Binding<i32>>| *c.get_mut() += 1,
            ),

        text!("Drags: {drags}")
            .padding()
            .size(200.0, 100.0)
            .background(Color::srgb(156, 39, 176).with_opacity(0.3))
            .state(&drags)
            .gesture(
                DragGesture::new(5.0),
                |State(c): State<Binding<i32>>| *c.get_mut() += 1,
            ),

        text!("{chained}")
            .padding()
            .background(Color::srgb(244, 67, 54).with_opacity(0.3))
            .state(&chained)
            .gesture(
                TapGesture::new().then(LongPressGesture::new(300)),
                |State(s): State<Binding<Str>>| s.set(Str::from("Chain complete")),
            ),
    )))
}

Every counter is displayed with text!, so the label updates from the binding without rebuilding the view that owns the gesture.

Try it yourself: give the first box a double tap as well, using TapGesture::repeat(2).exclusively_before(TapGesture::new()), and watch how the single-tap fallback waits for the double-tap window to expire.

What’s next

A gesture that starts a network request needs somewhere to show progress. In the next chapter you will handle async work with Suspense and render a loading state while the data is in flight.

Suspense and async views

In this chapter, you will:

  • Show a placeholder while an async operation runs, then swap in the result
  • Customize the loading view per instance and app-wide
  • Implement SuspendedView to reach the environment during loading
  • Choose between Suspense and ViewExt::task based on cancellation needs
  • Reload suspended content when its input changes

A view body is synchronous, but data usually is not. Suspense bridges the two: it renders a loading view immediately, spawns your future on the local executor, and replaces the placeholder when the future resolves.

The Suspense component

Suspense lives in waterui::widget::suspense, and the suspense() shorthand is in the prelude.

use waterui::prelude::*;
use waterui::text::Text;
use waterui::widget::suspense::Suspense;

async fn fetch_user() -> Text {
    text(api::get_user_name().await)
}

let view = Suspense::new(fetch_user());

Any Future whose output is a View implements SuspendedView, which is why a plain async fn works with no extra glue.

Internally Suspense allocates a Dynamic node, sets the loading view into it, spawns the future with spawn_local, and sets the resolved content when it completes. The swap replaces that subtree — deliberately, since the placeholder and the content are different views.

Loading views

Per instance

.loading() overrides the placeholder for one Suspense:

use waterui::prelude::*;
use waterui::text::Text;
use waterui::widget::suspense::Suspense;

async fn fetch_data() -> Text {
    text("Data loaded!")
}

let view = Suspense::new(fetch_data())
    .loading(text("Loading data..."));

The turbofish is not optional. loading is declared as loading<Loading2, Output: View>(self, loading: Loading2), and Output appears nowhere in the arguments or the return type, so inference has nothing to work from and the call site has to spell it. Any View type satisfies it; naming the async function’s own output type, as here, at least keeps the intent readable.

App-wide

Install a DefaultLoadingView in the environment and every Suspense without an explicit .loading() picks it up. DefaultLoadingView::new takes any ViewBuilder, which a Fn() -> impl View closure satisfies:

use waterui::app::App;
use waterui::prelude::*;
use waterui::widget::suspense::DefaultLoadingView;

pub fn app(env: Environment) -> App {
    let mut env = env;
    env.insert(DefaultLoadingView::new(|| {
        vstack((loading(), text("Please wait...")))
    }));
    App::new(main, env)
}

loading() is the facade’s indeterminate circular Progress. Without a DefaultLoadingView, Suspense renders an empty view while loading — install one at the root so no async screen is ever blank.

UseDefaultLoadingView is the sentinel that performs that lookup. Suspense::new(fut) uses it already; naming it explicitly is only useful when you need to write the type out:

use waterui::widget::suspense::{Suspense, UseDefaultLoadingView};

// Identical to Suspense::new(fetch_data()).
let view = Suspense::new(fetch_data()).loading(UseDefaultLoadingView);

Implementing SuspendedView

pub trait SuspendedView: 'static {
    fn body(self, env: Environment) -> impl Future<Output = impl View>;
}

Implement it directly when the async work needs environment services — an API client, a configuration value, a locale. Suspense clones the environment before spawning, so everything in scope at construction is available inside the future:

use waterui::prelude::*;
use waterui::widget::suspense::{SuspendedView, Suspense};

struct UserLoader {
    user_id: u32,
}

impl SuspendedView for UserLoader {
    async fn body(self, env: Environment) -> impl View {
        let api = env
            .get::<ApiClient>()
            .expect("ApiClient must be installed before rendering UserLoader")
            .clone();
        let user = api.fetch_user(self.user_id).await;

        vstack((text(user.name).headline(), text(user.email)))
    }
}

let view = Suspense::new(UserLoader { user_id: 42 });

Failures are views too

Result<V, E> implements View when both sides do, so a fallible load can resolve to either branch:

use waterui::prelude::*;
use waterui::widget::error::Error;
use waterui::widget::suspense::Suspense;

async fn fetch_with_error() -> AnyView {
    match api::get_data().await {
        Ok(data) => text(data.content).anyview(),
        Err(e) => Error::new(e).anyview(),
    }
}

let view = Suspense::new(fetch_with_error());

Error::new renders through your app’s DefaultErrorView, so a failed load looks like every other failure in the app. The Error handling chapter covers ResultExt::error_view for shaping the error at the call site.

Suspense or ViewExt::task?

They differ in one respect that matters: cancellation.

Suspense detaches its task. If the user navigates away mid-flight, the future still runs to completion — fine for a read, a problem for anything with side effects.

ViewExt::task retains the task handle on the view. Dropping the view drops the handle, and dropping the handle cancels the task:

use waterui::prelude::*;
use waterui::reactive::binding;

fn my_view() -> impl View {
    let status: Binding<Str> = binding("Loading...");
    let sink = status.clone();

    text!("{status}").task(async move {
        sink.set(api::get_status().await);
    })
}

Reach for Suspense when the placeholder is a different view from the result. Reach for .task() when the view already exists and the async work only fills in reactive state.

Reloading when the input changes

Suspense resolves once. When the input identity changes — a different user id, a different document — the correct behavior is a genuinely new Suspense instance, placeholder included, and that is one of the rare cases watch exists for:

use waterui::prelude::*;
use waterui::widget::suspense::Suspense;

fn user_profile(user_id: Binding<u32>) -> impl View {
    watch(user_id, |id: u32| {
        Suspense::new(async move { text(api::get_user(id).await.name) })
    })
}

watch replaces the whole child subtree and discards any state it owned. That is what you want here and almost nowhere else: for a changing scalar use text! or a signal-taking input, and for a changing set of rows use ForEach / List. If the profile screen owns editable fields, hoist those bindings above the watch so they survive the reload.

Nesting

Inner content can suspend again, so each region appears as soon as its own data lands:

use waterui::prelude::*;
use waterui::text::Text;
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..."));

Both levels need the turbofish, for the same reason as above.


Next: Error handling, where the Error type you just spawned into a suspended view gets a proper presentation layer.

Error handling

In this chapter, you will:

  • Return Result and Option directly from view code
  • Wrap any std::error::Error as a view with Error
  • Configure app-wide error presentation with DefaultErrorView
  • Shape errors at the call site with ResultExt::error_view
  • Scope a different error presentation to one subtree

In a library you propagate errors with ? until someone handles them. In a UI, “handling” means rendering: a network failure has to become a view the user can read and act on. WaterUI does that by making errors views.

Two modules cover it:

  • waterui::widget::errorError, DefaultErrorView, UseDefaultErrorView, ResultExt. This is the one you configure once and use everywhere.
  • waterui::error — a smaller ErrorView / ErrorViewBuilder pair that falls back to plain text when nothing is configured.

Results and options are views

Result<V, E> implements View when both V: View and E: View, and Option<V> implements it with None rendering as empty. Fallible view functions need no wrapper type:

use waterui::prelude::*;

fn user_card() -> impl View {
    match load_user() {
        Ok(user) => text(user.name).anyview(),
        Err(_) => text("Failed to load user").anyview(),
    }
}

The Error type

A string is rarely enough. waterui::widget::error::Error wraps any std::error::Error, keeps the concrete type recoverable, and renders through whatever the environment says errors should look like:

use std::io;
use waterui::widget::error::Error;

let error_view = Error::new(io::Error::new(io::ErrorKind::NotFound, "File not found"));

When rendered, it looks up DefaultErrorView in the environment. Without one it renders nothing at all — which is why installing one at the root is not optional.

From a view

When the failure is not a Rust error — a validation state, a “no results” screen — build the presentation directly:

use waterui::prelude::*;
use waterui::widget::error::Error;

let custom_error = Error::from_view(vstack((
    text("Something went wrong!"),
    text("Please try again later."),
)));

Recovering the original error

use std::io;
use waterui::widget::error::Error;

let error = Error::new(io::Error::new(io::ErrorKind::NotFound, "File not found"));

match error.downcast::<io::Error>() {
    Ok(io_error) => assert_eq!(io_error.kind(), io::ErrorKind::NotFound),
    Err(original) => drop(original),  // not an io::Error; handle generically
}

downcast returns Ok(Box<T>) on a match and gives the Error back unchanged on a miss, so a failed downcast costs you nothing.

DefaultErrorView

DefaultErrorView holds a builder from BoxedStdError to a view, stored in the environment:

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(theme_color::MutedForeground),
    ))
}));

text! reads named placeholders from the surrounding scope and maps over them as signals, so the message has to be bound to an identifier first.

Note the color: theme_color::MutedForeground resolves against the installed theme, so the secondary line stays legible in dark mode. Hard-coding Color::srgb(128, 128, 128) here would be a bug on half the platforms.

Environment::extending is the by-value, chainable form — it returns a new environment overlaying the value. With &mut Environment in hand, env.insert(value) and the chainable env.with(value) mutate in place.

UseDefaultErrorView is the view that performs the lookup. Error::new creates one internally; you rarely name it directly.

The simple module

waterui::error is the lighter option: ErrorView renders through an ErrorViewBuilder if one is installed, and otherwise falls back to the error’s Display output as plain text.

use waterui::error::{ErrorView, ErrorViewBuilder};
use waterui::prelude::*;

let view = ErrorView::from(std::io::Error::new(std::io::ErrorKind::NotFound, "Not found"));

let mut env = Environment::new();
env.insert(ErrorViewBuilder::new(|error, env| {
    text(format!("Error: {error}")).anyview()
}));

The builder receives the environment as a second argument and must return AnyView. Use this module when a text fallback is genuinely acceptable; use widget::error when you want one deliberate presentation everywhere.

Shaping errors at the call site

ResultExt::error_view converts the Err variant into an Error wrapping a view you supply, leaving Ok untouched:

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

Use it when one call site needs a message the global builder cannot produce — a field name, a retry affordance specific to that operation. Everything else should fall through to Error::new and stay consistent.

Errors and async loading

The natural place to resolve both outcomes is inside the suspended body, so the Suspense sees a single view either way:

use waterui::prelude::*;
use waterui::text::Text;
use waterui::widget::error::Error;
use waterui::widget::suspense::Suspense;

async fn fetch_profile() -> AnyView {
    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..."))
}

Scoping a different presentation

Error is an ordinary view, so error boundaries follow the view hierarchy. To change the presentation for one subtree, wrap the configuration in a plugin and install it there with ViewExt::install:

use waterui::prelude::*;
use waterui::widget::error::{BoxedStdError, DefaultErrorView};
use waterui::{Environment, Plugin};

struct TopLevelErrorStyle;

impl Plugin for TopLevelErrorStyle {
    fn install(self, env: &mut Environment) {
        env.insert(DefaultErrorView::new(|error: BoxedStdError| {
            let message = Binding::container(error.to_string());
            vstack((
                text("Application error").headline(),
                text!("{message}"),
                button("Retry").action(|| tracing::info!("retry requested")),
            ))
        }));
    }
}

fn app_shell() -> impl View {
    vstack((header(), content_area())).install(TopLevelErrorStyle)
}

ViewExt::install clones the environment, runs the plugin against the clone, and attaches it to the subtree, so the outer presentation is untouched. Nesting works the same way: the nearest installed DefaultErrorView wins. The Plugins chapter goes deeper on the pattern.


Next: Accessibility — because an error message nobody can hear is not handled either.

Accessibility

In this chapter, you will:

  • Rely on the labels WaterUI forces every control to carry
  • Hide a label visually without removing it from the accessibility tree
  • Override labels, roles, and states for custom widgets
  • Report disabled and hidden states correctly
  • Assert on the accessibility tree in cargo test

WaterUI does not treat accessibility as an annotation you add later. Its control constructors put the accessible name in the signature, so the common path already produces a labeled control. The work left to you is the part the type system cannot do: describing custom composites and hiding decoration.

The types live in waterui::accessibility; the modifiers are on ViewExt.

Labels are mandatory, visibility is not

button, slider, stepper, toggle, and field all take an impl IntoLabel as their first argument. Screen readers, voice control, switch control, and command palettes all read that label — a control without one is an anonymous widget that assistive technology users cannot reach.

Reach for the ergonomic free functions rather than Toggle::new(&binding) and TextField::new(&binding), which start with an empty label and rely on you remembering .label(...) afterwards.

Hiding the label is a separate, presentational decision. .hide_label() collapses the label’s rendered view to zero size while keeping the semantic text in the accessibility tree:

use waterui::prelude::slider::slider;
use waterui::prelude::*;

let progress = Binding::f64(0.5);

// Announced as "Playback position"; nothing is drawn next to the track.
slider("Playback position", &progress).hide_label()

.hide_label() is shorthand for .label_style(LabelDisplayMode::Hidden). The other modes — TitleAndIcon, TitleOnly, IconOnly, and the default Automatic — pick between title and icon without ever dropping the semantic text. Install a LabelDisplayMode in the environment to set the default for a whole subtree.

Icon-only controls

An icon-only button is a display mode, not a label-less button:

use waterui::prelude::*;

// Still announced as "Search".
button(label("Search").icon(search_icon()).icon_only())
    .action(run_search)

Label::icon takes any view, so an icon-pack crate works here. SystemIcon (via Label::system_icon) renders SF Symbols on Apple platforms and is intentionally unsupported on Android, Linux, and Web — for portable code prefer Label::icon with waterui-icons-lucide, waterui-icons-material-icon, or waterui-icons-fontawesome7.

When the visible content is not the spoken text

Label::new(semantic_text, content) is the general constructor: it takes arbitrary visual content plus the text assistive technology should announce.

use waterui::prelude::*;

let verified = Label::new(
    "Account, verified",
    hstack((text("Account"), verification_badge())),
);

button(verified).action(open_account)

A Label::new label owns its own layout, so the semantic-label builders (.icon(), .system_icon(), .leading(), .trailing(), .spacing(), .font()) panic on it — compose those inside content instead.

Overriding a label

ViewExt::a11y_label replaces the spoken label for any view. Reach for it when the view is not a control and its visual content does not describe it:

use waterui::prelude::*;

logo_image().a11y_label("Acme, home")

Keep it short and action-oriented, and leave out prefixes like “Button:” — the role already communicates that.

The label is reactive. Pass a signal and a label derived from app state stays current without rebuilding the subtree, the same way accessibility state does:

use waterui::prelude::*;

fn inbox(unread: &Binding<i32>) -> impl View {
    let label = unread.clone().map(|n| Str::from(format!("{n} unread messages")));
    button("Inbox").action(|| {}).a11y_label(label)
}

Roles

AccessibilityRole describes what a view is. Built-in controls set their own role; custom composites need one assigned:

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

Navigation containers do not attach landmark roles for you. If you build a sidebar, say so:

use waterui::accessibility::AccessibilityRole;
use waterui::prelude::*;

vstack((
    text("Menu").headline(),
    button("Home").action(go_home),
    button("Settings").action(go_settings),
))
.a11y_role(AccessibilityRole::Navigation)
.a11y_label("Main navigation")

States

AccessibilityState carries what a label and role cannot express. It is a const builder, so a state is cheap to construct:

use waterui::accessibility::AccessibilityState;

let state = AccessibilityState::new().expanded(Some(true)).busy(false);
FieldMeaning
disabledVisible but not interactive
selectedThe current selection within its group
checkedSome(true), Some(false), or None for mixed
expandedSome(true) / Some(false) for disclosure controls
busyLoading or processing
hiddenNot exposed to assistive technology

Attach a fixed state with .a11y_state(state), or a reactive one with .a11y_state_signal(signal):

use waterui::accessibility::AccessibilityState;
use waterui::prelude::*;

fn disclosure(expanded: &Binding<bool>, content: impl View) -> impl View {
    let state = expanded.map(|open| AccessibilityState::new().expanded(Some(open)));

    content.a11y_state_signal(state)
}

Disabled and hidden come for free

Two ViewExt modifiers already write state for you:

  • .disabled(signal) installs a disabled scope over the subtree. Controls render their platform disabled appearance, stop hit-testing, and report the disabled state to assistive technology. Nested scopes OR-combine, so an enclosing .disabled(true) cannot be undone by a child.
  • .visible(signal) sets hidden on the accessibility state as it fades the view out, so an invisible view is not announced.

.disabled(...) reaches every control, because no control implements disabled state itself: each one reads the scope in force at its own position. A menu Command carries the state as data instead — a menu is a list of records handed to the platform’s menu API, with no leaf environment to read — and its own flag is OR-combined with the enclosing scope when the command resolves.

Hiding decoration

Background patterns, dividers, and brand marks add noise to a screen reader. ViewExt::a11y_hidden(true) drops a view from the tree:

use waterui::prelude::*;

decorative_swirl().a11y_hidden(true)

When you have re-described a whole composite with one label, drop its children instead of hiding the container:

use waterui::accessibility::AccessibilityChildren;
use waterui::prelude::*;

hstack((star_icon(), text("4.8"), text("(120)")))
    .a11y_label("Rated 4.8 out of 5, 120 reviews")
    .a11y_children(AccessibilityChildren::ExcludeDescendants)

A custom control, end to end

A star rating needs a role and a label on the container and on each star. Note that the filled/empty glyph comes from a mapped signal fed into text — not from watch, which would rebuild the star subtree on every rating change and discard its state.

use waterui::accessibility::{AccessibilityRole, AccessibilityState};
use waterui::prelude::*;

fn star_rating(rating: &Binding<i32>, max: i32) -> impl View {
    hstack(
        (0..max)
            .map(|i| {
                let glyph = rating.map(move |r| if r > i { "★" } else { "☆" }).computed();
                let filled = rating.map(move |r| {
                    AccessibilityState::new().selected(r > i)
                });

                text(glyph)
                    .a11y_label(format!("Rate {} of {max}", i + 1))
                    .a11y_role(AccessibilityRole::Button)
                    .a11y_state_signal(filled)
                    .state(rating)
                    .on_tap(move |State(r): State<Binding<i32>>| r.set(i + 1))
            })
            .collect::<Vec<_>>(),
    )
    .a11y_role(AccessibilityRole::Group)
    .a11y_label("Rating")
}

The current value reaches the screen reader through each star’s selected state rather than through the container’s label. That is deliberate: AccessibilityLabel wraps a plain Str, so labels are fixed when the view is built. Only AccessibilityState has a reactive form (.a11y_state_signal(...)). When a value genuinely needs to be announced as it changes, use a real Slider — it reports its own value — instead of relabeling a custom composite.

Reduced motion

WaterUI does not ship a “prefers reduced motion” signal. Define a marker type, install it from your backend integration, and pick the animation from it:

use core::time::Duration;
use waterui::animation::Animation;
use waterui::prelude::*;

#[derive(Debug, Clone, Copy)]
struct PrefersReducedMotion(bool);

fn entrance(env: &Environment) -> impl View {
    let opacity = Binding::f32(0.0);
    let reduced = env.get::<PrefersReducedMotion>().is_some_and(|p| p.0);

    let animation = if reduced {
        Animation::linear(Duration::ZERO)
    } else {
        Animation::ease_in_out(Duration::from_millis(300))
    };

    text("Welcome")
        .opacity(opacity.with(animation))
        .on_appear(move || opacity.set(1.0))
}

A zero-duration animation applies the value immediately, so the same code path serves both preferences. This matters for users with vestibular disorders — wire your platform’s reduced-motion API into the environment rather than ignoring the preference.

Focus

ViewExt::focused drives both the visual focus ring and accessibility focus from one binding:

use waterui::prelude::*;

#[derive(Clone, PartialEq, Eq)]
enum Field {
    Name,
    Email,
}

let focus = Binding::container(None::<Field>);
let name = Binding::container(Str::from(""));

field("Name", &name).focused(&focus, Field::Name)

Setting focus to Some(Field::Name) moves VoiceOver or TalkBack focus to that field.

Testing the tree

waterui-testing drives views through the Hydrolysis accessibility tree, so an interaction test is an accessibility test. Query by role and label, then assert on the node:

use waterui::prelude::*;
use waterui_testing::{Role, SemanticApp};

fn submit_button() -> impl View {
    button("Submit").action(|| {}).disabled(true)
}

#[waterui::test(submit_button)]
fn submit_is_named_and_reports_disabled(app: &mut SemanticApp) {
    let element = app.query().role(Role::BUTTON).label("Submit").single();
    assert!(!element.node().enabled());
}

#[waterui::test(...)] expands to a plain #[test], so these run under the normal harness. If a component cannot be reached by role and label, that is a bug in the component, not a reason to skip the test.

Pair automated checks with the platform auditors before shipping: Accessibility Inspector on iOS and macOS, Accessibility Scanner on Android, VoiceOver (Cmd+F5), and Accerciser for AT-SPI on GTK. Ten minutes navigating your own app with a screen reader turned on finds things no assertion will.

What’s next

Your app is usable regardless of ability. In the next chapter you will make it readable regardless of language, with translation catalogs, CLDR plural rules, and locale-aware formatting.

Internationalization

In this chapter, you will:

  • Write TOML translation files that text! compiles into your binary
  • Handle CLDR plural forms, including languages with four of them
  • Understand script-aware fallback (zh-TW resolves to zh-Hant, never zh-Hans)
  • Format dates, numbers, units, and lists per locale
  • Switch locale at runtime and have every string follow

Translating strings is the easy half. Plural rules differ (English has two forms, Russian has four), dates reorder their fields, units change symbol, and lists change their conjunction. WaterUI puts all of that behind ICU4X and wires the result into the reactive system, so a locale change updates text in place without rebuilding views.

Translation files

Put one TOML file per locale in an i18n/ directory next to your crate’s Cargo.toml. The text! macro reads them at compile time from CARGO_MANIFEST_DIR, so a library crate uses its own i18n/, not the application’s.

my-app/
├── Cargo.toml
├── i18n/
│   ├── en.toml
│   ├── de.toml
│   ├── ru.toml
│   └── zh-Hant.toml
└── src/lib.rs

The key is the source string as it appears in code; the value is the translation:

# i18n/de.toml
"Welcome to the World Fair!" = "Willkommen auf der Weltausstellung!"
"Language Booth" = "Sprachkabine"

For a long paragraph, a $-prefixed key keeps the code readable:

# i18n/en.toml
"$udhr_article_1" = "All human beings are born free and equal in dignity and rights."

If a key is missing everywhere, text! falls back through the locale chain, then to English, then to the key itself — so an untranslated string shows the source text rather than a placeholder.

Plural forms

Mark the pluralizing variable with {#name} in the key, and use {name} without the # in the values:

# i18n/en.toml
"I have {#count} passport stamp" = { one = "I have {count} passport stamp", other = "I have {count} passport stamps" }
# i18n/ru.toml
"I have {#count} passport stamp" = { one = "У меня {count} паспортный штамп", few = "У меня {count} паспортных штампа", many = "У меня {count} паспортных штампов", other = "У меня {count} паспортных штампов" }
# i18n/zh.toml
"I have {#count} passport stamp" = { other = "我有{count}个护照章" }

The form keys are the CLDR categories: zero, one, two, few, many, and other. Only other is required, and a plural entry without it fails the build. Chinese, Japanese, and Korean use other alone; English uses one and other; Russian uses one, few, many, and other.

When one sentence pluralizes two quantities independently, use the four dual forms:

"I have {#apples} apple and {#oranges} orange" = { one_one = "…", one_other = "…", other_one = "…", other_other = "…" }

The text! macro

text! expands to a Text view that resolves its content against the environment’s effective locale and re-resolves when that locale changes. Placeholders capture identifiers from the surrounding scope:

use waterui::prelude::*;

fn greeting(name: &str) -> impl View {
    text!("Hello, {name}!").headline()
}

Pass a value explicitly with name = expr when the local variable has a different name — or when there is no local variable at all:

use waterui::prelude::*;

vstack((
    text!("I have {#count} passport stamp", count = 0),
    text!("I have {#count} passport stamp", count = 1),
    text!("I have {#count} passport stamp", count = 5),
))

Placeholder values are signals. Constants like 0 work, and so does a Binding<i32> — in which case the string re-renders on every change without rebuilding the surrounding view:

use waterui::prelude::*;

let count = Binding::i32(0);
text!("I have {#count} passport stamp", count = count.clone())

When the same English string needs two different translations, disambiguate with @context. The context becomes part of the lookup key:

text!("Open" @ verb)      // key: "Open#verb"
text!("Open" @ adjective) // key: "Open#adjective"

text! returns a Text, so the whole styling surface applies: .size(n), .bold(), .font(f), .body(), .title(), .headline(), .sub_headline(), .caption(), .footnote(). (.italic(signal) takes a boolean signal rather than being a bare toggle.)

Locales

waterui::locale::Locale wraps an ICU4X locale and keeps its Unicode extensions (u-ca calendar, u-hc hour cycle, u-nu numbering system), so it represents a full preference rather than a bare language tag.

use core::str::FromStr;
use waterui::locale::{Locale, locales};

let locale = Locale::from_str("en-US").expect("valid BCP 47 tag");
let tag = locale.canonical_tag();   // "en-US"
let lang = locale.language.as_str(); // "en" — via Deref to the language identifier

Parsing failures surface as icu_locale::ParseError. The locales module has constants so common tags never need parsing: EN, EN_US, EN_GB, ZH_CN, ZH_TW, ZH_HK, ZH_HANS, ZH_HANT, JA, KO, FR, DE, ES, RU, AR, HI, PT, PT_BR, PT_PT, SR_LATN, SR_CYRL.

Fallback is script-aware

Lookups walk ICU4X’s fallback chain, which understands scripts:

use core::str::FromStr;
use waterui::locale::Locale;
use waterui::locale::locale::get_fallback_chain;

let chain = get_fallback_chain(&Locale::from_str("zh-TW").unwrap());
// zh-TW → zh-Hant → zh   (never zh-Hans)

This is why a Taiwan build can keep only its Taiwan-specific vocabulary in i18n/zh-TW.toml and let everything else resolve from i18n/zh-Hant.toml. Serbian behaves the same way: sr-Latn never falls back into Cyrillic.

Plural rules directly

select_plural gives the CLDR category for a number in a locale, and valid_categories lists the categories a locale actually uses — handy for validating a translator’s file:

use waterui::locale::plural::valid_categories;
use waterui::locale::{PluralCategory, locales, select_plural};

assert_eq!(select_plural(&locales::EN, &1), PluralCategory::One);
assert_eq!(select_plural(&locales::EN, &0), PluralCategory::Other);

assert_eq!(select_plural(&locales::FR, &0), PluralCategory::One);  // 0 is "one" in French

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

let en = valid_categories(&locales::EN);
assert!(en.contains(&PluralCategory::One) && en.contains(&PluralCategory::Other));

Rules use absolute values, so a negative number selects the same category as its positive counterpart, and fractional values are handled per CLDR (1.2 is Other in English).

Locale-aware formatting

LocalizedDisplay::to_localized_string(&locale) is the entry point for everything below. A blanket implementation covers all Display types, which gives you a working call for any type — but only types with a real implementation produce locale-specific output.

Units

waterui::locale::format::unit gives dimensionally typed quantities that convert, add, and format themselves:

use waterui::locale::format::unit::{Kilometer, Length, Meter, Mile};
use waterui::locale::{LocalizedDisplay, locales};

let distance = Length::<Meter>::new(1500.0);
distance.to_localized_string(&locales::EN);    // "1,500m"
distance.to_localized_string(&locales::ZH_CN); // "1,500米"
distance.to_localized_string(&locales::JA);    // "1,500メートル"

let km = Length::<Kilometer>::new(1.0);
let in_miles = km.to::<Mile>();                 // ≈0.621 mi
let total = km + Length::<Meter>::new(500.0);   // 1.5 km — result keeps the left unit

The number is formatted for the locale and the symbol is appended without a separator. Three families ship today: Length (Meter, Kilometer, Mile, Feet), Mass (Kilogram, Gram, Pound, Ounce), and Temperature (Celsius, Fahrenheit, Kelvin).

Dates and times

use waterui::locale::format::date::{DateStyle, SimpleDate, TimeStyle, SimpleTime, format_date, format_time};
use waterui::locale::locales;

let date = SimpleDate::new(2006, 3, 20);

format_date(&locales::EN, &date, DateStyle::Short);   // slash-separated, month first
format_date(&locales::DE, &date, DateStyle::Short);   // dot-separated, day first
format_date(&locales::JA, &date, DateStyle::Long);    // 2006年3月20日

format_time(&locales::EN, &SimpleTime::new(9, 30, 0), TimeStyle::Short);

Both styles run Short, Medium, Long, and Full. Use format_datetime_with_regional_context when the time zone from the device’s regional settings should be part of the output.

Numbers and lists

use waterui::locale::format::LocalizedList;
use waterui::locale::format::number::{Currency, format_currency, format_number, format_percent};
use waterui::locale::{LocalizedDisplay, locales};

format_number(&locales::DE, 1234.5);
format_percent(&locales::EN, 0.42);
format_currency(&locales::EN, 19.99, Currency::USD);

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"

LocalizedList borrows a &[&str], so format the elements yourself first if they are not already strings.

Choosing the locale

WaterUI resolves the effective locale in this order, taking the first match:

  1. a Binding<Locale> installed in the environment,
  2. a RegionalContext in the environment,
  3. a plain Locale in the environment,
  4. the shared runtime locale, seeded from the platform.

Overriding a subtree

Insert a Locale for one branch of the tree with ViewExt::with:

use waterui::locale::locales;
use waterui::prelude::*;

vstack((text!("Welcome to the World Fair!"), text!("Language Booth")))
    .with(locales::JA)

Switching at runtime

For an app-wide language picker, drive the shared runtime locale. waterui::regional::set_locale_tag takes a BCP 47 tag and returns an error for an invalid one; every text! in the app re-resolves:

use waterui::form::picker::Picker;
use waterui::prelude::*;

fn language_picker() -> impl View {
    let selection = Binding::container("en-US");

    Picker::new(
        [
            text("English (US)").tag("en-US"),
            text("Deutsch").tag("de"),
            text("日本語").tag("ja"),
        ],
        &selection,
    )
    .on_change(&selection, |tag| {
        waterui::regional::set_locale_tag(tag).expect("picker tags must be valid");
    })
}

Nothing is wrapped in watch here. text! subscribes to the locale itself, so switching languages re-renders the strings without recreating the picker or losing its selection.

Reading the locale in a view

Locale implements Extractor, so use_env hands it to you:

use waterui::env::use_env;
use waterui::locale::Locale;
use waterui::prelude::*;

fn current_language() -> impl View {
    use_env(|locale: Locale| text(locale.canonical_tag()))
}

This is a snapshot, not a subscription: the extractor reads the locale once when the body runs, and the view will not update when the locale changes. Use it for one-time setup, and keep reactive strings in text!.

Formatting helpers take &Locale rather than reading the environment, so map a locale signal into the string you want and let a single text leaf update:

use waterui::locale::format::unit::{Length, Meter};
use waterui::locale::{Locale, LocalizedDisplay};
use waterui::prelude::*;

fn distance_label(locale: Computed<Locale>) -> impl View {
    let distance = locale
        .map(|locale| Length::<Meter>::new(1500.0).to_localized_string(&locale))
        .computed();

    text!("{distance}")
}

Runtime catalogs

text! needs no runtime data — its translations are baked in at expansion time. Components that look strings up at runtime (a Table re-resolving its column labels on a locale change, for example) read a TranslationCatalog from the environment. catalog!() builds one from the same i18n/ directory, and configure_environment!(env) installs it at a backend entry point.

What’s next

Localization is one cross-cutting concern threaded through the environment; theming, analytics, and error views are others. In the next chapter you will package such concerns as plugins that extend the framework without touching its core.

Plugins

In this chapter, you will:

  • Install services and configuration into an Environment with the Plugin trait
  • Scope a plugin to the whole app or to one view subtree
  • Make installed values extractable so views read them as typed parameters
  • Store several values of one type under phantom keys with store / query
  • Register a view hook from inside a plugin

Theming, analytics, default error and loading views: cross-cutting concerns accumulate, and scattering env.insert(...) calls through view code hides what an application actually depends on. A plugin packages one of those concerns as a value that knows how to install itself.

The Plugin trait

Plugin is re-exported at the facade root as waterui::Plugin (it is not in the prelude):

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 defaults: install stores the plugin keyed by its own concrete type, uninstall removes it. Override install when the plugin needs to inject something other than itself — a service, several values, or a hook.

The empty implementation is already useful as a feature marker:

use waterui::{Environment, Plugin};

struct DebugOverlay;
impl Plugin for DebugOverlay {}

let mut env = Environment::new();
env.install(DebugOverlay);
assert!(env.get::<DebugOverlay>().is_some());

Installing

For the whole application

Environment::install calls plugin.install(&mut self) and returns &mut Self, so installations chain:

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

pub fn app(env: Environment) -> App {
    let mut env = env;
    env.install(ThemePlugin::dark())
        .install(AnalyticsPlugin::new("api-key"));
    App::new(main, env)
}

For one subtree

ViewExt::install clones the environment, applies the plugin to the clone, and attaches it to the wrapped view. Everything outside is unaffected:

use waterui::prelude::*;

fn themed_section() -> impl View {
    vstack((
        text("This section uses the dark palette"),
        text("So does everything below it"),
    ))
    .install(ThemePlugin::dark())
}

Building one

A configuration plugin

Make the installed type an extractor with impl_extractor! so views receive it as a typed parameter instead of reaching into the environment by hand. The macro requires the type to be Clone:

use waterui::env::use_env;
use waterui::prelude::*;
use waterui::{Environment, Plugin, impl_extractor};

#[derive(Debug, Clone)]
pub struct ThemeConfig {
    pub primary: Color,
    pub secondary: Color,
    pub background: Color,
}

impl_extractor!(ThemeConfig);

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

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

fn themed_card() -> impl View {
    use_env(|config: ThemeConfig| {
        vstack((
            text("Themed card").foreground(config.primary),
            text("Secondary text").foreground(config.secondary),
        ))
        .background(config.background)
    })
}

Note the shape: ThemePlugin is the installer and disappears after installation; ThemeConfig is what views actually read. Keeping them separate means a view depends on the data, not on which plugin happened to provide it.

For real color work, prefer the built-in theme tokens (theme_color::Accent and friends) over a bespoke palette type — they already resolve reactively and follow the system appearance. A custom config type is for values the theme system does not model.

A service plugin

Same shape, with behavior attached. The service is Clone, so it can be moved into action closures:

use waterui::env::use_env;
use waterui::prelude::*;
use waterui::{Environment, Plugin, impl_extractor};

#[derive(Clone)]
pub struct AnalyticsService {
    api_key: String,
}

impl_extractor!(AnalyticsService);

impl AnalyticsService {
    pub fn track(&self, event: &str) {
        tracing::info!(api_key = %self.api_key, event, "analytics");
    }
}

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(|analytics: AnalyticsService| {
        button("Purchase").action(move || analytics.track("purchase_clicked"))
    })
}

Because AnalyticsService is an extractor, a handler can also take it directly as a parameter, exactly like State<T>.

Framework defaults

DefaultErrorView and DefaultLoadingView are ordinary environment values, so a plugin is the natural place to set them:

use waterui::prelude::*;
use waterui::widget::error::{BoxedStdError, DefaultErrorView};
use waterui::widget::suspense::DefaultLoadingView;
use waterui::{Environment, Plugin};

pub struct AppChromePlugin;

impl Plugin for AppChromePlugin {
    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()
        }));

        env.insert(DefaultLoadingView::new(|| {
            vstack((loading(), text("Loading...")))
        }));
    }
}

loading() is the facade’s indeterminate circular Progress. See Error handling and Suspense for what consumes these.

Lifecycle

Installation runs once; the installed values then stay visible to every view that reads that environment. uninstall removes the plugin entry and is mainly useful for undoing a per-subtree install.

Environment is a type-indexed map, so installing the same plugin type twice replaces the first instance. That is the intended way to override a default, not an accident to guard against.

Keyed storage

When a plugin installs several values of the same type under different logical meanings, a type key disambiguates them. Environment::store takes self and returns the extended environment; query reads it back:

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>();   // Option<&String>
let cdn_url = env.query::<CdnBaseUrl, String>();

ApiBaseUrl and CdnBaseUrl are never constructed; they exist only as keys. This is the same mechanism the theme system uses to keep many font slots distinct in one environment.

Composing

Grouping installations into named setups keeps app() readable and makes swapping configurations a one-line change:

use waterui::Environment;

fn setup_production(env: &mut Environment, analytics: AnalyticsPlugin) {
    env.install(ThemePlugin::light())
        .install(analytics)
        .install(AppChromePlugin);
}

fn setup_development(env: &mut Environment) {
    env.install(ThemePlugin::dark()).install(AppChromePlugin);
}

Registering a hook

A plugin’s install body is also where a view hook belongs — a function that intercepts a component’s ViewConfiguration and returns a substitute view:

use waterui::component::button::ButtonConfig;
use waterui::prelude::*;
use waterui::view::ViewConfiguration;
use waterui::{Environment, 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, boxes it into a Hook<C>, and stores it under the configuration’s type. Resolvers and hooks covers the mechanism.

Guidelines

  • One plugin, one concern. Many small plugins compose; one large one does not.
  • Separate the installer from the installed. Views should depend on ThemeConfig, not ThemePlugin.
  • Make installed types extractors. impl_extractor! turns use_env(|svc: MyService| ...) and typed handler parameters on, and keeps env.get::<T>() out of view code.
  • Do not perform I/O in install. Configure the environment; let the installed service do the work when something calls it.
  • Document what appears in the environment. A plugin’s public contract is the set of types it inserts.

Next: Resolvers and hooks, which is the machinery that turns a token like theme_color::Accent into a reactive value and lets a hook rewrite a component before it renders.

Resolvers and hooks

In this chapter, you will:

  • Understand how a design token like theme_color::Accent becomes a reactive signal
  • Implement Resolvable for your own token and install its signal
  • Erase and transform resolvables with AnyResolvable<T> and Map
  • Intercept a component before it renders with Hook<C>
  • Know when a signal still needs Dynamic — and when it does not

When you write .foreground(theme_color::Accent), nothing in that expression holds a color. Accent is a token: a zero-sized value that knows how to find its color in the Environment and hand back a signal. When the system switches to dark mode the signal fires and the affected views update — no rebuild, no diff.

A Hydrolysis preview of custom color tokens resolved through the environment. Example source.

Crate note: Resolvable, AnyResolvable, and Map live in waterui-core and are not re-exported through the waterui facade. A crate that implements its own tokens needs waterui-core = "0.2" as a direct dependency. Everything else in this chapter is reachable through waterui.

The Resolvable trait

pub trait Resolvable: Debug + Clone {
    type Resolved;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved>;
}

The return type is the whole design. resolve does not produce a value, it produces a signal, so three things follow:

  1. A native backend can inject a Computed<ResolvedColor> that tracks the system appearance.
  2. Every view that read the token subscribes to that signal and updates on its own.
  3. There is no rebuild step, so a theme change costs one signal emission per affected leaf.
Native backend            Environment                 View
     |                        |                        |
     | 1. Computed signal     |                        |
     |----------------------->|                        |
     | 2. Theme::install      |                        |
     |----------------------->|                        |
     |                        | 3. Accent.resolve(env) |
     |                        |<-----------------------|
     |                        | 4. signal              |
     |                        |----------------------->|
     | 5. dark mode toggled   |                        |
     |----------------------->| 6. signal fires        |
     |                        |----------------------->|

The built-in color tokens

waterui::theme::color defines eleven tokens, each a unit struct implementing Resolvable<Resolved = ResolvedColor>: Background, Surface, SurfaceVariant, Border, Foreground, MutedForeground, Accent, AccentContainer, AccentForeground, Tertiary, and TertiaryContainer. The prelude imports the module as theme_color.

Theme::installTheme is a Plugin — stores a signal per slot through theme::install_color_signal::<Token>, which also mirrors the signal into the matching waterui_graphics slot so GPU-drawn primitives track the same value.

Resolution fast-fails: a token whose slot was never installed panics with

WaterUI color token `waterui::theme::color::Accent` is not installed in the environment

rather than silently resolving transparent. The same applies to current_color_scheme(env). When you need to ask without committing, use the non-panicking companions theme::installed_color_signal::<Token>(env) and theme::installed_color_scheme(env).

Practical consequence: a bare Environment::new() cannot render themed views. Install a backend or a Theme first.

Implementing your own token

A token is a unit struct plus a lookup. Because install_color_signal and installed_color_signal are generic over the slot type, your token uses exactly the mechanism the built-in ones do:

use waterui::color::ResolvedColor;
use waterui::prelude::*;
use waterui::theme;
use waterui_core::{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> {
        theme::installed_color_signal::<Self>(env)
            .expect("BrandColor is not installed in the environment")
    }
}

Install the signal in a plugin, alongside the rest of your app chrome:

use waterui::color::{ResolvedColor, Srgb};
use waterui::prelude::*;
use waterui::{Environment, Plugin, theme};

pub struct BrandPlugin;

impl Plugin for BrandPlugin {
    fn install(self, env: &mut Environment) {
        let signal = Computed::constant(ResolvedColor::from_srgb(Srgb::new_u8(0, 122, 255)));
        theme::install_color_signal::<BrandColor>(env, signal);
    }
}

A constant signal is the simplest case. Feed it a Computed derived from the installed color scheme instead and the brand color follows dark mode with no further work.

Any Resolvable<Resolved = ResolvedColor> converts into Color, and every modifier that takes a color takes impl Into<Color>, so the token drops straight into view code:

text("Water").foreground(BrandColor)

Fonts use a public keyed slot

Font slots are stored as Store<Token, Computed<ResolvedFont>>, so they are readable with the generic keyed lookup:

use waterui::text::font::ResolvedFont;

env.query::<MyFontToken, Computed<ResolvedFont>>()   // Option<&Computed<ResolvedFont>>

That is the same store / query mechanism the Plugins chapter uses for phantom keys — useful whenever your own resolvable needs a slot the theme system does not already define.

AnyResolvable<T>

Many types resolve to the same output. A color can come from an sRGB literal, a theme token, or a derived expression; AnyResolvable<T> erases the difference:

use waterui::color::Srgb;
use waterui::prelude::*;
use waterui_core::resolve::AnyResolvable;

let from_srgb = AnyResolvable::new(Srgb::new_u8(255, 0, 0));
let from_token = AnyResolvable::new(theme_color::Accent);

AnyResolvable<T> implements Resolvable<Resolved = T> itself, so it composes anywhere a resolvable is expected, and its resolve returns a concrete Computed<T> rather than impl Signal — the type you can store, clone, and pass on. Color is built exactly this way: it is a newtype over AnyResolvable<ResolvedColor>.

The Map combinator

Map<R, F> derives a variation of a token without losing reactivity:

use waterui::color::ResolvedColor;
use waterui::prelude::*;
use waterui_core::resolve::Map;

let translucent_accent = Map::new(theme_color::Accent, |color: ResolvedColor| {
    color.with_opacity(0.5)
});

The closure runs on each emission, so when Accent changes the derived value changes with it. Map implements Resolvable — its resolve is self.resolvable.resolve(env).map(func) — which is how Color::lighten, Color::darken, and Color::saturate are built. Reach for Map when you want a derived token; reach for those methods when you just want a lighter color.

Note that the closure receives ResolvedColor, the concrete linear-sRGB struct, not a Color. Its API is to_oklch, to_srgb, with_opacity, with_headroom, and friends.

Hooks: intercepting a component

Resolvers handle values. Hooks handle views.

A view that implements ConfigurableView splits 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;
}

When such a view’s body runs, it looks for a Hook<Config> in the environment. If one is present, the hook receives the configuration and an environment with that hook removed, and returns a view; otherwise the configuration renders normally through config.render(). Removing the hook is what lets your closure end with config.render() without recursing forever.

Installing one

use waterui::component::button::{ButtonConfig, ButtonStyle};
use waterui::prelude::*;
use waterui::view::ViewConfiguration;
use waterui::{Environment, 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()
        });
    }
}

Install it with env.install(BorderedButtonsPlugin) for the app or .install(BorderedButtonsPlugin) on a subtree, and every button underneath changes style without a single call site changing.

ButtonConfig exposes label: Label, action, style, and disabled: Computed<bool> — the last already resolved against any enclosing .disabled(...) scope, so a hook sees the effective state rather than the literal one. The Label stays typed, so a hook can restyle or wrap the label but cannot strip the semantic text that assistive technology reads.

Hooks are the right tool for consistent styling, experiment flags, and instrumentation. They are the wrong tool for anything a modifier or a theme token already expresses.

When a signal still needs Dynamic

Most of the time a resolved signal never touches Dynamic. A Computed<T> feeds directly into a signal-taking input, and text! maps over any signal in scope:

use waterui::env::use_env;
use waterui::prelude::*;
use waterui_core::{Environment, Signal, resolve::Resolvable};

#[derive(Debug, Clone, Copy)]
struct AppTitle;

impl Resolvable for AppTitle {
    type Resolved = Str;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
        env.query::<Self, Computed<Str>>()
            .cloned()
            .expect("AppTitle is not installed in the environment")
    }
}

fn title_bar() -> impl View {
    use_env(|env: Environment| {
        let title = AppTitle.resolve(&env).computed();   // Computed<Str>
        text!("{title}").headline()
    })
}

Install it with env = env.store::<AppTitle, _>(Computed::constant(Str::from("WaterUI")));.

That is a precise update: the text node re-renders, nothing else does.

Dynamic is for the remaining case, where the shape of the subtree depends on the value. Dynamic::new() gives you a handler and a view to drive manually; watch (in the prelude) wraps a signal:

use waterui::prelude::*;

let (handler, view) = Dynamic::new();
handler.set(text("Initial content"));
handler.set(text("Updated content"));   // replaces the subtree

Every replacement discards the state the previous subtree owned. A Computed<V> is not itself a view even when V: View — if you really do have a signal of views, hand it to watch(signal, |view| view) deliberately. A changing set of rows is a different problem and belongs in ForEach / List, which diffs by id instead of replacing everything.


That is the bottom of the stack: tokens resolve to signals, hooks rewrite configurations, and everything above is built from those two ideas. Good next steps are implementing a token set for your own design system, or reading Plugins again now that you know what install can register.

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 worktree state that preview requires
  • Choose between the native support-app path and the Hydrolysis direct-render path
  • Assert on and profile a preview with water preview test and water preview perf

Spinning up a 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.

Two rendering paths exist, and they have different requirements:

PathCommandWhat it renders
Native support appwater preview <fn> --platform macos|ios|androidYour view through the real Apple or Android backend, via a long-lived support app that loads your code as a dylib
Hydrolysis directwater preview <fn> --backend hydrolysis --theme material3Your view through WaterUI’s self-drawn GPU renderer, as a managed offscreen binary — macOS host only

The support-app path covers macOS, the iOS Simulator, and Android — the targets that can load a Rust dylib through WaterUI’s dynamic-linking path. There is no Linux, Windows, or Web preview.

The #[preview] attribute

Mark any free 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.

#[preview] only applies to free functions. Putting it on a method with self is a compile error.

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 Appleseed")]
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 = ...)]

Naming a parameter that does not exist, or naming one twice, is also a compile error rather than a silently ignored argument.

Tip: Pick defaults that resemble real data. A user_card previewed with name = "" teaches you nothing about typography or wrapping.

Symbol naming

The macro emits exactly one C symbol per preview function:

waterui_preview_{crate_name}_{function_name}

crate_name is CARGO_PKG_NAME with dashes converted to underscores. function_name is the bare function name — a proc macro does not receive the surrounding module path, so the module the function lives in contributes nothing.

Crate nameFunctionExport symbol
my_appsidebarwaterui_preview_my_app_sidebar
together-appdashboard::admin::cardwaterui_preview_together_app_card

Preview function names must therefore be unique within a crate. Two #[preview] fn card() in different modules produce the same export symbol. water preview test --all and water preview perf --all detect this during discovery and refuse to run, naming both files.

You may still pass a module path on the command line — only the last segment is used. Misspelling the name produces a Preview component not found error that prints both the requested path and the expected export symbol.

Project requirements

The support-app path is a development-mode feature. Two things must be true before it will work. (The Hydrolysis path builds a managed backend binary instead and needs neither.)

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 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/, src/, utils/, backends/, kit/, icon/, Cargo.toml, Cargo.lock, .gitmodules, rust-toolchain*). 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
target#[preview] function name, or an expression with --exprrequired
--exprTreat the target as a Rust expression returning impl Viewoff
--platform, -pios, macos, or androidhost native (macOS)
--backendapple, android, or hydrolysisper-platform
--themematerial3 — Hydrolysis only, and required therenone
--frame, -fRender size as WIDTHxHEIGHT375x667
--output, -oOutput PNG pathpreview.png
--scenarioHydrolysis scenario TOML for interaction capturenone
--output-dirDirectory for scenario frames (required with --scenario)none
--pathProject directory.

--platform ios means the iOS Simulator. The default backend follows the platform: apple for ios/macos, android for android. The valid combinations are ios/apple, macos/apple, macos/hydrolysis, and android/android; anything else is rejected by name.

--expr, --scenario, and --output-dir work only with --backend hydrolysis.

# Preview a top-level function on macOS through the Apple backend
water preview my_view --platform macos

# Preview on the iOS Simulator with a custom frame size
water preview profile_card --platform ios --frame 390x844

# Preview on an Android emulator, save to a specific file
water preview home_screen --platform android --output screenshots/home.png

# Render an inline expression through the self-drawn renderer
water preview 'vstack((text("A"), text("B")))' --expr \
    --backend hydrolysis --theme material3

Interaction scenarios

The Hydrolysis path can drive input and capture a timeline instead of a single frame. A scenario is a TOML file listing capture timestamps and events:

captures_ms = [0, 120, 400]

[[events]]
at_ms = 50
kind = "pointer_down"
x = 100.0
y = 240.0

[[events]]
at_ms = 90
kind = "pointer_up"
x = 100.0
y = 240.0

Event kinds are pointer_move (alias hover), pointer_down, pointer_up, pointer_cancel, and scroll (alias wheel). Pointer events need x/y; scroll needs a non-zero dx or dy; button accepts primary, secondary, or middle. One PNG is written into --output-dir per capture timestamp, so you can see a ripple mid-flight rather than only its resting state.

Asserting on and profiling a preview

Two subcommands reuse the same target resolution and run on the Hydrolysis path (macOS host only).

water preview test builds the view, produces its accessibility tree without a render target, and runs a Rust automation body against that tree:

water preview test sidebar --theme material3 \
    --code 'app.query().role(Role::BUTTON).label("Save").assert_exists();'

The body receives app: &mut waterui_testing::SemanticApp, with waterui_testing::* and the WaterUI prelude already in scope. Because the tree under test is the accessibility tree, a preview that fails these assertions is usually an accessibility bug, not a test-harness problem. Use --code-file for anything longer than a line, and --all to run every #[preview] function in the crate.

water preview perf profiles the same view through the offscreen GPU pipeline:

water preview perf sidebar --theme material3 --samples 240 --format html -o perf.html

It reports per-phase timings, frame percentiles, rebuild ratio, scene- and clip-layer counts, and cache hit rates. --warmups (10), --samples (120), and --repetitions (7) control the measurement shape. The --max-p95-us, --max-rebuild-ratio, --max-scene-layers, --max-gpu-surface-layers, and --max-clip-layers thresholds turn a run into a pass/fail gate, which is what makes this usable in CI. --flamegraph writes a CPU call-stack SVG, and --trace writes a Chrome/Perfetto trace.

How the support-app path works

    water preview sidebar --platform macos
                  |
                  v
    1. Resolve preview requirements      (waterui_path, runtime fingerprint)
    2. Connect to an existing support app, or scaffold and launch one
    3. Verify handshake: fingerprint and platform must both match
    4. Fingerprint the project's build inputs (SHA-256 over file contents)
    5. Rebuild managed_backends/preview_ffi as a dylib if that fingerprint moved
    6. Compute DylibId from the build signature + dylib path/size/mtime
    7. Send Render { dylib, symbol, frame } (or just the id, if the app has it)
    8. Support app loads the dylib via libloading, ad-hoc codesigns if needed
    9. Resolve the export symbol, render the AnyView, ship PNG bytes back

The 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, which owns the TCP server and rendering loop. You never edit it.

The app shuts itself down after 15 minutes idle (WATERUI_PREVIEW_IDLE_SHUTDOWN_SECS).

Handshake and transport

The support app binds a TCP server starting at port 2106. On macOS it also writes a JSON entry into a local instance registry under the WaterUI cache directory, so the CLI can find a running app by fingerprint instead of scanning ports.

The CLI 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, or the CLI launches a fresh support app for the right runtime.

After the handshake, requests use a length-prefixed binary frame format (4-byte big-endian length + bincode payload). The request set is Ping, HasDylib, Render, and Shutdown.

Build freshness

water preview fingerprints your project’s build inputs by hashing the contents of every build-input file — everything under src/ and assets/, plus top-level Cargo.toml, Cargo.lock, Water.toml, and build.rs, plus files with build-input extensions (.rs, .swift, .kt, .java, .metal, .wgsl, .toml, .json, .yaml, .plist, and the C-family headers and sources). target/, .git/, .jj/, .water/, node_modules/, .gradle/, .idea/, and .vscode/ are skipped.

That fingerprint goes into a build signature alongside the runtime fingerprint, target triple, crate name, and link mode. The signature is written next to the dylib as <dylib>.waterui-preview-dylib-signature. If the stored signature matches the one the CLI just computed, the build is skipped entirely — content, not timestamps, decides. Touching a file, or rewriting it with identical bytes, does not trigger a rebuild. Adding or deleting a file does, because relative paths are hashed alongside contents.

Dylib identity

Each build gets a DylibId: a SHA-256 over the build signature, the dylib path, its length, and its mtime. This is the cache key the support app uses to recognise an already-loaded library. The CLI first asks HasDylib { id }; on a hit only the render request crosses the wire, and on a miss the CLI sends the bytes — or, on macOS, a local file path, since the CLI and support app share a filesystem.

The support app keeps an in-memory LRU of loaded libraries (default capacity 8).

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.

macOS codesigning

System Integrity Protection requires loaded dylibs to be signed. The support app handles this transparently: it tries dlopen, and on failure checks whether the dylib is already signed. If it is, the original load error is reported as-is; if it is not, an ad-hoc signature is applied and the load retried. The ad-hoc signature satisfies the OS without any Apple Developer account, so you never sign preview dylibs by hand.

Environment variables

The TCP server, on-disk caches, and timeouts are configurable when defaults do not fit your environment. Values that are present but unparseable fail fast rather than falling back to the default.

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
WATERUI_PREVIEW_IDLE_SHUTDOWN_SECSSupport app idle shutdown900
WATER_CACHE_DIRRoot for the preview instance registryOS cache dir

Build-cache hygiene

Playground projects keep their generated backends under ~/.water/build_cache/<absolute-project-path>/managed_backends/ rather than scattering .water directories through your source tree. Entries whose source projects are gone, or which have not been used in 30 days, are removed by:

water gc build-cache

Nothing runs this for you. Run it when you want the disk back after archiving old projects.

Error recovery

  • 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 through the device event stream rather than waiting out the timeout.

Next: the iteration loop

A single water preview run builds, loads, and renders one moment. The next chapter covers what survives between runs — the support app, its loaded runtime, and the dylib cache — and what does not.

The preview iteration loop

In this chapter, you will:

  • Learn why WaterUI’s preview system replaces hot reload
  • See exactly what the preview pipeline reuses across runs and what it rebuilds
  • Read how content fingerprinting decides whether the dylib is still fresh
  • Know which edits force a fresh support app

WaterUI does not support hot reload; the preview system replaces it. There is no file watcher, no daemon polling your source tree, and no patching of a running app’s view tree. water preview is a one-shot command: you save a file, you run it again.

What makes that loop fast is that almost everything around your code survives between invocations — the support app process, its loaded WaterUI runtime, its TCP connection, and its cache of previously loaded dylibs. Only your crate is rebuilt, and only when its contents actually changed.

This chapter is about the support-app path (macOS, iOS Simulator, Android). The Hydrolysis path (--backend hydrolysis) has no long-lived process at all: it rebuilds and re-runs a managed offscreen binary each time.

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 and re-validates the handshake
2. CLI hashes every build-input file; if the hash matches the last build, the dylib is reused
3. Otherwise managed_backends/preview_ffi is rebuilt (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 entirely.

Freshness is content, not timestamps

Before every build the CLI walks your project and hashes the contents of each build-input file, together with its relative path and length, into one SHA-256 fingerprint. Inputs are everything under src/ and assets/, the top-level Cargo.toml, Cargo.lock, Water.toml, and build.rs, and any file with a build-input extension (.rs, .swift, .kt, .java, .metal, .wgsl, .toml, .json, .yaml, .plist, and the C-family sources and headers). target/, .git/, .jj/, .water/, node_modules/, .gradle/, .idea/, and .vscode/ are skipped. Symlinked source directories are followed, so a project whose src/ points elsewhere is still tracked correctly.

That fingerprint is folded into a build signature:

build_signature =
    inputs=<sha256 of project build inputs>
    runtime=<waterui runtime fingerprint>
    target=<target triple>
    crate=<preview crate name>
    link_mode=<dylib or cdylib, prefer-dynamic>

The signature is stored next to the dylib as <dylib>.waterui-preview-dylib-signature. If the stored signature equals the one just computed, the build is skipped.

Hashing content rather than mtimes means an editor that rewrites a file on save, a touch, or a checkout that restores identical bytes all cost nothing. Adding or removing a file is still caught, because each file’s relative path is hashed alongside its contents.

Dylib identity

The build signature also feeds the DylibId that the support app uses as its cache key:

DylibId = SHA-256(
    build_signature
    || dylib_path
    || file_length
    || mtime_seconds || mtime_subsec_nanos
)

The CLI asks HasDylib { id } first. On a hit only the render request crosses the wire. On a miss the CLI sends the dylib — as raw bytes, or, when the CLI and support app share a filesystem (macOS local preview), as a local file path so a multi-megabyte payload never goes through the socket.

Because the build signature embeds the WaterUI runtime fingerprint — the clean git rev-parse HEAD of the local waterui_path worktree — changing your WaterUI checkout produces a different id, and the old dylib can never be mistaken for the new one.

The support app keeps an in-memory LRU of loaded libraries (default capacity 8, configurable via WATERUI_PREVIEW_DYLIB_CACHE_SIZE).

Persistent sessions

The support app outlives any single CLI invocation. After a successful render the CLI calls session.detach(), which clears the drop hooks that would otherwise terminate the app when the CLI exits, so the next command finds it still listening.

On failure the CLI calls session.shutdown() instead: it sends a Shutdown request and drops the handle, so the next invocation starts from a clean process rather than inheriting a broken one.

Left alone, the support app exits by itself after 15 minutes of inactivity (WATERUI_PREVIEW_IDLE_SHUTDOWN_SECS).

The timings this produces:

  • First invocation: scaffold, launch, and full build — several seconds.
  • Subsequent invocations on unchanged code: reconnect plus a cached render.
  • Subsequent invocations after an edit: incremental cargo rebuild plus a fresh render.

Tip: If consecutive previews feel slow, look for Connected to existing preview app versus a fresh launch in the CLI logs (--logs debug). A fresh launch 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 the build automatically when it can find the binary. Without it 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 — doing so makes per-project build caches balloon.

State across reloads

Every water preview call asks the support app to construct a brand-new AnyView and render it once. There is no shared Binding, Computed, or Environment carried over between invocations: the support app builds each preview from scratch, drives one render, and drops everything.

If a preview needs to exercise a specific data shape, set that data up inside the preview function or pass it through #[preview(...)] defaults. Do not expect the support app to remember anything from the previous run.

This is also why struct layout changes inside your own crate are safe: nothing stateful survives to be corrupted.

Per-function granularity

#[preview] works at the function level. Mark as many functions as you like in one crate — each gets its own export symbol, and switching between them shares the same dylib and the same support-app session:

#[preview]
fn sidebar() -> impl View { /* ... */ }

#[preview]
fn header() -> impl View { /* ... */ }

#[preview(count = 3)]
fn notification_list(count: usize) -> impl View { /* ... */ }
water preview sidebar --platform macos
water preview header --platform macos
water preview notification_list --platform macos

Remember that the export symbol uses only the crate name and the bare function name, so these three names must be unique across the whole crate.

When you need a fresh support app

  • The WaterUI runtime changed. Bumping the local waterui_path checkout, switching commits, or leaving that worktree dirty changes the runtime fingerprint. The handshake rejects the running support app and the CLI launches a new one.
  • You switched platform or backend. A support app is built for one runtime platform; the handshake checks that too.
  • The support app crashed. When the CLI sees it exit, it shuts the session down so the next preview gets a clean process.
  • The CLI itself was rebuilt. The support app is re-scaffolded when its embedded templates change.

Architecture summary

+------------------+       TCP (port 2106+)       +------------------------+
|                  | <--------------------------> |                        |
|   water CLI      |   Binary protocol (bincode)  |  Preview support app   |
|                  |                              |                        |
+--------+---------+                              +-----------+------------+
         |                                                    |
   Hash project build inputs                       Load dylib via libloading
         |                                         Ad-hoc codesign on macOS
   Build managed_backends/preview_ffi              Resolve preview symbol
   as a dylib (cargo + sccache)                    Render AnyView via native
         |                                         ViewRenderer, encode PNG
   Compute DylibId                                 LRU dylib cache

The build side and the render side share nothing but the socket. That separation is what lets the support app survive across CLI runs, and it is the whole basis of the “hot” feel.

Next: how WaterUI renders

The Internals section opens the box on the runtime these tools accelerate: how WaterUI walks the view tree, crosses the FFI boundary, and turns nodes into widgets.

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 this chapter to build apps. You need it to debug a view that renders wrong, to write a backend, or to understand why a modifier you invented panics with “not caught by your renderer”.

Your application code produces a tree of Rust structs implementing View. A backend walks that tree at runtime and maps each node to something it can draw — a UIKit view, an Android View, a GTK widget, or a Vello scene.

The rendering pipeline

Rust view tree
     |
     v
FFI layer (C ABI / JNI)        or        Rust-side backend
     |                                          |
     v                                          v
Native backend (Swift / Kotlin)         ViewDispatcher
     |                                          |
     v                                          v
Platform UI framework                   GTK widgets, or Vello scenes
(UIKit / AppKit / Android Views)                |
     |                                          v
     v                                   GPU (wgpu) or CPU raster
Pixels on screen

WaterUI ships several backends, and they differ in how they consume the tree, never in the tree itself:

BackendCrate / directoryConsumes the tree viaDraws with
Applebackends/apple (submodule)C ABIUIKit / AppKit
Androidbackends/android (submodule)JNIAndroid Views
GTKbackends/gtk (waterui-gtk)ViewDispatcherGTK4 widgets
Hydrolysisbackends/hydrolysisViewDispatcherVello on wgpu, GPU-required
Dewbackends/dewViewDispatchervello_cpu, CPU-first, MCU-class targets

backends/core (waterui-backend-core) holds the plumbing every Rust-side backend shares: the dispatcher, widget metrics, input, gestures, and frame signals. backends/hydrolysis_m3 is a Material 3 theme package for Hydrolysis, not a backend of its own.

Hydrolysis and Dew are both self-drawn, and they sit at deliberately opposite design points: Hydrolysis redraws the full scene every frame on the GPU and targets 120fps-class hardware; Dew re-rasterizes only dirty bands on the CPU so peak pixel memory is one band, and targets microcontrollers with no GPU and no full-resolution framebuffer. Neither is a fallback for the other.

View categories

Every view falls into one of three categories. These categories are the render loop.

Raw views (leaf nodes)

A raw view maps directly to something the backend draws. It is declared with the raw_view! macro in waterui-core:

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

// With explicit stretch axis
raw_view!(Color, StretchAxis::Both);
raw_view!(Spacer, StretchAxis::MainAxis);
raw_view!(ScrollView, StretchAxis::Both);

The macro implements two traits:

  1. NativeView — marks the type as a leaf, and declares its stretch axis.
  2. View — implements body() to return Native::new(self), a sentinel wrapper meaning “stop recursing, extract my data.”

Native<T> is where recursion is supposed to stop. Its own body() panics with the type name unless a fallback view was attached with .with_fallback(...). A backend that reaches Native<T>::body() has failed to handle a leaf it was expected to handle, and it finds out immediately.

Composite views

A composite view has a body() returning other views. The backend evaluates body() and keeps walking the result until it bottoms out at a raw view.

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

Any struct, function, or closure implementing View is composite unless it went through raw_view! or configurable!. Divider is a good example: it has no config struct and no FFI type. Its body() reads the parent stack’s axis out of the environment and returns a one-point Frame, so a backend gets a correct divider for free — though a backend is still free to recognize Divider and draw a native rule instead.

Configurable views

configurable! bridges the two. It splits a view into a public type and a config struct, and lets a Hook<Config> installed in the Environment intercept the config before it reaches the backend:

configurable!(Button, ButtonConfig);
configurable!(Slider, SliderConfig, StretchAxis::Horizontal);

When the view’s body() runs, it looks for Hook<ButtonConfig> in the environment. If one is present, the hook may alter or completely replace the view. If not, the config falls through to Native::new(config) and behaves like a raw view.

A resolve |config, env| ... clause can additionally rewrite the config against the environment before it becomes Native — that is how a config resolves theme tokens or locale-dependent labels without the backend knowing anything about either.

Note: The configurable pattern is what makes theming work without forking. A library defines Button; downstream code replaces its rendering by installing a hook.

View identification

The backend needs a fast way to tell what it is holding. The FFI layer uses 128-bit type IDs:

#[repr(C)]
pub struct WuiTypeId {
    pub low: u64,
    pub high: u64,
}

The ID is a 128-bit FNV-1a hash of the type’s name, not its std::any::TypeId. This is deliberate: TypeId is not stable across dynamic library boundaries, but type_name() is. Since the preview system loads user code as a dylib, the IDs must agree across that boundary.

The backend keeps a table mapping IDs to handlers and compares in constant 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 through type-specific FFI functions generated by ffi_view!:

ffi_view!(TextConfig, WuiText, text);
// Generates:
//   waterui_text_id()       -> WuiTypeId
//   waterui_force_as_text() -> WuiText

waterui_force_as_* performs an unchecked downcast — it trusts that the caller already compared the ID. The returned C struct carries everything the backend needs: content signals, alignment, colors, action handlers.

Note the type parameter: waterui_text_id() returns the ID of Native<TextConfig>, not of Text. The backend never sees Text; it sees the config the view resolved to.

Metadata and modifiers

Modifiers like .padding(), .opacity(), or .on_appear() do not introduce new widget types. They wrap the inner view in a Metadata<T> node:

Metadata<Opacity> {
    content: AnyView,   // the wrapped view
    value: Opacity { value: Computed<f32> }
}

Metadata nodes carry their own type IDs (from ffi_metadata!). The backend extracts the value and the inner content, applies the modifier to the platform widget, and continues into the content.

Metadata is mandatory by default. Metadata<T>::body() panics:

The metadata `...::Metadata<...::Opacity>` is not caught by your renderer.
If the metadata is not essential, use `IgnorableMetadata<T>`.

Optional modifiers use IgnorableMetadata<T> instead, whose body() simply returns the content. That is how a platform-specific feature such as MaterialBackground degrades to plain content elsewhere rather than crashing. The distinction is a design decision per modifier: silently dropping a padding would be a bug, silently dropping a blur-material is not.

The render loop

Per node, the backend does:

  1. Call waterui_view_id(view) for the 128-bit type ID.
  2. Look it up in the handler table.
  3. Handler found (raw view or metadata) — call waterui_force_as_*, create or update the platform widget, and for metadata also render the content child.
  4. No handler — call waterui_view_body(view, env) and go back to step 1 with the result.

Rust-side backends get this loop from ViewDispatcher in 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)
}

Handlers register against the type the tree actually contains, which for a leaf is the Native<Config> wrapper:

dispatcher.register::<Native<TextConfig>>(|state, ctx, native, env| {
    // build a platform label from native.as_inner()
});

For concrete types the view stays on the stack and dispatch allocates nothing; only AnyView takes the boxed path.

Tip: Set WATERUI_DISPATCH_DEBUG=1 to have the dispatcher log an indented trace of every view type it walks. It is the fastest way to find out why your view resolved to something you did not expect.

Reactivity and updates

The initial walk is only half the story. When data changes, WaterUI does not diff view trees. Each signal is wired directly to the one widget property it feeds:

Binding<String> --> Computed<Str> --> Watcher --> UILabel.text

The watcher callback updates that single property. No reconciliation, no virtual DOM, no re-walk.

Collections are the same idea one level up. The Views trait exposes get_id(index), len(), get_view(index), and watch(range, watcher); AnyViews erases it for the FFI. The backend receives id-level change notifications for a range and patches the platform list — inserting, removing, and reusing rows by id rather than rebuilding all of them. That is why ForEach/List over a reactive collection preserves per-row animation, focus, and accessibility state, and why watch(...) over a Vec does not.

Stretch axis negotiation

Every view declares how it wants to fill 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

Stacks use this to distribute space: a VStack gives leftover height to children that report Vertical or MainAxis after content-sized children are measured.

waterui_view_stretch_axis() exposes the value to native backends so they can lay out without evaluating the view’s body.

Measurement is parallel, and caching belongs to the leaf

Layout containers probe children through the SubView trait, which requires Send + Sync so independent children can be measured on worker threads. A leaf whose measurement genuinely must touch main-thread-only state confines that state in waterui_core::MainThreadBound and returns true from SubView::require_main_thread(); the executor then keeps it on the calling thread. waterui_layout::measure_children splits children along exactly that line, under the crate’s parallel feature (off by default, so embedded builds stay serial).

Because containers probe the same child many times with different proposals, expensive measurements — text shaping above all — must cache. That cache belongs to the SubView implementation, never to the Layout, and because measurement can run on worker threads it must be thread-safe, not a RefCell.

Performance characteristics

  • No tree diffing. A changed signal updates its own property; cost is independent of tree size.
  • No virtual DOM. Views are consumed by body(), not cloned.
  • Constant-time dispatch. A 128-bit hash comparison, not string matching.
  • Lazy evaluation. body() runs only when a backend needs that subtree.

The main cost is the initial walk, proportional to the number of visible views. After that, cost tracks the number of changed signals.

Next: the FFI bridge

The next chapter opens the layer this one kept behind a curtain — how a Rust AnyView becomes a Swift object or a Kotlin class, and what contract a backend has to honor at startup.

The FFI bridge

In this chapter, you will:

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

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

Who owns the FFI

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

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

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

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

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

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

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

waterui_ffi::export!();

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

The waterui-ffi crate

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

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

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

The export!() macro

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

waterui_init()

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

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

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

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

waterui_app()

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

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

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

Android entry points

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

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

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

Initialization sequence

Every native backend follows the same protocol:

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

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

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

Theme installation APIs

Color scheme

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

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

waterui_theme_install_color_scheme(env, scheme);   // takes ownership

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

Color slots

WaterUI defines 11 semantic color slots:

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

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

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

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

Font slots

WaterUI defines 6 font slots:

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

Querying theme values

Reads return a new reference that the caller must drop:

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

View traversal

waterui_view_id()

WuiTypeId waterui_view_id(const WuiAnyView *view);

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

waterui_view_body()

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

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

waterui_force_as_*()

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

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

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

WuiMetadataOpacity waterui_force_as_metadata_opacity(WuiAnyView *view);

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

WuiIgnorableMetadataMaterialBackground
    waterui_force_as_ignorable_metadata_material_background(WuiAnyView *view);

waterui_view_stretch_axis()

WuiStretchAxis waterui_view_stretch_axis(const WuiAnyView *view);

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

FFI macros

ffi_safe!

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

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

opaque!

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

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

ffi_view!

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

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

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

ffi_metadata!

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

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

into_ffi!

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

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

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

Panics at the boundary

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

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

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

C header generation

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

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

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

Android JNI

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

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

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

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

Adding a new view to the FFI

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

If it does need one:

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

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

Next: the layout engine

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

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
  • Measure children in parallel and cache measurements correctly
  • Write a custom layout from scratch

Parents propose sizes, children respond with their preferences, and parents make the final placement decisions. That two-phase negotiation is the whole layout system; everything else in this chapter is a consequence of it.

Logical pixels

All layout values in WaterUI use logical pixels (also called “points” or “dp”), the same unit system design tools use:

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

A button at 44pt height with 16pt padding in Figma is .height(44.0).padding_with(16.0) in WaterUI, with no conversion step.

The Layout trait

Layout lives in waterui_core::layout and defines a container’s algorithm:

pub trait Layout: Debug + Any {
    /// 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, one `Rect` per child.
    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect>;

    /// Which axis this container stretches on.
    fn stretch_axis(&self) -> StretchAxis {
        StretchAxis::None
    }
}

Those are the two methods you must write. The trait also carries defaulted hooks for alignment guides (explicit_horizontal, explicit_vertical, and their *_alignments companions) and one #[doc(hidden)] hook, watch_invalidation, covered under reactive layout parameters.

The separation matters: during sizing you may probe children with several different proposals to learn how flexible they are before committing to an arrangement.

ProposalSize

The parent states its intent through ProposalSize:

pub struct ProposalSize {
    pub width: Option<f32>,
    pub height: Option<f32>,
}
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”

Three constants cover the common probes:

ProposalSize::UNSPECIFIED  // None, None
ProposalSize::ZERO         // Some(0.0), Some(0.0)
ProposalSize::INFINITY     // Some(INFINITY), Some(INFINITY)

A child is never obligated to accept a proposal. Text returns its intrinsic size from the content and font no matter what is proposed.

The SubView proxy

Containers never touch child views directly. They work through SubView:

pub trait SubView: Send + Sync {
    /// 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; higher wins.
    fn priority(&self) -> i32;

    /// Whether this child's measurement must run on the main thread.
    fn require_main_thread(&self) -> bool { false }
}

measure returns ViewDimensions, not a bare Size: the size field plus any explicit alignment guides the child published. Reach for .size when guides do not matter.

Three properties of this trait drive everything else:

  • Measurement is pure. Every method takes &self. Calling measure five times with five proposals is legal and expected.
  • SubView is Send + Sync. Layout may measure independent children on worker threads.
  • Priority orders space distribution. Higher-priority children are measured first and claim space before flexible siblings such as spacers.

Caching is the child’s job

The Layout trait deliberately has no cache, because containers probe freely and a container-level cache would have to guess which probes repeat. Caching belongs to the SubView implementation, and expensive measures — text shaping above all — must cache.

Because measurement can run off the main thread, that cache has to be thread-safe: a lock or a lock-free map, never a RefCell.

A leaf whose measurement genuinely must stay on the main thread wraps its non-Send state in waterui_core::MainThreadBound<T> (which satisfies Send + Sync at the type level while asserting single-thread access at runtime) and returns true from require_main_thread. Returning false while touching main-thread-only state is a bug, and the MainThreadBound assertion fails fast when it happens.

Measuring children in parallel

waterui_layout::measure_children applies one measure closure across a child slice and returns results in the original order:

use waterui_layout::measure_children;

let sizes = measure_children(children, |child| child.measure(proposal).size);

With the parallel feature enabled (off by default; it pulls in rayon), children that report require_main_thread() == false are measured on a worker pool while the rest are measured on the calling thread. Without the feature — on no_std and embedded targets, for instance — the same call measures serially. HStack and VStack already route their measurement through it.

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 resolved against the parent: in a VStack, MainAxis is vertical; in an HStack it is horizontal. This is what lets Spacer push siblings apart in either orientation and Divider span the cross axis in either orientation.

How the built-in layouts work

VStack and HStack

Sizing:

  1. Separate children into fixed (non-stretchy) and flexible (stretchy) groups.
  2. Propose the available size to each fixed child and collect their measurements.
  3. Compute the space remaining after fixed children and spacing.
  4. Distribute the remainder among flexible children as equal shares.
  5. Sum child extents along the main axis, plus spacing.

Placement: start at the top (VStack) or leading edge (HStack), advance by child extent plus spacing, and align each child on the cross axis.

VStack reports StretchAxis::Horizontal: it fills available width and takes its height from its children.

When an HStack’s children do not fit, the overflow is resolved by water-filling, not by squeezing children in order. The stack finds the largest common width cap T such that the sum of min(width, T) fits the available space, clamps everything above the cap (with a 20pt floor), and re-measures the clamped children at the cap so wrapped content reports its true height. Equal-width children therefore shrink equally instead of the leading ones absorbing the entire overflow.

Frames

.width(...), .height(...), .size(w, h), and the min_*/max_* family each wrap the view in a Frame. The frame proposes the constrained size to its child and reports the constrained dimensions upward.

Grids

GridLayout arranges children into rows and columns; each column can be fixed-width, flexible, or adaptive.

ScrollView

ScrollView proposes an infinite extent along its scroll axis so content may exceed the viewport. The backend owns the scrolling behavior itself.

Padding

text("Padded").padding_with(EdgeInsets::all(16.0))

Sizing adds the insets to the child’s size; placement offsets the child’s origin by the leading and top insets.

Reactive layout parameters

Layout inputs are signals, not snapshots. HStackLayout::spacing, VStackLayout::spacing, GridLayout::spacing, and every FrameLayout dimension hold a Computed rather than a plain f32, so passing a binding produces a real re-layout when it changes:

use waterui::prelude::*;
use waterui::layout::stack::hstack;

let gap = Binding::f64(8.0);

hstack((text("left"), text("right"))).spacing(gap.clone())

.spacing(...) accepts anything implementing IntoSignalF32, which covers integer and float literals as well as signals.

The mechanism behind this is Layout::watch_invalidation. A layout returns watcher guards for its own reactive fields; the native container holds those guards for the layout object’s lifetime and requests a new layout pass when one fires. HStackLayout, for example, returns a single guard on its spacing signal. The hook is #[doc(hidden)] backend infrastructure with a default empty implementation — implement it only if your custom layout stores signals.

One consequence to know about: HStack::new, hstack(), VStack::new, vstack(), and GridLayout::new are no longer const fn, because building a Computed is not a const operation. ZStack and zstack() remain const.

Writing a custom layout

Implement Layout. Here is a flow layout that wraps children to the next line when they exceed the available width:

use waterui_core::layout::{Layout, Point, ProposalSize, Rect, Size, SubView};

#[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 {
                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
    }
}

Both phases measure with the same proposal, so the sizes computed in phase 1 match what phase 2 places. If the two disagree, children render at one size and are positioned as if they had another.

Try it yourself: arrange children in a circle. Use size_that_fits for the bounding box and place to position each child at an angle around the center.

Safe area

Safe area handling is deliberately outside the Layout trait, because it is platform state rather than geometry: notch and home indicator on iOS, navigation bar and cutouts on Android, toolbar and title bar on macOS.

Backends apply safe-area insets themselves. A view opts out with .ignore_safe_area(EdgeSet), which attaches IgnoreSafeArea metadata telling the backend to extend past the boundary on the listed edges:

use waterui::layout::safe_area::EdgeSet;

hero_image().ignore_safe_area(EdgeSet::TOP)

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 answers the usual geometric questions directly:

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); // top, bottom, leading, trailing

Layout and the FFI

Layout is Rust-only. Backends that delegate to a platform layout system — Apple through UIKit/AppKit, Android through its view hierarchy — do not call it. They read each view’s stretch axis over the FFI through waterui_view_stretch_axis() and let the host system position widgets.

The Rust trait is the source of truth for backends that lay out themselves: Hydrolysis, Dew, and any custom backend built on waterui-backend-core.

What’s next

Layout decides where views go; backends decide what they are made of. The next chapter surveys the backend architecture.

Backend architecture

In this chapter, you will:

  • See how each backend maps a Rust view tree onto its platform
  • Understand the shared contract every backend implements
  • Learn why WaterUI ships two self-drawn renderers with opposite designs
  • Follow the steps for adding a new backend

A backend turns the same Rust view tree into whatever the target platform understands. Apple and Android bridge to native widgets; GTK4 bridges to GTK widgets; Hydrolysis and Dew draw the pixels themselves.

Note: You do not need any of this to use WaterUI. This chapter is for contributors, backend authors, and the curious.

Native bridge first

WaterUI’s rule is that a semantic component gets a native bridge on every platform with a suitable platform primitive, and a shared self-drawn realization where none exists. “Native” means coupled to the platform’s own object model, lifecycle, accessibility, input, and graphics pipeline — not merely “a library that happens to ship with the OS.” Bundling a portable engine and calling it native does not qualify.

The self-drawn realization is a deliberate backend, never a runtime fallback. A failed native bridge is an error to fix, not a cue to silently swap renderers.

The backend contract

Every backend must:

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

waterui_init and waterui_app come from waterui_ffi::export!(). That macro lives in the FFI companion crate the water CLI generates — application crates neither declare waterui-ffi nor call export!() themselves.

The waterui-backend-core crate holds what Rust-side backends share: ViewDispatcher for type-based dispatch, plus animation, gesture, input, scroll, frame-signal, and time modules.

Apple backend (Swift)

Location: backends/apple/ (git submodule)

A Swift Package targeting UIKit (iOS/tvOS), AppKit (macOS), and WatchKit (watchOS). It is the most mature backend and the reference implementation.

Rust library (.dylib / .a)
     |
     C ABI (waterui.h)
     |
Swift package (WaterUI)
     |
     UIKit / AppKit widgets

The Swift side walks the Rust view tree and creates the corresponding platform views:

Rust viewiOSmacOS
TextUILabelNSTextField (label mode)
ButtonUIButtonNSButton
ToggleUISwitchNSSwitch
TextFieldUITextFieldNSTextField
ScrollViewUIScrollViewNSScrollView
NavigationStackUINavigationControllercustom NSView stack with NSWindow toolbar accessories
GpuSurfaceCAMetalLayerCAMetalLayer

AppKit has no navigation controller, so the macOS stack is built from NSView containers and drives the window’s title, leading, trailing, and search accessories directly. This is the “asymmetries are documented, not faked” principle in practice.

Reactive integration

The backend subscribes to WaterUI signals through FFI watchers. A change in Rust invokes a C callback that Swift registered:

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

Only the bound property is touched, and the update lands on the main thread.

Theme injection

The backend maps system appearance and typography into WaterUI’s theme slots:

// 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 slot (Foreground, Background, Surface, Accent, and the rest) resolves to a platform dynamic color, so ordinary view code adapts to light/dark mode with no extra work. Backends read these slots rather than hard-coding .label or .systemBackground: getting defaults right is the backend’s job, not the view author’s.

Build integration

Drive the backend through the water CLI, never xcodebuild or swift build directly. water run --platform ios cross-compiles the Rust staticlib for the target triple, hands the path to the Swift package, signs, and deploys to the chosen simulator or device. If a build step the CLI cannot express turns up, file an issue against cli/ rather than scripting around it.

Android backend (Kotlin/JNI)

Location: backends/android/ (git submodule)

Rust library (.so)
     |
     JNI
     |
Kotlin runtime (dev.waterui.android)
     |
     Android View hierarchy

The Kotlin runtime is organized into components, ffi, layout, reactive, and runtime packages.

JNI bridge

On Android, the FFI macros emit JNI entry points beside the C functions. For a view registered as 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 identifier is lower-camelized for the *Id function and upper-camelized after the forceAs prefix. The JNI module (ffi/src/jni/) caches class references at JNI_OnLoad, converts #[repr(C)] structs into Java objects field by field, and passes Rust pointers as jlong.

export!() generates JNI_OnLoad in the companion crate:

extern "system" fn JNI_OnLoad(vm: *mut c_void, _reserved: *mut c_void) -> i32 {
    unsafe { waterui_ffi::__jni_init(vm) }
}

Targeting Android without the android-jni feature is a compile error rather than a silent no-op.

Gradle project

The Gradle project holds the runtime Kotlin library and its JNI bindings. You do not invoke Gradle or adb; water run --platform android cross-compiles for the Android targets, copies the .so into jniLibs/, runs the embedded Gradle wrapper, and installs and launches the app.

GTK4 backend

Location: backends/gtk/

The native Linux bridge, built on gtk4-rs. Beyond the widget mapping it hosts the embedded browser components: the system WebKitGTK view, WaterUI’s bundled WPE runtime, and the CEF-based Chromium runtime, selected through the webview_backend key in Water.toml.

Select it with water run --platform linux --backend gtk4, or scaffold with water create <name> --backends gtk4.

Two self-drawn renderers

Hydrolysis and Dew share waterui-core, reactivity, layout, and text, and diverge only in render strategy — deliberately, at opposite ends of the hardware range. Neither is converging on the other.

HydrolysisDew
TargetHigh-end desktop, mobile, webMCU-class embedded (ESP32-S3, ESP32-C3)
RasterizationGPU-required (vello on wgpu)CPU-first (vello_cpu sparse strips), GPU optional
Frame strategyFull-scene redraw, game-engine styleDirty regions only, sliced into bands
Peak pixel memoryFull frameOne band
Frame rateHigh refresh, explicitly requested30/60fps, power-frugal
Dependency graphModern GPU + multi-core CPULean, feature-gated for firmware

Hydrolysis

Location: backends/hydrolysis/, with backends/hydrolysis_m3/ supplying the Material 3 skin.

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

Rules that hold when you author GPU-backed components on top of it:

  • GPU only, no CPU fallback. There is no software rasterization path. Production surfaces reject software and noop wgpu adapters; the WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTER environment variable exists for one-off diagnostics, not for shipping.
  • Never read render targets back to CPU memory on the runtime render path. Offscreen capture for tests has its own entry points.
  • One GpuView per GpuSurface. GpuSurface::new(renderer) owns that GpuView for the surface’s lifetime, and persistent GPU resources belong in GpuView::setup() — not in a cache that outlives the surface.
  • No damage tracking. The scene is redrawn rather than invalidated by region. Adding dirty-rectangle logic here would contradict the design.

Starting a Hydrolysis app installs the Material 3 defaults onto the app’s environment after the app is constructed:

let env = Environment::new();
let mut app = my_crate::app(env);
hydrolysis_m3::install_defaults(&mut app.env);
hydrolysis::run(app);

The CLI generates exactly this for managed Hydrolysis backends.

Accessibility as a build output

With the accessibility feature enabled, every Hydrolysis component emits an accesskit tree as a first-class artifact rather than a retrofit. waterui-testing consumes that tree directly, so a component that cannot be covered by an accessibility query is failing its design contract, not just a CI lint.

Dew

Location: backends/dew/

WaterUI view tree
   |  dispatch + waterui-layout measure/place
   v
DisplayList        retained draw commands (kurbo paths, peniko brushes)
   |  diff against previous frame -> dirty rects
   v
BandScheduler      dirty rects -> row slices no taller than band_height
   |
Painter            vello_cpu rasterizes each band into a scratch pixmap
   |
DisplayFlush       the only platform-specific piece: in-memory buffer,
                   simulator window, or RGB565 panel stream

The screen never has to exist as a full-resolution framebuffer, which is what makes the backend viable on a microcontroller driving an SPI/QSPI panel. Dew is std-based through its embedded RTOS rather than bare-metal no_std, and firmware builds strip GPU, widget, and gesture features from the dependency graph.

The whole flow runs on the desktop without cross-compiling:

cargo run -p waterui-dew --example watch_sim --features embedded-simulator

waterui_dew::render_view_png(builder, env, width, height) renders one frame headlessly for snapshot tests. Views Dew does not yet support panic rather than render something wrong.

View dispatch

Rust-side backends route views through ViewDispatcher from waterui-backend-core:

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

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

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

dispatcher.register::<Native<ButtonConfig>>(|state, ctx, view, env| {
    // Build the backend's button widget.
});

let widget = dispatcher.dispatch(my_view, &env, context);

dispatch is the render loop:

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

That is the same algorithm the Apple and Android backends implement in Swift and Kotlin, minus the language boundary.

Debug tracing

WATERUI_DISPATCH_DEBUG=1 logs the dispatch tree as views are matched:

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

Any view that falls through to body() and never reaches a registered handler is a backend gap worth filing.

Adding a new backend

  1. Create the crate in backends/your-backend/.
  2. Depend on waterui-backend-core for ViewDispatcher and the shared interaction types.
  3. Register handlers for each native view type you support:
    dispatcher.register::<Native<TextConfig>>(|state, ctx, view, env| {
        // Create your platform's text widget
    });
  4. Handle metadata the same way:
    dispatcher.register::<Metadata<Opacity>>(|state, ctx, meta, env| {
        // Apply opacity, then render meta.content
    });
  5. Implement the lifecycle: window creation, event loop, signal-driven updates.
  6. Install theme signals, mapping your platform’s appearance system onto WaterUI’s color and font slots.

Two constraints apply throughout. A bridge may only make reachable the platform code the selected WaterUI features actually need — hiding a whole framework behind FFI or broad keep rules defeats dead-stripping and inflates every packaged app. And unused WaterUI features must drop their Rust code, platform code, resources, and transitive dependencies from the artifact, which means new backend dependencies are feature-gated and measured.

For FFI backends, you write the view walker in the target language against the generated C header or JNI functions instead of registering Rust handlers.

Backend status

BackendPathNotes
Applebackends/apple/Submodule. UIKit/AppKit bridge; reference implementation.
Androidbackends/android/Submodule. Android View bridge over JNI.
GTK4backends/gtk/Linux bridge; also hosts the WebKitGTK/WPE/CEF web views.
Hydrolysisbackends/hydrolysis/GPU self-drawn renderer; drives waterui-testing.
Hydrolysis-M3backends/hydrolysis_m3/Material 3 skin for Hydrolysis.
Dewbackends/dew/CPU self-drawn renderer for MCU-class targets.
Backend corebackends/core/Shared dispatch, gesture, scroll, animation, frame timing.

Component coverage moves too fast to freeze in a matrix. Run your view against a target and read the dispatch trace instead.

What’s next

The next chapter turns from extending the framework downward to extending it outward: authoring reusable WaterUI component crates.

Library authoring

In this chapter, you will:

  • Use configurable! and raw_view! to define hookable and leaf views
  • Apply the Type::new / free-function constructor split WaterUI uses everywhere
  • Accept IntoText, IntoLabel, IntoSignal<T>, and IntoComputed<T> in your APIs
  • Pass context through the Environment and the Plugin trait
  • Test a component through its accessibility tree

A WaterUI component crate is an ordinary Rust library that follows a handful of conventions. Following them is what makes your components compose with the rest of the ecosystem instead of sitting beside it.

Where a component crate lives

In this repository, component crates sit under a domain folder in components/: foundation (layout, text, controls, form, navigation, shape, icon), visual, multimedia, data, codes, assets, effects, devtools, and platform. Your own crate follows the same shape as any of them:

[package]
name = "myco-waterui-widgets"
edition = "2024"
rust-version = "1.95"

[dependencies]
waterui-core.workspace = true
waterui-layout.workspace = true
waterui-text.workspace = true
nami.workspace = true

[features]
default = []

[lints]
workspace = true

Two habits worth copying. Depend on the specific component crates you use, not the waterui facade — that is what keeps unused features out of a consuming app’s artifact. And keep features granular, so a consumer disabling gpu or building for an embedded target drops your GPU code with it.

The configurable! macro

configurable! defines a view that carries a config struct and can be intercepted 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,
});

It generates the wrapper struct, a NativeView impl on the config declaring the stretch axis, ConfigurableView on the wrapper, ViewConfiguration on the config, and a View impl that checks the environment for a hook before falling through to native rendering.

That hook is how a consumer replaces your view globally without forking your crate:

let mut env = Environment::new();
env.insert_hook(|env: &Environment, config: ButtonConfig| {
    custom_button(config.label, config.action)
});

Stretch axis and environment resolution

The third argument declares the stretch axis, either statically or from the config:

configurable!(MyView, MyConfig);                          // StretchAxis::None
configurable!(MyView, MyConfig, StretchAxis::Horizontal); // always horizontal
configurable!(MyView, MyConfig, |config| {
    if config.is_expanded { StretchAxis::Both } else { StretchAxis::None }
});

An optional resolve clause runs before the config reaches Native, which is where you fold environment state into the payload the backend receives:

configurable!(MyView, MyConfig, StretchAxis::Horizontal, resolve |config, env| {
    MyConfig { density: env.get::<Density>().copied().unwrap_or_default(), ..config }
});

Use resolve when the backend needs a value it cannot look up itself. Do not use it to snapshot a signal — that would freeze a reactive input at build time.

The raw_view! macro

For leaf views with nothing to hook:

raw_view!(Divider, StretchAxis::CrossAxis);
raw_view!(Spacer, StretchAxis::MainAxis);
raw_view!(Image);  // StretchAxis::None

This implements NativeView and View without the ConfigurableView/Hook machinery.

The constructor split

WaterUI exposes construction two ways, and libraries should match:

  • Type::new(...) is the general constructor. It takes the most general shape the component can render.
  • Free functions like button(...) are the ergonomic entry points. They accept narrower semantic inputs so a string literal lands in the i18n-aware text pipeline with correct accessibility defaults.
// Ergonomic: the literal becomes semantic text with a default a11y label.
let save = button("Save").action(|| { /* ... */ });

// General: arbitrary visual content, with its spoken text stated separately.
let verified = Button::new(Label::new(
    "Verified account",
    hstack((text("Account"), verification_badge)),
));

Note what Button::new takes: a Label, not an open impl View. A control’s label is never optional, because an unlabelled control is an inaccessible control. Label::new(semantic_text, content) is how you supply arbitrary visual content while keeping the spoken text intact; Label’s semantic-only builders (icon, system_icon, leading, trailing, spacing, font) panic on custom-content labels rather than silently dropping the decoration.

Do not add a parallel Type::custom(...). If Type::new is not general enough, widen Type::new.

Flexible input types

IntoText and IntoLabel

Use IntoText for semantic text and IntoLabel for control labels. Both route literals, String, Str, StyledStr, and reactive Computed<T> through the i18n-aware pipeline, so localization and accessibility come along automatically:

use waterui_text::{IntoText, Text, font::Caption};

pub fn caption(content: impl IntoText) -> Text {
    Text::new(content).font(Caption)
}

caption("Saved");
caption(String::from("Saved"));
caption(text!("Saved at {now}"));

Reach for a bare impl View only when the slot really is arbitrary visual composition rather than a textual label.

IntoSignal<T> and IntoComputed<T>

For non-textual reactive inputs, accept a signal so callers can pass a constant or a live source without wrapping anything:

pub fn opacity(value: impl IntoComputed<f32>) -> Opacity {
    Opacity { value: value.into_computed() }
}

opacity(0.5);            // constant
opacity(my_binding);     // Binding<f32>
opacity(computed_value); // Computed<f32>

This is not a convenience: an API that takes a plain f32 where the underlying state is dynamic forces the caller into a subtree rebuild to change one number. New public surfaces take signals whenever the value can change.

IntoSignalF32

IntoSignalF32 is the numeric-literal-friendly variant. It converts any signal whose output is a Rust numeric type into a signal of f32:

use waterui_core::IntoSignalF32;

pub fn spacing(value: impl IntoSignalF32 + 'static) -> Computed<f32> {
    value.into_signal_f32().computed()
}

spacing(8);          // i32 literal
spacing(8.0);        // f32 literal
spacing(my_binding); // Binding<f64>

It returns a signal, not an f32 — that is what makes .spacing(binding) re-lay-out instead of freezing the first value.

Environment for context passing

Environment is a type-indexed store. Store<K, V> gives you a keyed slot when the value type alone is not a unique key:

use waterui_core::{Environment, env::use_env};

pub struct AccentSlot;

let env = Environment::new().store::<AccentSlot, Color>(Color::blue());

// Read it back inside a view.
pub fn themed_button() -> impl View {
    use_env(|env: Environment| {
        let color = env.query::<AccentSlot, Color>().cloned().unwrap_or(Color::blue());
        button("Tap me").foreground(color)
    })
}

store is a consuming builder on Environment, and use_env’s closure takes values extracted from the environment — Environment itself implements Extractor, so an owned Environment parameter works, and so does a tuple of extractable types:

let view = use_env(|(nav, db): (Navigator<Route>, Database)| {
    button("Load").action(move || { /* ... */ })
});

Library views should extract what they need rather than making callers thread parameters through every function.

The Plugin trait

Bundle a library’s setup into one installable value:

use waterui_core::{Environment, plugin::Plugin};

pub struct MyLibraryPlugin {
    pub theme: MyTheme,
}

impl Plugin for MyLibraryPlugin {
    fn install(self, env: &mut Environment) {
        env.insert(self.theme);
        env.insert_hook(|env: &Environment, config: ButtonConfig| {
            custom_button(config)
        });
    }
}

let mut env = Environment::new();
env.install(MyLibraryPlugin { theme: MyTheme::default() });

install takes self by value. The default implementation just inserts the plugin into the environment, so a plugin that is only a bag of settings needs no method body at all.

Composition patterns

Prefer a function that composes existing modifiers over a new view type:

// Prefer this.
pub fn panel(content: impl View) -> impl View {
    content.padding_with(EdgeInsets::all(16.0)).floating()
}

.floating() promotes a view to a themed elevated surface — container color, clip radius, and both shadows resolved from FloatingStyle in the environment. It panics if those tokens are absent, which is the fast-fail you want: a missing theme is a setup bug, not a reason to render an unstyled box.

Create a dedicated struct only when the component needs one:

  • It owns reactive state exposed as Binding<T> inputs.
  • It participates in FFI as a native view.
  • It has enough configuration to justify configurable!.
  • It must intercept or scope environment values for its subtree.

State belongs to the caller, not the body

Views are consumed by body(). State that must survive a rebuild lives in a Binding owned above the view, passed in as a parameter:

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

Two things this example is showing. Handlers receive state through typed State<T> extractor parameters paired with .state(...) calls, one per injected value — bundle them into a single #[derive(Clone)] struct once you reach four. And text! reads the binding reactively; calling .get() inside a body would read once and never update.

There is no renderer-provided local state slot to reach for. Component identity is not inferred from call order, so if your component seems to need one, the state is being owned at the wrong level.

Theming

Backends resolve Foreground, Background, Surface, SurfaceVariant, Border, Accent, AccentContainer, AccentForeground, MutedForeground, Tertiary, and TertiaryContainer from the environment. Read those tokens instead of naming concrete colors, and your component adapts to light/dark mode and to whatever theme the host app installed.

An unresolvable token panics with the slot name rather than rendering transparent, so a missing token surfaces at first render instead of as an invisible widget.

Testing

Accessibility-first component tests

waterui-testing renders a view headlessly and queries the accessibility tree it produces. #[waterui::test(view_fn)] expands to a plain #[test], so these run under the normal harness:

use waterui::ViewExt as _;
use waterui::accessibility::AccessibilityRole;
use waterui_testing::{Role, SemanticApp};

fn glyph_view() -> impl waterui::View {
    IconGlyph::new('\u{2605}', "Helvetica")
        .with_size(24.0)
        .a11y_role(AccessibilityRole::Image)
        .a11y_label("Glyph icon")
}

#[waterui::test(glyph_view)]
fn glyph_exposes_accessibility_image(app: &mut SemanticApp) {
    app.query().role(Role::IMAGE).label("Glyph icon").assert_exists();
}

This is simultaneously an interaction test and an accessibility test. A component that cannot be queried this way has an accessibility bug, not an untestable design.

Controls spawn local tasks internally, so a plain #[test] that constructs one directly needs a local executor installed first.

Snapshots and previews

TestHost::capture_snapshot writes PNG artifacts under the canonical <suite>/<case>/<stage>.png layout when WATERUI_TEST_ARTIFACTS_DIR is set.

For a visual check during development, give each public component a #[preview] function:

#[preview]
fn button_styles() -> impl View {
    vstack((
        button("Automatic"),
        button("Prominent").style(ButtonStyle::BorderedProminent),
        button("Plain").style(ButtonStyle::Plain),
    ))
    .spacing(8.0)
}
water preview button_styles --platform macos --path ./app --output button.png

Preview symbols are waterui_preview_<crate_name>_<function_name>, so names must be unique within a crate.

Public API shape

Export the constructors, the view types, and the style enums; keep configs and internals private:

pub use button::{Button, ButtonStyle, button};
// ButtonConfig and friends stay crate-private.

When a type must be public for macro expansion but has no business in the docs, mark it #[doc(hidden)].

One rule to hold onto: never degrade a public trait to make it object-safe. Expose the friendliest signature — -> impl Future, -> impl View, generic methods — and if you need dynamic dispatch internally, add a private object-safe shim trait with a blanket impl and store Box<dyn XxxImpl> behind a public wrapper. CustomViewRenderer in waterui-core is the reference: implementors write a plain async fn render_to_rgba, and the boxing lives out of sight behind ViewRenderer.

What’s next

The next chapter steps back from the code to the design principles these conventions come from.

Philosophy

In this chapter, you will:

  • Learn what “native” means in WaterUI, and what it deliberately excludes
  • See why style is an attribute rather than a separate component type
  • Understand the reactivity rules that shape every API in the framework
  • Read the principles a contribution is measured against

The rules below are constraints on every WaterUI feature, refactor, and review. They override convenience, and they explain why a lot of the API looks the way it does.

Native means platform-coupled

A native realization projects WaterUI semantics into the target platform’s own object model, lifecycle, accessibility, input, graphics, or media pipeline. It can come from an OS framework or from an official extension inseparable from that platform — Android’s View-based Material Components qualify, because they are coupled to Android’s view, resource, accessibility, and graphics pipelines.

A package is not native when it ships a largely self-contained engine that owns the domain instead of bridging into the platform, is portable to other platforms, and substantially expands the dependency closure. Being preinstalled is not the test. Under this definition ExoPlayer/Media3, Flutter, React Native, FFmpeg, and WaterUI’s own Hydrolysis and Dew are all not native implementations, however useful they are.

Layers are classified independently. Native controls, decoders, surfaces, or media sessions around an application-owned playback engine do not make that engine native.

Bridge native first, then draw

Every semantic component gets a native bridge on each platform that has a suitable primitive. Where a platform has none, it goes straight to the shared self-drawn realization — never to a third-party parallel engine relabelled as native.

The self-drawn path is a deliberate backend, not a rescue. A failed native bridge is an error to fix; it must not silently switch realizations at runtime. Some components have no platform primitive anywhere and therefore start self-drawn: particle systems and QR codes, for instance.

The trade-off is that pixel-identical rendering across platforms is not a goal. A button looks like an iOS button on iOS and a Material button on Android. If you need identical pixels everywhere, or you are on a target with no native widget set, that is what the self-drawn renderers are for.

Bridges must stay proportional

A bridge may only make reachable the platform code the selected WaterUI features actually need. Hiding a complete third-party framework behind FFI, reflection, service registration, or broad keep rules defeats R8, linker dead-stripping, and Cargo feature pruning, and every app pays for it. Unused features must drop their Rust code, platform code, resources, and transitive dependencies from the packaged artifact.

Style is an attribute, not a component

Toggle covers switch and checkbox. Picker covers menu, radio, and wheel. List covers plain, inset-grouped, and sidebar. You choose the presentation with an attribute — .style(...), theme tokens, an environment plugin, or the backend’s platform default.

There is no CheckboxToggle or GroupedList, and there will not be. Semantic identity is fixed; visual presentation is a property of the surrounding context.

Compose in Rust before binding native

Only a widget backed by a real platform primitive that cannot be expressed by composing existing primitives belongs on the FFI. Form, Card, Badge, LabeledContent, and GroupBox are Rust-side composers built from vstack, hstack, padding, and theme tokens; they ship zero new C-ABI types.

Adding a new FFI entry point requires evidence that no Rust-side composition produces the same result. Each one is a surface every backend must implement forever.

Fine-grained reactivity

WaterUI uses precise per-Binding/Computed updates. There is no virtual DOM, no tree diff, and no reconciliation pass:

Binding<i32> changes from 0 to 1
  |
  v
Computed<Str> = "Count: 1"   (only this recomputes)
  |
  v
UILabel.text = "Count: 1"    (only this property updates)

Update cost is proportional to the number of affected signals, not to the size of the tree. No other widget is touched, and no identity heuristics (keys, indices) are needed to work out what stayed the same.

This is a hard constraint on API design, not just an implementation detail. An API that would force a structural recompute to change one text value is rejected. New surfaces accept impl IntoComputed<T>, impl Signal<Output = T>, or Binding<T> whenever the underlying state can change.

Dynamic::watch is the exception, not the tool. It replaces the watched subtree and discards state owned inside it. The replacements are direct:

text!("{status}")                                // reactive text
Photo::new(url).blur(blur.clone())               // reactive value
Lazy::for_each(rows.clone(), row_view)           // dynamic set of views

No React-style state slots

Component identity is never inferred from body call order. There are no renderer-provided local state slots, no hook-like storage, no body-position keys. Mutable UI state is an explicit Binding/Computed owned at the correct semantic level and passed through the API.

Views are consumed rather than retained: View::body(self, env) takes self by value, so a view struct is moved when its body is evaluated. A component recreated by when(...) or watch(...) loses its instance state, and that is correct — a new instance is being initialized. If preserving state across a rebuild seems necessary, the state is owned at the wrong level.

Defaults are the framework’s job

When a backend renders a primitive it reads theme tokens — Foreground, Background, Surface, SurfaceVariant, Border, Accent, MutedForeground, AccentForeground — rather than hard-coding .label, .systemBackground, or NSColor.windowBackgroundColor.

The test is simple: view code calling .foreground(), .background(), or text("...") with no extra modifiers must produce platform-correct output. If app code has to reach into a backend to make defaults right, that is a backend bug.

Asymmetries are documented, not faked

Apple platforms ship SF Symbols; Android has no OS-supplied icon catalog. The honest answer is that the primitive is supported on one and explicitly unsupported on the other, and that portable code depends on a packaged icon-set crate. The dishonest answer is bundling a Material font and calling it “system.”

Surfacing the asymmetry as documentation is the right outcome. Hiding it behind a fallback is not.

Fail fast

An unexpected state crashes with a clear message rather than degrading into a plausible-looking default. A missing theme token panics with the slot name instead of resolving to transparent. .floating() panics when FloatingStyle tokens are absent instead of drawing an unstyled box. A view Dew cannot render panics rather than rendering something else.

Silent fallbacks hide the bug and move the failure somewhere harder to diagnose.

Signals, not streams

Reactive streams model sequences of events over time; signals model the current value of a piece of state. UI wants the latter. A text field always has a current value, a label always shows current text, and a subscriber connecting late needs the value now rather than a replay.

Binding<T> is state plus change notification, Computed<T> is a derived value. Both are synchronous, glitch-free, and main-thread-safe.

Rust all the way

Application logic, UI composition, state, and layout algorithms are all Rust; the native backends are adapters. This buys memory safety without a garbage collector, one language for business logic and UI, and no_std support in the core and FFI crates for embedded targets.

The cost is the FFI boundary. Every Rust/native interaction crosses a C ABI or JNI. WaterUI keeps that cost down with 128-bit type IDs for O(1) dispatch (no string comparisons), ownership transfer instead of cross-boundary reference counting, panic catching at the boundary, and generated bindings — ffi/waterui.h is produced by cbindgen and never hand-edited.

The water metaphor

Water takes the shape of its container without changing what it is. The same view tree flows through Apple, Android, GTK4, Hydrolysis, and Dew unchanged, and each gives it a local shape. The API follows: StretchAxis::MainAxis means “expand along whatever axis the parent uses,” Foreground is a slot rather than a hex value, and font::Body resolves to San Francisco, Roboto, or the system font depending on where it lands.

Principles for contributors

  1. Bridge native first. Use the platform primitive where one exists; go to the self-drawn realization only where none does.
  2. Style is an attribute. Add a variant to an existing semantic component rather than a parallel type.
  3. Keep the FFI surface minimal. Compose in Rust before adding a C-ABI type.
  4. Type safety over runtime checks. Encode invariants in the type system; prefer generics and traits over enums and type erasure.
  5. Never degrade a public trait for object safety. Expose the friendliest signature and erase types privately behind a shim.
  6. No global state. Pass context through Environment, never through statics or singletons.
  7. Fail fast. Panic with a clear message instead of falling back.
  8. Less code is better. Import a maintained crate rather than reimplementing it. Every line is a line that can break.

Automation and CI

In this chapter, you will:

  • Script the water CLI for deterministic, non-interactive builds
  • Run preview-based semantic and performance tests in CI
  • Set up a multi-platform GitHub Actions workflow
  • Debug CI failures with structured logging

Deterministic CLI runs

JSON output

--json is a global flag. It switches every status, error, and success message from human-readable ANSI to machine-readable JSON:

water --json devices

Pipe it through jq to pull out fields:

# The first iOS simulator's identifier
water --json devices | jq -r '.ios[0].udid'

The devices payload has one section per platform — ios, android (emulators and devices), macos, and esp32 — and omits sections that were not scanned.

Non-interactive mode

Subcommands that may prompt accept -y/--yes:

water clean -y
water backend remove apple -y

Not every command prompts; check water <command> --help. A forgotten prompt hangs the pipeline.

Scripting with water commands

Building for multiple platforms

--platform is a flag, not a positional argument:

#!/bin/bash
set -euo pipefail

water build --platform ios-simulator
water build --platform android
water build --platform linux
water build --platform esp32c3

Accepted values are ios, ios-simulator, android, macos, linux, windows, esp32s3, and esp32c3.

Device discovery

DEVICE_ID=$(water --json devices | jq -r '.ios[0].udid')
water run --platform ios --device "$DEVICE_ID"

Preview rendering

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 — the same command whether you are eyeballing a change locally or regenerating snapshots before a diff.

Preview-based testing

Beyond rendering an image, water preview has two subcommands built for CI.

Semantic assertions

water preview test drives a preview through WaterUI’s accessibility tree. --all discovers and runs every #[preview] function in the crate:

water preview test --all --theme material3 --platform macos --path ./app

The automation body is Rust, supplied inline with --code or from a file with --code-file, and receives app: &mut waterui_testing::SemanticApp:

water preview test my_form \
    --theme material3 \
    --code-file ci/checks/my_form.rs

Because the assertions run against the accessibility tree, they are simultaneously interaction tests and accessibility tests. Prefer these to pixel diffs: a snapshot fails on any antialiasing change, while a semantic query fails only when the UI actually changed meaning.

Performance measurement

water preview perf profiles a preview through the offscreen GPU pipeline and emits a JSON report:

water preview perf --all \
    --theme material3 \
    --warmups 10 --samples 120 --repetitions 7 \
    --path ./app

The automation body here receives perf: &mut waterui_testing::PerfApp<_, _, _>. Add --flamegraph <path> to also write a CPU call-stack SVG.

Image comparison

If you do need pixel comparison, generate and diff explicitly:

water preview my_button --platform macos --path ./app --output current/button.png
compare -metric RMSE reference/button.png current/button.png diff/button.png

GitHub Actions

name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo fmt --check
      - run: cargo clippy -- -D warnings
      - 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
      - run: cargo install waterui-cli
      - run: water doctor
      - 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
      - run: cargo install waterui-cli
      - run: water build --platform android

WaterUI requires rustc 1.95 or newer, so a pinned toolchain older than that will fail before any WaterUI-specific step runs.

Environment validation

Run water doctor early. It checks the Rust toolchain and required targets, Xcode and the macOS/iOS SDKs, iOS simulators, Android run targets, GTK4, and Linux system packages. water doctor --fix installs what it can — missing Rust targets, for instance — and reports the rest.

Caching

- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

Do not cache Xcode derived data or Gradle build directories; they are fragile across runners and produce failures that are hard to diagnose.

WaterUI keeps managed backend builds under ~/.water/build_cache. On a long- lived self-hosted runner, prune stale entries:

water gc build-cache --path ./app

Testing

cargo test
cargo test -p waterui-core

Component tests written with #[waterui::test(...)] expand to ordinary #[test] functions, so they need no custom runner. When WATERUI_TEST_ARTIFACTS_DIR is set, snapshot artifacts are written beneath it as <suite>/<case>/<stage>.png, ready to upload as a workflow artifact.

If your project includes an mdBook, mdbook test compiles the Rust code blocks in your markdown; blocks marked rust,ignore are skipped.

Release builds

water package requires an explicit --backend, and --release selects 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

--arch is required for Android and accepts a comma-separated list (arm64, x86_64, armv7, x86). Add --distribution for App Store or Play Store packaging.

Clean builds

water clean

This removes WaterUI-specific build artifacts. Avoid cargo clean, which wipes the whole target directory and forces a full rebuild.

Environment variables

VariablePurpose
RUST_LOGTracing filter for the runtime (e.g. debug)
WATERUI_DISPATCH_DEBUGLogs the view dispatch tree in any backend using ViewDispatcher
WATERUI_TEST_ARTIFACTS_DIRRoot directory for waterui-testing snapshot artifacts
WATERUI_HYDROLYSIS_RENDER_DIAGPer-frame render diagnostics from Hydrolysis
WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTERAllows a software wgpu adapter — diagnostics only, never in a release job

Debugging in CI

water run --platform ios --logs debug
RUST_LOG=debug cargo test

--logs debug streams device logs at or above the given level, showing view dispatch, signal updates, and FFI calls. Apple platforms route these through os_log, Android through logcat, and everything else to stderr. Add --native-logs when you need the platform’s own output (NSLog, print) alongside WaterUI’s — noisy, but necessary when debugging native code.

FFI header verification

This one is for WaterUI contributors only; application authors never need it. ffi/waterui.h is checked in and generated, never hand-written, so CI verifies it has not drifted:

cargo run --bin generate_header --features cbindgen --manifest-path ffi/Cargo.toml
git diff --exit-code ffi/waterui.h

What’s next

If CI is green but something still misbehaves, the Troubleshooting appendix covers the common failures.

Troubleshooting

In this appendix, you will:

  • Fix Rust toolchain and platform SDK problems
  • Resolve build failures, missing-feature errors, and FFI mismatches
  • Read WaterUI’s fast-fail panics and act on them
  • Debug rendering and reactivity problems with structured logging

Start with water doctor; it diagnoses most environment problems and can fix several of them.

First steps

water doctor          # check the environment
water doctor --fix    # install what can be installed automatically
water clean           # clear WaterUI build artifacts (not the cargo target dir)

Rust toolchain

Rust version too old

Symptom: compilation errors about unstable features or unrecognized syntax.

WaterUI requires Rust edition 2024 and rustc 1.95 or newer.

rustc --version
rustup update stable

If the project pins a toolchain in rust-toolchain.toml, raise it there too.

Missing target triple

Symptom: error[E0463]: can't find crate for 'std' when cross-compiling.

rustup target add aarch64-apple-ios          # iOS device
rustup target add aarch64-apple-ios-sim      # iOS simulator, Apple Silicon
rustup target add x86_64-apple-ios           # iOS simulator, Intel
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android

water doctor --fix installs missing targets for you.

Cryptic cargo failures

Update Rust (rustup update), then refresh the lockfile (cargo update). If it persists, clear the registry cache:

rm -rf ~/.cargo/registry/cache
cargo update

Avoid cargo clean unless nothing else works — it discards every compiled artifact and forces a full rebuild.

Missing features

WaterUI is feature-granular, and a missing feature shows up as an unresolved path rather than a runtime problem.

cannot find ... in crate waterui

Symptom: svg, FilterViewExt, ImageGenerator, ImageAnalysis, or one of the generator types (NoiseGenerator, LinearGradientGenerator, and friends) does not resolve.

These live behind the gpu feature, which is on by default. If you built with --no-default-features — typically to cross-compile for an embedded target without wgpu and vello — re-enable it explicitly:

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

waterui::webview does not resolve

webview is not a default feature. Opt in:

waterui = { version = "0.2", features = ["webview"] }

Then pick the engine in Water.toml with webview_backend (default, system, wpe, or cef). Setting the key alone changes nothing if your app never links waterui-webview.

chart, barcode, map, particle, and navigation-restoration are opt-in the same way.

Platform SDKs

iOS: Xcode not found

xcode-select -p
# If it points at CommandLineTools instead of Xcode.app:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept

iOS: no simulator found

water devices              # what water can see
water run --platform ios   # let water pick and boot one

If water devices lists no simulators at all, the problem is the host Xcode install; water doctor will say so.

iOS: code signing errors

Simulator builds need no signing. Device builds need an Apple Developer account configured in Xcode, a development certificate and provisioning profile, and the right team selected in project settings.

Android: SDK not found

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

Add it to your shell profile so it survives a new terminal.

Android: NDK not found

Symptom: cross-compilation fails looking for aarch64-linux-android-* tools.

sdkmanager --install "ndk;27.0.12077973"

The CLI finds the NDK under $ANDROID_HOME/ndk/.

Android: emulator not running

water devices

If the list is empty, start an emulator from Android Studio and re-run. water run --platform android then deploys to it automatically.

Linux: GTK4 development packages

The GTK4 backend is the native Linux bridge and needs GTK’s development headers:

sudo apt install libgtk-4-dev              # Ubuntu / Debian
sudo dnf install gtk4-devel                # Fedora
sudo pacman -S gtk4                        # Arch Linux

Add libwebkitgtk-6.0-dev (or your distribution’s equivalent) if you use the system web view.

Linux: no usable GPU adapter

Symptom: Hydrolysis cannot find a wgpu adapter.

Hydrolysis is GPU-required. Install a working Vulkan stack:

sudo apt install libvulkan-dev mesa-vulkan-drivers vulkan-tools   # Ubuntu / Debian
sudo dnf install vulkan-loader-devel mesa-vulkan-drivers vulkan-tools  # Fedora
sudo pacman -S vulkan-icd-loader vulkan-tools                     # Arch Linux

If vulkaninfo reports no device, Hydrolysis refuses to boot rather than quietly falling back to software rendering. Use a machine with a real GPU, or set WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTER=1 for a single diagnostic run.

Build failures

Linking errors

Symptom: undefined reference or unresolved external symbol.

  • Missing system libraries: run water doctor to check SDK installation.
  • Architecture mismatch: confirm the target with rustc --print target-list | grep <platform>.
  • Stale artifacts: water clean, then rebuild.

FFI header out of date

Symptom: the native backend fails to compile against missing or mismatched function signatures. This only affects contributors working inside the WaterUI repository.

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

Then rebuild the native backend. ffi/waterui.h is generated and checked in; never edit it by hand.

Proc macro errors

Proc macro crates compile for the host, not the target. If you see “can’t load proc macro”, check rustup show active-toolchain and that cargo build -p waterui-macros succeeds on its own.

Fast-fail panics

WaterUI panics with a specific message instead of degrading into a plausible-looking default. These are the ones you are most likely to hit.

WaterUI color token ... is not installed in the environment

A themed view resolved a color slot that nothing installed. This happens when a view is rendered against a bare Environment::new() with no backend or theme.

Install a theme before rendering. For a Hydrolysis app the CLI-generated entry point does it for you:

let env = Environment::new();
let mut app = my_crate::app(env);
hydrolysis_m3::install_defaults(&mut app.env);
hydrolysis::run(app);

In your own app(env) you can install one directly:

pub fn app(mut env: Environment) -> App {
    env.install(Theme::new().color_scheme(ColorScheme::Dark));
    App::new(main, env)
}

WaterUI color scheme is not installed in the environment

Same cause, raised by current_color_scheme(env). When you legitimately need to ask whether a scheme exists, use the non-panicking installed_color_scheme(env) -> Option<Computed<ColorScheme>> instead.

WaterUI `.floating()` requires FloatingStyle theme tokens

.floating() resolves its container color, clip radius, and shadows from a FloatingStyle in the environment. Theme::install inserts a default one, so this panic means no theme was installed — or that a custom environment removed it. Install your own to override:

env.install(FloatingStyle { clip_radius: 0.25, ..FloatingStyle::default() });

A Label builder panicked

Label::icon, system_icon, leading, trailing, spacing, and font are semantic-only. Calling them on a label built with Label::new(semantic_text, content) panics rather than silently dropping the decoration. Build the decoration into the custom content instead.

An unsupported view panicked on Dew

Dew supports a subset of views (stacks, padding, colors, spacers, text) and panics on anything else rather than rendering something wrong. Either compose the screen from supported views or contribute the missing handler.

Assets

Asset not found at runtime

Symptom: an ImageAsset or FontAsset resolves to a missing resource.

  1. Confirm the file exists in the project’s assets/ directory.
  2. Check the filename case — mobile platforms are case-sensitive.
  3. The CLI bundles assets/ automatically; if you compose a custom Bundle, confirm it points at the right path.

Image not displaying

  • Format: the self-drawn renderers decode PNG, JPEG, GIF, WebP, BMP, ICO, AVIF, and TIFF. Native backends use the platform decoder, whose format support differs.
  • Corruption: try opening the file in an image viewer.
  • Size: very large images can fail to decode on memory-constrained devices.

Runtime issues

View not rendering

  1. Zero size: the view has no intrinsic size and no frame constraint. Add .size(width, height) or make sure the parent offers space.
  2. Hidden: check for .opacity(0.0) or a transparent background.
  3. Conditional: verify the condition driving when(...) is what you expect.

Set WATERUI_DISPATCH_DEBUG=1 to see which view types the backend matched and which fell through to body().

Signals not updating

Symptom: changing a Binding leaves the UI unchanged.

  1. Do not call .get() in a view body. It reads once and never subscribes. Use text! for reactive text, or a combinator for other values:

    // Wrong: reads once, no reactivity
    text(format!("Count: {}", count.get()))
    
    // Right
    text!("Count: {count}")
  2. Prefer signal-taking APIs. Passing a Binding to .spacing(...), .blur(...), or .disabled(...) updates that one property. Wrapping the subtree in watch(...) rebuilds it and throws away any state it owned.

  3. Use a collection for a dynamic set of views. ForEach/List over a reactive collection diffs by id; watch over a Vec rebuilds everything.

  4. Mutate in place. *count.get_mut() += 1 is the idiom; count.set(count.get() + 1) reads outside a subscription.

  5. Binding lifetime: if the binding is dropped, its watchers disconnect. Keep it alive at the level that owns the state.

  6. Thread: update bindings from the main thread. From an async task, dispatch back through the executor.

State lost after an update

Expected when the component was recreated — when(...), watch(...), or any parent-driven reconstruction initializes a new instance, and instance-local state goes with the old one. The fix is to own that state in a Binding one level up and pass it in, not to try to preserve it across the rebuild.

App crashes on startup

water run --platform ios --logs debug
water run --platform android --logs debug
RUST_LOG=debug water run --platform linux

Common causes:

  • A missing theme token or color scheme — see the panics above.
  • A panic inside your app(env) function; the log carries the message.
  • Rust library and native backend out of sync; rebuild both.

Platform-specific tools

iOS simulator

xcrun simctl erase "iPhone 16"
xcrun simctl spawn "iPhone 16" log stream --level debug --predicate 'subsystem == "dev.waterui"'

Android emulator

adb shell pm clear dev.waterui.yourapp
adb logcat -s WaterUI:D
adb shell am force-stop dev.waterui.yourapp

Generated projects default to a dev.waterui.<name> bundle identifier.

Hydrolysis on desktop

# Which views matched a handler, and which fell through to body()
WATERUI_DISPATCH_DEBUG=1 water run --platform linux --backend hydrolysis --logs debug

# Per-frame render diagnostics
WATERUI_HYDROLYSIS_RENDER_DIAG=1 water run --platform linux --backend hydrolysis

Debug logging

WaterUI logs through tracing:

water run --platform <platform> --logs debug
RUST_LOG=debug water run --platform <platform>
RUST_LOG=waterui=debug,waterui_core=trace water run --platform <platform>

--logs accepts error, warn, info, debug, and verbose. Add --native-logs to include the platform’s own output alongside WaterUI’s.

Apple platforms log to os_log under the dev.waterui subsystem:

log stream --predicate 'subsystem == "dev.waterui"' --level debug

Android logs to logcat:

adb logcat -s WaterUI:D

Getting help

  1. Search the GitHub issues.
  2. Include water doctor output in the report.
  3. Include the full error and relevant logs.
  4. State your OS, rustc --version, and platform SDK versions.
  5. Provide a minimal reproduction if you can.