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
| Backend | Targets | Realisation |
|---|---|---|
| Apple | iOS, macOS | UIKit / AppKit through a Swift package |
| Android | Android | Android View through Kotlin and JNI |
| GTK4 | Linux | GTK4 widgets through gtk4-rs |
| Hydrolysis | macOS, Linux, Windows, Web | Self-drawn GPU renderer (Vello on wgpu) |
| Dew | ESP32-S3, ESP32-C3 | Self-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.
| Crate | Path | Role |
|---|---|---|
waterui | / | Facade: prelude, widgets, macro re-exports |
waterui-internal | src/ | Implementation behind the facade |
waterui-core | core/ | View, Environment, AnyView, layout and accessibility contracts |
waterui-layout | components/foundation/layout/ | Stacks, grids, ScrollView, Spacer, absolute layout |
waterui-text | components/foundation/text/ | Text, fonts, styled text |
waterui-controls | components/foundation/controls/ | Button, Toggle, Slider, Stepper, TextField, Label |
waterui-form | components/foundation/form/ | Form builder, Picker |
waterui-navigation | components/foundation/navigation/ | Navigation stacks, tabs, split views, routing |
waterui-shape | components/foundation/shape/ | Shape primitives |
waterui-icon | components/foundation/icon/ | Icon system; icon sets live under components/icon/ |
waterui-graphics | components/visual/graphics/ | Colours, gradients, GPU surface, image analysis |
waterui-image / waterui-svg / waterui-canvas | components/visual/ | Images, SVG, canvas drawing |
waterui-media / waterui-video | components/multimedia/ | Photos, audio, video playback |
waterui-chart / waterui-map | components/data/ | Charts and maps |
waterui-barcode | components/codes/barcode/ | Barcode and QR rendering |
waterui-particle | components/effects/particle/ | Particle systems |
waterui-webview / waterui-chromium | components/platform/ | Embedded web views and Chromium/CDP |
waterui-assets | components/assets/runtime/ | Asset loading, asset!, bundles |
waterui-macros | macros/ | text!, #[form], #[preview], #[derive(Identifiable)] |
waterui-locale | utils/locale/ | Locale resolution and catalog! |
nami | utils/nami/ | The reactive engine behind waterui::reactive |
filtrate | utils/filtrate/ | GPU filter and effect runtime |
waterui-testing | testing/ | Semantic UI tests over the accessibility tree |
waterui-ffi | ffi/ | C ABI bridge; owned by the CLI, not by your app |
waterui-cli | cli/ | 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
- Book source: github.com/water-rs/book
- Framework source: github.com/water-rs/waterui
Continue to The Water CLI to install the toolchain and scaffold a project.
The Water CLI
In this chapter, you will:
- Install the
watercommand-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-cliis much faster than a full install. Reinstall when you need the new binary on yourPATH.
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
| Argument | Description |
|---|---|
name | Display name. The folder is its kebab-case form, the crate its snake_case form. |
--bundle-id | Bundle identifier. Defaults to dev.waterui.<snake_case_name>. |
--backends | Comma-separated: apple, android, gtk4, hydrolysis, esp32. App mode only. |
--mode | app (default) or playground. |
--waterui-path | Path 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.
| Argument | Description |
|---|---|
--platform, -p | ios, android, macos, linux, windows, web, esp32s3, esp32c3. Defaults to the host. |
--backend, -b | apple, android, gtk4, hydrolysis, dew. Overrides the platform default. |
--device, -d | Device name or identifier. Defaults to the first booted or available device. |
--path | Project directory (defaults to .). |
--logs | Minimum level to stream: error, warn, info, debug, verbose. |
--native-logs | Include all native logs (NSLog, logcat), not just WaterUI’s. |
Platform defaults and the combinations the CLI accepts:
| Platform | Default backend | Also accepts |
|---|---|---|
| iOS | Apple | — |
| macOS | Apple | Hydrolysis |
| Android | Android | — |
| Linux | GTK4 | Hydrolysis |
| Windows | Hydrolysis | — |
| Web | Hydrolysis | — |
| ESP32-S3 / ESP32-C3 | Dew | — |
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
| Argument | Description |
|---|---|
--platform, -p | ios, ios-simulator, android, macos, linux, windows, esp32s3, esp32c3. |
--backend, -b | apple, android, gtk4, hydrolysis, dew. |
--arch, -a | arm64, x86-64, armv7, x86. Apple and Android backends only. |
--release | Optimised build. |
--path | Project directory (defaults to .). |
--output-dir | Copy 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
| Argument | Description |
|---|---|
--platform, -p | ios, ios-simulator, android, macos, linux, windows, web. |
--backend, -b | Required: apple, android, gtk4, hydrolysis. |
--release | Optimised build. |
--distribution | Package for store submission. |
--arch | Android architectures, comma-separated: arm64, x86-64, armv7, x86. Required for Android. |
--path | Project 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!")
}
| Argument | Description |
|---|---|
target | #[preview] function name or path, or an expression with --expr. |
--expr | Treat the target as an expression rather than a function path. |
--platform, -p | ios, macos, android. Defaults to the native preview platform. |
--backend | apple, android, hydrolysis. |
--theme | material3. Hydrolysis previews only. |
--frame, -f | WIDTHxHEIGHT (default 375x667). |
--output, -o | Output file (default preview.png). |
--scenario / --output-dir | Hydrolysis scenario TOML for interaction capture, and where to write its frames. |
--path | Project 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 doctorfirst 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:
- rust-analyzer
- Even Better TOML
– highlights
Cargo.tomlandWater.toml - CodeLLDB
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:
- Settings > Languages & Frameworks > Android SDK.
- Under SDK Platforms, install a recent API level.
- 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 inView,Environment,Binding,State, the layout and control constructors, and the macros.fn main() -> impl Viewis the root view.&'static strimplementsView, so a bare literal renders as text.pub fn app(env: Environment) -> Appis the entry point. The backend hands you anEnvironmentcarrying 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 aBinding<i32>. There are typed constructors foru32,u64,usize,i32,i64,isize,f32,f64, andbool. For anything else useBinding::container(value). There is noBinding::new.text!("Count: {counter}")interpolates named placeholders that match a binding in scope, or an explicit alias such astext!("Count: {n}", n = counter). It subscribes tocounterand 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:
button("Increment")takes animpl 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..action(...)runs on activation. EachState<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..state(&counter)injects a value. Chain one.state(...)perState<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.tomlmanifest- 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-cacheorwater 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"
| Field | Type | Description |
|---|---|---|
type | "playground" or "app" | Project mode. |
name | string | Display name shown by the OS. |
bundle_identifier | string | Reverse-domain identifier: iOS bundle ID and Android application ID. |
assets_path | string | Assets directory relative to the project root. Defaults to "assets"; omitted from the file at the default. |
accessory | boolean | Build 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
Viewtrait and understand whybodyconsumesself- 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
AnyViewis 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:
selfby 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 aBindinginstead.
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:
| Type | Behavior |
|---|---|
() | 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() -> V | Calls 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 andComputed<T>for derived state- Learn the golden rule that keeps your UI updating, and the
map/zipcombinators that replace.get()- Format reactive text with
s!andtext!- 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:
| Group | Methods |
|---|---|
| Comparison | equal_to, condition, gt, lt, ge, le |
| Boolean | not, and, or, then_some, select |
| Numeric | abs, negate, sign, is_positive, is_negative, is_zero |
Option | is_some, is_none, unwrap_or, unwrap_or_else, unwrap_or_default, some_equal_to, flatten, map_some, and_then_some |
Result | is_ok, is_err, ok, err, unwrap_or_result, map_ok, map_err |
| String-like | is_empty, str_len, contains |
| Plumbing | map_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 fromwaterui::component::list. An explicituse 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 update | Use this, not watch |
|---|---|
| Text content | text!("{status}") |
| A view attribute | the signal-taking modifier, e.g. photo.blur(amount) |
| Collection membership | Lazy::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_envand 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 withWaterUI color token ... is not installed in the environment. Backends andTheme::installset 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:
| Type | Behavior |
|---|---|
Environment | Clones 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
| Category | Modifiers |
|---|---|
| Layout | padding, padding_with, width, height, size, min_width, max_width, min_height, max_height, min_size, max_size, alignment, ignore_safe_area |
| Visual | background, foreground, opacity, overlay, shadow, border, border_with, clip, floating, floating_with, visible |
| Transform | scale, scale_from, rotation, rotation_from, offset |
| Interaction | on_tap, on_tap_gesture, on_tap_gesture_count, on_long_press_gesture, gesture, gesture_observer, hittable, disabled, draggable, drop_destination, state |
| Feedback | on_tap_haptic, on_tap_haptic_default, cursor, badge |
Filter (gpu) | blur, brightness, contrast, saturation, grayscale, hue_rotation, invert |
| Lifecycle | on_appear, on_disappear, on_change, task |
| Event | on_hover_enter, on_hover_exit, event |
| Other | tag, 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()andtext!, and know which one consults the translation catalog- Style text through theme font tokens, weights, colors, and decorations
- Compose rich text with
StyledStrand 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:
| Input | Behavior |
|---|---|
&'static str | Looked up in the translation catalog (Text::localized) |
String, Str | Rendered verbatim, never translated (Text::verbatim) |
StyledStr | Rendered verbatim, keeping its per-chunk styling |
Computed<T> / Binding<T> where T: IntoText | Reactive; 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 andTheme::installdo this for you; a bareEnvironment::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 fromViewExtsets the inherited foreground for a whole subtree, so children pick it up through the cascade..foreground()takesimpl Into<Color>rather than a signal, which is why the bare palette markers (Red,Grey) work there but needColor::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 / Function | Purpose |
|---|---|
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)),
))
}
| Constructor | Description |
|---|---|
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)
}
| Method | Description |
|---|---|
.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
Frameonly 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"),
)))
}
| Function | Direction | Path |
|---|---|---|
scroll(content) | Vertical only | in the prelude |
scroll_horizontal(c) | Horizontal only | waterui::layout::scroll |
scroll_both(c) | Both directions | waterui::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,
),
))
}
| Method | Description |
|---|---|
.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:
| Variant | Meaning | Examples |
|---|---|---|
None | Content-sized | Text, Button, zstack, hstack |
Horizontal | Fills the width, keeps its intrinsic height | TextField, Slider, Toggle, vstack |
Vertical | Fills the height, keeps its intrinsic width | — |
Both | Fills the space it is given | ScrollView, absolute, colours |
MainAxis | Fills along the parent stack’s main axis | Spacer |
CrossAxis | Fills 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 theState<T>extractor- Use
Toggle,Slider,Stepper, andTextFieldfor 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_iconrenders 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 aswaterui-icons-lucideorwaterui-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 aViewExtmethod, 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.
| Style | Use for |
|---|---|
Automatic | Platform default (the default) |
Plain | Low-emphasis actions, toolbar buttons |
Link | Text-based links and URL navigation |
Borderless | No border, but hover and press feedback |
Bordered | Secondary actions |
BorderedProminent | The 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
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
Commandis 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
| Control | Constructor | Value | Stretch axis |
|---|---|---|---|
Button | button(label) / Button::new(Label) | action closure | None |
Toggle | toggle(label, &b) / Toggle::new(&b) | Binding<bool> | Horizontal |
Slider | slider(label, &b) / Slider::new(Label, &b) | Binding<f64> | Horizontal |
Stepper | stepper(label, &b) / Stepper::new(Label, &b) | Binding<i32> | Horizontal |
TextField | field(label, &b) / TextField::styled(&b) | Binding<Str> / Binding<StyledStr> | Horizontal |
Menu | Menu::new(label, items) | action closures | None |
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 type | Control | Notes |
|---|---|---|
String | TextField | Doc comment becomes the prompt |
Str | TextField | WaterUI’s interned string type |
bool | Toggle | |
i32 | Stepper | Range i32::MIN..=i32::MAX |
f64 | Slider | Range 0.0..=1.0 |
f32 | Slider | Mapped through f64, same range |
Color | ColorPicker | Platform-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.
| Style | Appearance |
|---|---|
Automatic | Platform default |
Menu | Dropdown menu button |
Radio | Vertical radio group |
Segmented | Horizontal 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:
| Validator | Validates |
|---|---|
Range<T> | start..end, exclusive end, for T: Display + Debug + Ord + Clone |
Regex | Any AsRef<str> matches the pattern |
Required | Option<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_eachand#[derive(Identifiable)]- Compose static, heterogeneous, sectioned lists with
List::contentandSection- 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 acceptsCollectionorIntoIterator. Reach forvec!only when the length is decided at runtime, when the collection needs mutation, or when the API specifically requires aVec.
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 asReactiveListand keeps the bareListfor 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 plainLazy::for_eachstack too.
Choosing between the surfaces
| You have | Use |
|---|---|
| An identity-keyed collection that changes at runtime | List::for_each over a ReactiveList |
| A fixed set of rows with headers, footers, or mixed shapes | List::content with Section / row |
| A large collection with no list chrome | Lazy::for_each |
| A handful of views that never change | vstack / 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 amatch+.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:
- The previous subtree is removed.
- The new branch’s builder closure runs.
- 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 changes | Use | Not |
|---|---|---|
| A displayed value | text!("{status}") | when / watch around two text() calls |
| A parameter of a live view | a signal-taking input, e.g. .blur(amount.clone()) | rebuilding the view |
| The membership of a collection | ForEach / List over a reactive collection | watch over a Vec |
| Whether a subtree is on screen but should keep its state | .visible(signal) | when |
| Which kind of view is on screen | when / 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
| Pattern | Purpose |
|---|---|
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, andNavigationLink- Model your screens as route data with
NavigationPathand.destination(...)- Drive the stack from any handler with
Navigatorand observe destination lifecycle- Attach semantic chrome — titles, toolbar placements, search — and pick a transition
- Lay out top-level structure with
TabsandNavigationSplitView
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:
NavigationLinkneeds a surrounding navigation context. A debug assertion fires if you place one outside aNavigationStack.
Navigation views and titles
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 / mode | Behavior |
|---|---|
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:
Texthas its own inherent.title()with no arguments — that is the typography preset from the text chapter, not a navigation title. On a bareText, useNavigationView::new("Title", text(…))instead oftext(…).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:
| Hook | Fires 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"))
}
Toolbars, bar chrome, and search
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.
Deep links and restoration
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()andvideo_player()- Own playback state in a
PlaybackSessionand drive it through aPlayerController- 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"] }
| Crate | Path | Contents |
|---|---|---|
waterui-media | waterui::media | Photo, LivePhoto, Media, MediaPicker, Image, plus re-exports of the video types |
waterui-video | waterui::video | Video, 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
| Component | Controls | Use for |
|---|---|---|
Video | none (raw surface) | custom player UI, background clips |
VideoPlayer | platform-appropriate controls | ordinary 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 ().
| Command | Notes |
|---|---|
play / pause / stop | stop pauses and returns to the start of the item |
step_forward / step_backward | pause 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:
| Getter | Type |
|---|---|
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}"),
_ => {}
})
}
| Event | Meaning |
|---|---|
ReadyToPlay | the current item can start |
PlaybackStateChanged { playing } | media time started or stopped advancing |
Buffering / BufferingEnded | playback 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 / PreviousRequested | system or player UI asked to change item |
Ended | the 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"),
)),
];
| Variant | Renders 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”.
| Filter | Selects |
|---|---|
MediaFilter::Image / Video / LivePhoto | one 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 acfgdecision, 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()fromcontroller.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
mapfeature onwaterui. Enable it inCargo.toml(waterui = { version = "...", features = ["map"] }) sowaterui::mapis 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:
| Constructor | Free function | Input |
|---|---|---|
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.
SatelliteandHybridare honored by the native MapKit realization. The GPU vector realization used on other platforms panics on anything butStandard, 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:
| Method | Effect |
|---|---|
.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 ituser_locationoroptional_user_locationif 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:
| Accessor | Type |
|---|---|
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
MKMapViewfrom 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(®ion)
.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::openentry 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:
webviewis not in the default feature set (gpu,assets,media,flow-markdown). Enable it explicitly:waterui = { version = "0.2", features = ["webview"] }The
waterui::webviewmodule — 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.")),
}
}
}
Navigation state
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:
| Event | Fields | Meaning |
|---|---|---|
None | — | Initial state, before anything happens |
WillNavigate | url: Url | Navigation is about to begin |
Loading | progress: f32 | Load progress, 0.0 to 1.0 |
Loaded | — | The page finished loading |
Redirect | from: Url, to: Url | A redirect occurred |
Error | WebViewError | Navigation 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
| Value | Engine | Where it works |
|---|---|---|
default | Bundled WPE on Linux, system engine everywhere else | — |
system | Platform web view (WKWebView, Android WebView, WebKitGTK) | Apple platforms, Android, Linux + GTK4, web |
wpe | WaterUI’s bundled WPE WebKit runtime | Linux, with GTK4 or Hydrolysis |
cef | WaterUI’s bundled Chromium Embedded Framework runtime | macOS, 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
barcodefeature onwaterui(waterui = { version = "...", features = ["barcode"] }). The crate is then re-exported aswaterui::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
| Symbology | Constructor | Shape | Typical use |
|---|---|---|---|
| QR Code | Barcode::qr(content) | 2D matrix | URLs, tokens, arbitrary text |
| Code 128 | Barcode::code128(content) | 1D bars | Alphanumeric 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
| Symbology | Quiet zone (modules) |
|---|---|
| QR Code | 4 |
| Code 128 | 10 |
The quiet zone is drawn in the light color and added automatically — you do not need to pad the view yourself.
API reference
Barcode
| Method | Description |
|---|---|
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>
| Method | Description |
|---|---|
.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.
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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();
})
| Method | Description |
|---|---|
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
CanvasImagehandles 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
GpuViewtrait 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
- Setup runs once, after the wgpu device is ready. Build pipelines, buffers, bind groups, and owned textures here. Clone
ctx.redraw_handleif you need to wake the surface from outside the render loop. - Resize is implicit: every
rendercall carries the currentframe.width/frame.height. Detect a size change there and recreate size-dependent resources. - Render runs whenever the surface is dirty. Submit your wgpu commands through
frame.queue, and callframe.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,
}
adapteris always present. Use it forget_texture_format_featureswhen you need to know what the hardware supports.surface_formatmay beRgba16Floatwhen the platform supports HDR. Callctx.is_hdr()and gate your blend state on it — with HDR active, useblend: Nonerather thanBlendState::REPLACE.msaa_samplesis the sample count the backend selected for this surface. Use it for both pipeline configuration and any MSAA attachments you create.redraw_handleis a cheap, thread-safe handle. Clone and stash it; callrequest_redraw()whenever new state should drive a frame. It also exposesis_dirty(),take_dirty(), andset_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()andframe.pointer_normalized()give quick access to pointer state.frame.gesturecarries 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
| Item | Role |
|---|---|
GpuView | Trait you implement for custom GPU rendering |
GpuContext | Setup-time wgpu handles + redraw handle |
GpuFrame | Per-frame texture, pointer, gesture, timing |
GpuSurface | Raw view that owns a single GpuView instance |
RedrawHandle | Wakes the surface from outside the render loop |
GpuRuntime | Explicitly constructed GPU context for headless work |
OffscreenRenderConfig | Headless render configuration |
OffscreenRenderOutput | RGBA8 pixel output with PNG encoding |
OffscreenRenderOutputHdr | RGBA16F 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
ShaderSurfacetoGpuView
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 macro — shader!("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:
- 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 isREPLACEon SDR surfaces and disabled on HDR ones. - Render rewrites the uniform buffer with the latest time and resolution, clears to transparent, and draws the six-vertex quad.
- Every frame requests the next one.
ShaderSurfaceis unconditionally animated — there is no static mode. If your effect does not useuniforms.time, you are paying for frames you do not need; write aGpuViewthat only requests redraws when something changes. - 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(), andsmoothstep()beforeif. - WGSL floats are 32-bit. For pixel-precise work, multiply
uvbyuniforms.resolutionrather 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
EffectandEffectRenderertraits- 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:
| Method | Parameters | Description |
|---|---|---|
.brightness(amount) | 1 | 0.0 leaves the image unchanged; positive brightens |
.exposure(ev) | 1 | Exposure in photographic stops |
.contrast(amount) | 1 | 1.0 unchanged, above 1.0 increases contrast |
.gamma(gamma) | 1 | Gamma adjustment |
.saturation(amount) | 1 | 1.0 unchanged, 0.0 fully desaturated |
.vibrance(amount) | 1 | Saturates muted colors more than saturated ones |
.grayscale(intensity) | 1 | 0.0 full color, 1.0 fully gray |
.sepia(intensity) | 1 | 0.0 no effect, 1.0 full sepia |
.hue_rotation(angle) | 1 | Rotate hue, in radians |
.invert() | 0 | Invert all channels |
.temperature_tint(temp, tint) | 2 | White-balance adjustment |
.highlights_shadows(hi, lo) | 2 | Recover highlights, lift shadows |
.color_matrix(m) | [[f32; 4]; 3] | Arbitrary 3×4 color transform |
.white_point(r, g, b) | 3 | Color balance from an explicit white point |
.vignette(radius, softness) | 2 | Darken toward the edges |
Spatial filters sample neighboring pixels and therefore need a pass of their own:
| Method | Parameters | Description |
|---|---|---|
.blur(radius) | 1 | Blur with the given pixel radius |
.gaussian_blur(sigma) | 1 | Gaussian blur by standard deviation |
.motion_blur(radius, angle) | 2 | Directional blur |
.zoom_blur(amount, x, y) | 3 | Radial blur around a focal point |
.sharpen(amount) | 1 | Edge sharpening |
.unsharp_mask(radius, amount) | 2 | Classic unsharp mask |
.bloom(radius, intensity, threshold) | 3 | Glow around bright regions |
.sobel() / .prewitt() | 0 | 3×3 edge detection |
.median3x3() | 0 | Salt-and-pepper denoise |
.convolution3x3(kernel) / .convolution5x5(kernel) | kernel array | Arbitrary convolution |
.pixellate(size) / .crystallize(size) | 1 | Mosaic effects |
.kaleidoscope(...) / .mirror_tile(...) | 4 / 2 | Tiling and reflection |
.bump_distortion(...), .pinch_distortion(...), .twirl_distortion(...), .vortex_distortion(...) | 4 each | Geometric 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_renderis the required method, notrender. You write your commands into a caller-supplied encoder so a host rendering several effects in one frame can submit once.renderhas a default implementation that creates an encoder, callsencode_render, and submits.- The result types carry meaning.
EffectSetupResultisResult<(), &'static str>;EffectRenderResultisResult<bool, &'static str>, where theboolanswers “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
particlefeature. Addwaterui = { version = "...", features = ["particle"] }toCargo.toml— thewaterui::particlemodule 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
| Modifier | Meaning |
|---|---|
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
| Modifier | Meaning |
|---|---|
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.
| Modifier | Meaning |
|---|---|
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
| Modifier | Meaning |
|---|---|
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, interactionradius/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 newParticleSystem.
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
AnimatedMeshGradientandFlowingGradient
WaterUI ships two gradient layers with overlapping names, and picking the wrong one is the most common way to get stuck.
waterui::gradientholds semantic gradient descriptions:LinearGradient,RadialGradient,AngularGradient,MeshGradient,ColorStop,MeshVertex,UnitPoint. They carry reactiveComputed<Color>stops, and the unifiedGradientenum wraps any of them. At the pinned revision none of these types implementView, so they cannot be handed to.background(...)or placed in a stack.waterui::graphicsholds the GPU views:Gradient(backed byGradientConfig), the reactiveMeshGradient<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.AnimatedMeshGradientrequests a redraw every frame whilespeed > 0.0, andFlowingGradientis built onShaderSurface, 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
Animatablefor 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. Importingwaterui::animation::AnimationExtadditionally 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:
| Constructor | Control points | Behavior |
|---|---|---|
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 aGestureObserver) 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 type | Fields |
|---|---|
TapEvent | location: GesturePoint, count: u32 |
LongPressEvent | location: GesturePoint, duration: f32 |
DragEvent | phase, location, translation, velocity |
MagnificationEvent | phase, 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:
| Modifier | Equivalent |
|---|---|
.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
SuspendedViewto reach the environment during loading- Choose between
SuspenseandViewExt::taskbased 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
ResultandOptiondirectly from view code- Wrap any
std::error::Erroras a view withError- 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::error—Error,DefaultErrorView,UseDefaultErrorView,ResultExt. This is the one you configure once and use everywhere.waterui::error— a smallerErrorView/ErrorViewBuilderpair 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:
| Category | Roles |
|---|---|
| Interactive | Button, Link, Checkbox, RadioButton, Switch, Slider |
| Content | Text, Image, Header, Footer, Article |
| Structure | Navigation, Main, Search, Section, Group |
| Collections | List, ListItem, Tab, TabList, TabPanel |
| Menus | Menu, MenuItem, MenuBar, MenuItemCheckbox, MenuItemRadio |
| Forms | Combobox, 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);
| Field | Meaning |
|---|---|
disabled | Visible but not interactive |
selected | The current selection within its group |
checked | Some(true), Some(false), or None for mixed |
expanded | Some(true) / Some(false) for disclosure controls |
busy | Loading or processing |
hidden | Not 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)setshiddenon 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:
- a
Binding<Locale>installed in the environment, - a
RegionalContextin the environment, - a plain
Localein the environment, - 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
Environmentwith thePlugintrait- 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, notThemePlugin. - Make installed types extractors.
impl_extractor!turnsuse_env(|svc: MyService| ...)and typed handler parameters on, and keepsenv.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::Accentbecomes a reactive signal- Implement
Resolvablefor your own token and install its signal- Erase and transform resolvables with
AnyResolvable<T>andMap- 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, andMaplive inwaterui-coreand are not re-exported through thewateruifacade. A crate that implements its own tokens needswaterui-core = "0.2"as a direct dependency. Everything else in this chapter is reachable throughwaterui.
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:
- A native backend can inject a
Computed<ResolvedColor>that tracks the system appearance. - Every view that read the token subscribes to that signal and updates on its own.
- 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::install — Theme 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 withwater preview- Set up the
devfeature 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 testandwater 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:
| Path | Command | What it renders |
|---|---|---|
| Native support app | water preview <fn> --platform macos|ios|android | Your view through the real Apple or Android backend, via a long-lived support app that loads your code as a dylib |
| Hydrolysis direct | water preview <fn> --backend hydrolysis --theme material3 | Your 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_cardpreviewed withname = ""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 name | Function | Export symbol |
|---|---|---|
my_app | sidebar | waterui_preview_my_app_sidebar |
together-app | dashboard::admin::card | waterui_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 / flag | Description | Default |
|---|---|---|
target | #[preview] function name, or an expression with --expr | required |
--expr | Treat the target as a Rust expression returning impl View | off |
--platform, -p | ios, macos, or android | host native (macOS) |
--backend | apple, android, or hydrolysis | per-platform |
--theme | material3 — Hydrolysis only, and required there | none |
--frame, -f | Render size as WIDTHxHEIGHT | 375x667 |
--output, -o | Output PNG path | preview.png |
--scenario | Hydrolysis scenario TOML for interaction capture | none |
--output-dir | Directory for scenario frames (required with --scenario) | none |
--path | Project 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.
| Variable | Description | Default |
|---|---|---|
WATERUI_PREVIEW_HOST | TCP bind/connect address | 127.0.0.1 |
WATERUI_PREVIEW_PORT_START | First port to try | 2106 |
WATERUI_PREVIEW_PORT_RANGE | Number of consecutive ports to scan | 50 |
WATERUI_PREVIEW_DYLIB_CACHE_SIZE | Max in-memory dylib cache entries | 8 |
WATERUI_PREVIEW_MAX_FRAME_BYTES | Max TCP frame size (bytes) | 128 MiB |
WATERUI_PREVIEW_CONNECT_TIMEOUT_MS | TCP connect timeout | 100 |
WATERUI_PREVIEW_HANDSHAKE_TIMEOUT_MS | Ping/Pong handshake timeout | 500 |
WATERUI_PREVIEW_REQUEST_TIMEOUT_MS | General request timeout | 20000 |
WATERUI_PREVIEW_RENDER_TIMEOUT_MS | Render request timeout | 120000 |
WATERUI_PREVIEW_IDLE_SHUTDOWN_SECS | Support app idle shutdown | 900 |
WATER_CACHE_DIR | Root for the preview instance registry | OS 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 appversus a fresh launch in the CLI logs (--logs debug). A fresh launch means something invalidated the running app — usually awaterui_pathchange 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_pathcheckout, 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:
| Backend | Crate / directory | Consumes the tree via | Draws with |
|---|---|---|---|
| Apple | backends/apple (submodule) | C ABI | UIKit / AppKit |
| Android | backends/android (submodule) | JNI | Android Views |
| GTK | backends/gtk (waterui-gtk) | ViewDispatcher | GTK4 widgets |
| Hydrolysis | backends/hydrolysis | ViewDispatcher | Vello on wgpu, GPU-required |
| Dew | backends/dew | ViewDispatcher | vello_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:
NativeView— marks the type as a leaf, and declares its stretch axis.View— implementsbody()to returnNative::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:
- Call
waterui_view_id(view)for the 128-bit type ID. - Look it up in the handler table.
- Handler found (raw view or metadata) — call
waterui_force_as_*, create or update the platform widget, and for metadata also render thecontentchild. - 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=1to 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:
| Value | Behavior | Example |
|---|---|---|
None | Content-sized, uses intrinsic dimensions | Text, Image |
Horizontal | Expands width, intrinsic height | TextField, Slider |
Vertical | Intrinsic width, expands height | (rare) |
Both | Greedy, fills all available space | Color, GpuSurface |
MainAxis | Expands along the parent stack’s main axis | Spacer |
CrossAxis | Expands along the parent stack’s cross axis | Divider |
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, soOption<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:
- Installs a panic hook that forwards panics to
tracing. - Initializes platform logging —
tracing-oslogunder subsystemdev.wateruion Apple,tracing-androidwith tagWaterUIon Android,tracing-subscriberfmtelsewhere. Each honorsRUST_LOG, with a quiet default that silenceswgpu,naga, and JNI noise. - Initializes the global async executor.
- 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.
- Creates an
Environmentand installs the app’s compile-time translation catalog into it (configure_environment!). - 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. - 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. Yourapp()may referencetheme::color::Foregroundor.body()the moment it runs, so install theme signals beforewaterui_app(). (Useinstalled_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:
| Slot | Value | Purpose |
|---|---|---|
Background | 0 | Primary background |
Surface | 1 | Elevated surfaces (cards, sheets) |
SurfaceVariant | 2 | Alternate surfaces |
Border | 3 | Borders and dividers |
Foreground | 4 | Primary text and icons |
MutedForeground | 5 | Secondary/dimmed text |
Accent | 6 | Interactive element highlights |
AccentForeground | 7 | Text on accent backgrounds |
AccentContainer | 8 | Container tied to the accent |
Tertiary | 9 | Contrasting accent for complementary emphasis |
TertiaryContainer | 10 | Container 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:
| Slot | Value | Purpose |
|---|---|---|
Body | 0 | Body text |
Title | 1 | Titles |
Headline | 2 | Headlines |
Subheadline | 3 | Subheadlines |
Caption | 4 | Captions |
Footnote | 5 | Footnotes |
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:
- Define the view with
raw_view!orconfigurable!in its component crate. - Define a
#[repr(C)]FFI struct (WuiMyView) underffi/src/components/. - Implement
IntoFFIfor the config, usually viainto_ffi!. - Call
ffi_view!(MyViewConfig, WuiMyView, my_view). - Regenerate the C header.
- 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
ProposalSizelets parents and children negotiate dimensions- See how
StretchAxiscontrols 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>,
}
| Value | Meaning |
|---|---|
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. Callingmeasurefive times with five proposals is legal and expected. SubViewisSend + 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:
- Separate children into fixed (non-stretchy) and flexible (stretchy) groups.
- Propose the available size to each fixed child and collect their measurements.
- Compute the space remaining after fixed children and spacing.
- Distribute the remainder among flexible children as equal shares.
- 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_fitsfor the bounding box andplaceto 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
| Type | Fields | Description |
|---|---|---|
Point | x: f32, y: f32 | Position relative to parent origin |
Size | width: f32, height: f32 | Two-dimensional extent |
Rect | origin: Point, size: Size | Positioned rectangle |
ProposalSize | width: 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:
- Call
waterui_init()to initialize the Rust runtime and get anEnvironment. - Install theme signals (color scheme, colors, fonts) into that environment.
- Call
waterui_app(env)to obtain the application’s window tree. - Walk the tree, dispatching each node by its
WuiTypeId. - Subscribe to reactive signals and update widgets when values change.
- 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 view | iOS | macOS |
|---|---|---|
Text | UILabel | NSTextField (label mode) |
Button | UIButton | NSButton |
Toggle | UISwitch | NSSwitch |
TextField | UITextField | NSTextField |
ScrollView | UIScrollView | NSScrollView |
NavigationStack | UINavigationController | custom NSView stack with NSWindow toolbar accessories |
GpuSurface | CAMetalLayer | CAMetalLayer |
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.
| Hydrolysis | Dew | |
|---|---|---|
| Target | High-end desktop, mobile, web | MCU-class embedded (ESP32-S3, ESP32-C3) |
| Rasterization | GPU-required (vello on wgpu) | CPU-first (vello_cpu sparse strips), GPU optional |
| Frame strategy | Full-scene redraw, game-engine style | Dirty regions only, sliced into bands |
| Peak pixel memory | Full frame | One band |
| Frame rate | High refresh, explicitly requested | 30/60fps, power-frugal |
| Dependency graph | Modern GPU + multi-core CPU | Lean, 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
wgpuadapters; theWATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTERenvironment 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
GpuViewperGpuSurface.GpuSurface::new(renderer)owns thatGpuViewfor the surface’s lifetime, and persistent GPU resources belong inGpuView::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:
- Look up the view’s
TypeIdin the handler table. - If a handler is registered, run it — the view stays on the stack, no allocation.
- 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
- Create the crate in
backends/your-backend/. - Depend on
waterui-backend-coreforViewDispatcherand the shared interaction types. - Register handlers for each native view type you support:
dispatcher.register::<Native<TextConfig>>(|state, ctx, view, env| { // Create your platform's text widget }); - Handle metadata the same way:
dispatcher.register::<Metadata<Opacity>>(|state, ctx, meta, env| { // Apply opacity, then render meta.content }); - Implement the lifecycle: window creation, event loop, signal-driven updates.
- 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
| Backend | Path | Notes |
|---|---|---|
| Apple | backends/apple/ | Submodule. UIKit/AppKit bridge; reference implementation. |
| Android | backends/android/ | Submodule. Android View bridge over JNI. |
| GTK4 | backends/gtk/ | Linux bridge; also hosts the WebKitGTK/WPE/CEF web views. |
| Hydrolysis | backends/hydrolysis/ | GPU self-drawn renderer; drives waterui-testing. |
| Hydrolysis-M3 | backends/hydrolysis_m3/ | Material 3 skin for Hydrolysis. |
| Dew | backends/dew/ | CPU self-drawn renderer for MCU-class targets. |
| Backend core | backends/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!andraw_view!to define hookable and leaf views- Apply the
Type::new/ free-function constructor split WaterUI uses everywhere- Accept
IntoText,IntoLabel,IntoSignal<T>, andIntoComputed<T>in your APIs- Pass context through the
Environmentand thePlugintrait- 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
- Bridge native first. Use the platform primitive where one exists; go to the self-drawn realization only where none does.
- Style is an attribute. Add a variant to an existing semantic component rather than a parallel type.
- Keep the FFI surface minimal. Compose in Rust before adding a C-ABI type.
- Type safety over runtime checks. Encode invariants in the type system; prefer generics and traits over enums and type erasure.
- Never degrade a public trait for object safety. Expose the friendliest signature and erase types privately behind a shim.
- No global state. Pass context through
Environment, never through statics or singletons. - Fail fast. Panic with a clear message instead of falling back.
- 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
waterCLI 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
| Variable | Purpose |
|---|---|
RUST_LOG | Tracing filter for the runtime (e.g. debug) |
WATERUI_DISPATCH_DEBUG | Logs the view dispatch tree in any backend using ViewDispatcher |
WATERUI_TEST_ARTIFACTS_DIR | Root directory for waterui-testing snapshot artifacts |
WATERUI_HYDROLYSIS_RENDER_DIAG | Per-frame render diagnostics from Hydrolysis |
WATER_HYDROLYSIS_FORCE_FALLBACK_ADAPTER | Allows 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 doctorto 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.
- Confirm the file exists in the project’s
assets/directory. - Check the filename case — mobile platforms are case-sensitive.
- The CLI bundles
assets/automatically; if you compose a customBundle, 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
- Zero size: the view has no intrinsic size and no frame constraint. Add
.size(width, height)or make sure the parent offers space. - Hidden: check for
.opacity(0.0)or a transparent background. - 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.
-
Do not call
.get()in a view body. It reads once and never subscribes. Usetext!for reactive text, or a combinator for other values:// Wrong: reads once, no reactivity text(format!("Count: {}", count.get())) // Right text!("Count: {count}") -
Prefer signal-taking APIs. Passing a
Bindingto.spacing(...),.blur(...), or.disabled(...)updates that one property. Wrapping the subtree inwatch(...)rebuilds it and throws away any state it owned. -
Use a collection for a dynamic set of views.
ForEach/Listover a reactive collection diffs by id;watchover aVecrebuilds everything. -
Mutate in place.
*count.get_mut() += 1is the idiom;count.set(count.get() + 1)reads outside a subscription. -
Binding lifetime: if the binding is dropped, its watchers disconnect. Keep it alive at the level that owns the state.
-
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
- Search the GitHub issues.
- Include
water doctoroutput in the report. - Include the full error and relevant logs.
- State your OS,
rustc --version, and platform SDK versions. - Provide a minimal reproduction if you can.