WaterUI Tutorial Book
Welcome to the complete guide for building cross-platform applications with WaterUI! This book will take you from a complete beginner to an advanced WaterUI developer, capable of building sophisticated applications that run on desktop, web, mobile, and embedded platforms.
What is WaterUI?
WaterUI is a modern, declarative UI framework for Rust that enables you to build applications using a single codebase for multiple platforms. It combines the safety and performance of Rust with an intuitive, reactive programming model inspired by SwiftUI and React.
Key Features
- 🚀 Cross-Platform: Learn once, apply anywhere - iOS, Android, macOS.
- 🦀 Type-Safe: Leverage Rust’s powerful type system for compile-time correctness
- ⚡ Reactive: Automatic UI updates when data changes
- 📝 Declarative: Describe what your UI should look like, not how to build it
Meet the water CLI
Every chapter assumes you have the WaterUI CLI installed so you can scaffold, build, and package projects without leaving the terminal.
cargo install --path cli --locked
From there you can bootstrap a playground app and run it on any configured backend:
water create --name "Water Demo" \
--bundle-identifier com.example.waterdemo \
--backend swiftui --backend android --backend web --yes
water run --platform web --project water-demo
water package --platform android --project water-demo
Use water doctor --fix whenever you need to validate the local toolchain, and water devices --json to pick a simulator/emulator when scripting. The CLI mirrors the repository layout you are about to explore, so the hands-on examples in each chapter directly match real projects.
Framework Layout
The WaterUI workspace is a set of focused crates:
waterui-core: theViewtrait,Environment, resolver system, and plugin hooks.waterui/components/*: reusable primitives for layout, text, navigation, media, and form controls.nami: the fine-grained reactive runtime that powers bindings, signals, and watchers.waterui-cli: the developer workflow described above.
This book mirrors that structure—learn the core abstractions first, then layer components, and finally explore advanced topics such as plugins, animation, and async data pipelines.
Workspace Crates (excluding backends)
| Crate | Path | Highlights |
|---|---|---|
waterui | waterui/ | Facade crate that re-exports the rest of the stack plus hot reload, tasks, and metadata helpers. |
waterui-core | waterui/core | View, Environment, resolver system, plugins, hooks, and low-level layout traits. |
waterui-controls | waterui/components/controls | Buttons, toggles, sliders, steppers, text fields, and shared input handlers. |
waterui-layout | waterui/components/layout | Stacks, frames, grids, scroll containers, padding, and alignment primitives. |
waterui-text | waterui/components/text | The Text view, typography helpers, and localization-ready formatting APIs. |
waterui-media | waterui/components/media | Photo/video/Live Photo renderers plus media pickers. |
waterui-navigation | waterui/components/navigation | Navigation bars, stacks, programmatic paths, and tab containers. |
waterui-form | waterui/components/form | FormBuilder derive macro, color pickers, secure fields, and validation helpers. |
waterui-graphics | waterui/components/graphics | Experimental drawing primitives and utilities that feed the canvas/shader chapters. |
waterui-render-utils | waterui/render_utils | Shared GPU/device glue used by multiple backends and native wrappers. |
waterui-macros | waterui/derive | Proc-macros (FormBuilder, View helpers) consumed by the higher-level crates. |
waterui-cli | waterui/cli | The water binary you installed earlier for scaffolding, running, and packaging apps. |
waterui-ffi | waterui/ffi | FFI bridge used by native runners (Swift, Kotlin, C) plus hot reload integration. |
waterui-color, waterui-str, waterui-url | waterui/utils/{color,str,url} | Utility crates for colors, rope strings, and URL handling shared by every component. |
window | waterui/window | Cross-platform window/bootstrapper that spins up render loops for each backend. |
demo | waterui/demo | Showcase app exercising all components—great for cross-referencing when you read later chapters. |
Outside waterui/ you will also find the nami/ workspace, which hosts the reactive runtime along with its derive macros and examples. Treat nami as part of the core mental model because every binding, watcher, and computed signal ultimately comes from there.
Prerequisites
Before starting this book, you should have:
- Basic Rust Knowledge: Understanding of ownership, borrowing, traits, and generics
- Programming Experience: Familiarity with basic programming concepts
- Command Line Comfort: Ability to use terminal/command prompt
If you’re new to Rust, we recommend reading The Rust Programming Language first.
How to Use This Book
- Clone the repository and run
mdbook serveso you can edit and preview chapters locally. - Explore the source under
waterui/whenever you want to dig deeper into a crate. - Use the CLI at the start of each part to scaffold a sandbox project for experimentation.
Roadmap
WaterUI is evolving quickly. Track milestones and open issues at waterui.dev/roadmap.
Contributing
This book is open source! Found a typo, unclear explanation, or want to add content?
- Source Code: Available on GitHub
- Issues: Report problems or suggestions
- Pull Requests: Submit improvements
WaterUI CLI Reference
The water CLI is your primary tool for managing WaterUI projects. It handles scaffolding, running, and packaging your applications across different platforms.
Installation
Ensure you have the CLI installed (see Setup):
cargo install --path cli --locked
Project Modes
WaterUI supports two project modes:
1. Playground Mode (Recommended for Learning)
water create "My Experiment" --playground
- Best for: Prototyping, learning, and simple apps.
- Structure: Hides native projects in
.water/. - Experience: Zero-config. Just run
water runand go.
2. Full Project
water create "My App" --bundle-id com.example.app --platform ios,android
- Best for: Production apps needing custom native code (Info.plist, AndroidManifest.xml).
- Structure: Generates explicit
apple/andandroid/folders. - Experience: Full control over the native build process.
Commands
create
Scaffolds a new project.
# Playground
water create "Demo" --playground
# Production
water create "Production App" --bundle-id com.org.app --platform ios
run
Builds and runs the application on a connected device or simulator.
# Auto-detect device
water run
# Target specific platform
water run --platform ios
water run --platform android
# Target specific device
water run --device "iPhone 15"
Hot Reload is enabled by default. Save your .rs files to see changes instantly.
package
Produces distributable artifacts (IPA, APK/AAB).
water package --platform ios --release
water package --platform android --release --arch arm64
doctor
Checks your development environment for missing dependencies.
water doctor
# Attempt to fix issues automatically
water doctor --fix
devices
Lists all detected simulators and physical devices.
water devices
Installation and Setup
Before we dive into building applications with WaterUI, let’s set up a proper development environment. This chapter will guide you through installing Rust, setting up your editor, and creating your first WaterUI project.
Installing Rust
WaterUI requires Rust 1.87 or later with the 2024 edition. The easiest way to install Rust is through rustup.
On macOS, Linux, or WSL
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
On Windows
- Download the installer from rustup.rs
- Run the downloaded
.exefile - Follow the installation prompts
- Restart your command prompt or PowerShell
Verify Installation
After installation, verify that everything works:
rustc --version
cargo --version
You should see output like:
rustc 1.87.0 (a28077b28 2024-02-28)
cargo 1.87.0 (1e91b550c 2024-02-27)
Note: WaterUI requires Rust 1.87 or later. If you have an older version, update with
rustup update.
Editor Setup
While you can use any text editor, we recommend VS Code for the best WaterUI development experience.
Visual Studio Code (Recommended)
-
Install VS Code: Download from code.visualstudio.com
-
Install Essential Extensions:
- rust-analyzer: Provides IntelliSense, error checking, and code completion
- CodeLLDB: Debugger for Rust applications
- Better TOML: Syntax highlighting for Cargo.toml files
-
Optional Extensions:
- Error Lens: Inline error messages
- Bracket Pair Colorizer: Colorizes matching brackets
- GitLens: Enhanced Git integration
Other Popular Editors
IntelliJ IDEA / CLion:
- Install the “Rust” plugin
- Excellent for complex projects and debugging
Vim / Neovim:
- Use
rust.vimfor syntax highlighting - Use
coc-rust-analyzerfor LSP support
Emacs:
- Use
rust-modefor syntax highlighting - Use
lsp-modewith rust-analyzer
Installing the WaterUI CLI
All examples in this book assume you have the water CLI available. From the repository root run:
cargo install --path cli --locked
water --version
The first command installs the current checkout; the second verifies that the binary is on your PATH.
Verify Your Toolchain
Run the built-in doctor before continuing:
water doctor --fix
This checks your Rust toolchain plus any configured Apple and Android dependencies. Repeat it whenever you change machines or SDK versions. To discover connected simulators/emulators (useful for later chapters), run:
water devices
Creating Your First Project
We will let the CLI scaffold a Playground project. This is the fastest way to get started, as it manages platform-specific native code for you automatically.
water create "Hello WaterUI" --playground
cd hello-waterui
The generated project is minimal:
hello-waterui/
├── Cargo.toml # crate manifest
├── Water.toml # WaterUI-specific metadata
├── src/lib.rs # starting point for your app views
└── .water/ # Managed native projects (hidden)
Hello, World!
Open src/lib.rs inside the newly created project and replace the body with a tiny view:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn home() -> impl View {
text("Hello, WaterUI! 🌊")
}
}
Building and Running
Use the CLI to run your app. It will detect available simulators or devices:
water run
Once the dev server starts, every change you save in src/lib.rs hot-reloads instantly.
If you prefer to run the Rust crate alone (useful for unit tests), you can still execute cargo test in parallel.
Troubleshooting Common Issues
Rust Version Too Old
Error: error: package requires Rust version 1.87
Solution: Update Rust:
rustup update
Windows Build Issues
Error: Various Windows compilation errors
Solutions:
- Ensure you have the Microsoft C++ Build Tools installed
- Use the
x86_64-pc-windows-msvctoolchain - Consider using WSL2 for a Linux-like environment
Your First WaterUI App
Now that your development environment is set up, let’s build your first interactive WaterUI application! We’ll create a counter app that demonstrates the core concepts of views, state management, and user interaction.
What We’ll Build
Our counter app will feature:
- A display showing the current count
- Buttons to increment and decrement the counter
- Dynamic styling based on the counter value
By the end of this chapter, you’ll understand:
- How to create interactive views
- How to manage reactive state
- How to handle user events
- How to compose views together
Setting Up the Project
If you completed the setup chapter you already have a CLI-generated workspace. Otherwise scaffold one now using playground mode:
water create "Counter App" --playground
cd counter-app
We will edit src/lib.rs to build our application. The playground mode handles all the native configuration for us.
Building the Counter Step by Step
Let’s build our counter app incrementally, learning WaterUI concepts along the way.
Step 1: Basic Structure
Start with a simple view structure. Since our initial view doesn’t need state, we can use a function:
Filename: src/lib.rs
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn counter() -> impl View {
text("Counter App")
}
}
Run this to make sure everything works:
water run
You should see a window with “Counter App” displayed.
Step 2: Adding Layout
Now let’s add some layout structure using stacks:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn counter() -> impl View {
vstack((
text("Counter App"),
text("Count: 0"),
))
}
}
Note:
vstackcreates a vertical stack of views. We’ll learn abouthstack(horizontal) andzstack(overlay) later.
Step 3: Adding Reactive State
Now comes the exciting part - let’s add reactive state! We’ll use the binding helper from waterui::prelude and the text! macro for reactive text:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn counter() -> impl View {
let count: Binding<i32> = binding(0);
vstack((
text("Counter App"),
text!("Count: {count}"),
hstack((
button("- Decrement").action_with(&count, |state: Binding<i32>| {
state.set(state.get() - 1);
}),
button("+ Increment").action_with(&count, |state: Binding<i32>| {
state.set(state.get() + 1);
}),
)),
))
}
}
water run will hot reload changes—save the file and keep the terminal open to see updates instantly.
Understanding the Code
Let’s break down the key concepts introduced:
Reactive State with binding
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn make_counter() -> Binding<i32> {
binding(0)
}
}
This creates a reactive binding with an initial value of 0. When this value changes, any UI elements that depend on it will automatically update.
Reactive Text Display
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn reactive_label() -> impl View {
let count: Binding<i32> = binding(0);
text!("Count: {count}")
}
}
- The
text!macro automatically handles reactivity - The text will update whenever
countchanges
Event Handling
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn decrement_button() -> impl View {
let count: Binding<i32> = binding(0);
button("- Decrement").action_with(&count, |count: Binding<i32>| {
count.set(count.get() - 1);
})
}
}
.action_with()attaches an event handler with captured state.Binding<T>implementsCloneefficiently (it’s a reference-counted handle), so you can pass it around.
Layout with Stacks
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn stack_examples() -> impl View {
vstack((
text("First"),
hstack((text("Left"), text("Right"))),
))
}
}
Stacks are the primary layout tools in WaterUI, allowing you to arrange views vertically or horizontally.
Understanding Views
The View system is the heart of WaterUI. Everything you see on screen is a View, and understanding how Views work is crucial for building efficient and maintainable applications. In this chapter, we’ll explore the View trait in depth and learn how to create custom components.
What is a View?
A View in WaterUI represents a piece of user interface. It could be as simple as a text label or as complex as an entire application screen. The beauty of the View system is that simple and complex views work exactly the same way.
The View Trait
Every View implements a single trait:
#![allow(unused)]
fn main() {
use waterui::env::Environment;
pub trait View: 'static {
fn body(self, env: &Environment) -> impl View;
}
}
This simple signature enables powerful composition patterns. Let’s understand each part:
'staticlifetime: Views can’t contain non-static references, ensuring they can be stored and moved safelyselfparameter: Views consume themselves when building their body, enabling zero-cost movesenv: &Environment: Provides access to shared configuration and dependencies-> impl View: Returns any type that implements View, enabling flexible composition
Building Views from Anything
The trait is deliberately broad. Anything that implements View (including functions and closures) can return any other View in its body. Two helper traits make this ergonomic:
IntoView: implemented for everyViewplus tuples, sovstack(("A", "B"))works without wrapping strings manually.TupleViews: converts tuples/arrays intoVec<AnyView>so layout containers can iterate over heterogeneous children.
This is why simple function components are the preferred way to build UI—fn header() -> impl View automatically conforms to the trait.
Built-in Views
WaterUI provides many built-in Views for common UI elements:
Text Views
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::stack::vstack;
use waterui::reactive::binding;
pub fn text_examples() -> impl View {
let name: Binding<String> = binding("Alice".to_string());
vstack((
// Static text
"Hello, World!",
// Reactive text
text!("Hello, {name}!"),
// Styled text
text("Important!").size(24.0),
))
}
}
Control Views
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::layout::stack::vstack;
pub fn control_examples() -> impl View {
let enabled = binding(false);
vstack((
button("Click me").action(|| println!("Clicked!")),
toggle(text("Enable notifications"), &enabled),
))
}
}
Layout Views
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::stack::{vstack, hstack, zstack};
pub fn layout_examples() -> impl View {
vstack((
vstack(("First", "Second", "Third")),
hstack((button("Cancel"), button("OK"))),
zstack((text("Base"), text("Overlay"))),
))
}
}
Creating Custom Views
The real power of WaterUI comes from creating your own custom Views. Let’s explore different patterns:
Function Views (Recommended)
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn welcome_message(name: &str) -> impl View {
vstack((
text("Welcome!").size(24.0),
text(format!("Hello, {}!", name)),
))
}
let lazy_view = || welcome_message("Bob");
}
Functions automatically satisfy View, so prefer them for stateless composition or whenever you can lean on existing bindings (as we did in examples::counter_view inside this book’s crate).
Struct Views (For Components with State)
Only reach for a custom struct when the component needs to carry configuration while building its child tree or interact with the Environment directly:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
pub struct CounterWidget {
pub initial_value: i32,
pub step: i32,
}
impl View for CounterWidget {
fn body(self, _env: &Environment) -> impl View {
let count = binding(self.initial_value);
vstack((
text!("Count: {count}"),
button("+").action_with(&count, move |state: Binding<i32>| {
state.set(state.get() + self.step);
}),
))
}
}
}
Type Erasure with AnyView
When you need to store different view types in the same collection (navigation stacks, list diffing, etc.), use AnyView:
#![allow(unused)]
fn main() {
use waterui::AnyView;
fn welcome_message(name: &str) -> &'static str { "hi" }
let screens: Vec<AnyView> = vec![
AnyView::new(welcome_message("Alice")),
AnyView::new(welcome_message("Bob")),
];
}
AnyView erases the concrete type but keeps behaviour intact, letting routers or layout engines manipulate heterogeneous children uniformly.
Configurable Views and Hooks
Many built-in controls implement ConfigurableView, exposing a configuration struct that can be modified globally through hooks:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::env::Environment;
use waterui::AnyView;
use waterui::component::button::ButtonConfig;
use waterui::layout::stack::hstack;
use waterui::text::Text;
use waterui::view::ViewConfiguration;
pub fn install_button_theme(env: &mut Environment) {
env.insert_hook(|_, mut config: ButtonConfig| {
config.label = AnyView::new(hstack((
text("🌊"),
config.label,
)));
config.render()
});
}
}
Hooks intercept ViewConfiguration types before renderers see them, enabling cross-cutting features like theming, logging, and accessibility instrumentation. Plugins install these hooks automatically, so understanding ConfigurableView prepares you for the advanced chapters on styling and resolver-driven behaviour.
The Environment System
WaterUI’s Environment is a type-indexed map that flows through your entire view hierarchy. It lets you pass themes, services, and configuration data without manually threading parameters through every function.
Seeding the Environment
Create an environment at the root of your app and attach values with .with (for owned values), .store (for namespaced keys), or .install (for plugins):
#![allow(unused)]
fn main() {
use waterui::env::Environment;
use waterui::prelude::*;
use waterui::Color;
#[derive(Clone)]
pub struct AppConfig {
pub api_url: String,
pub timeout_seconds: u64,
}
#[derive(Clone)]
pub struct Theme {
pub primary_color: Color,
pub background_color: Color,
}
fn home() -> &'static str { "Home" }
pub fn entry() -> impl View {
let env = Environment::new()
.with(AppConfig {
api_url: "https://api.example.com".into(),
timeout_seconds: 30,
})
.with(Theme {
primary_color: Color::srgb_f32(0.0, 0.4, 1.0),
background_color: Color::srgb_f32(1.0, 1.0, 1.0),
});
home().with_env(env)
}
}
ViewExt::with_env(available throughprelude::*) applies the environment to the subtree. You can wrap entire navigators, specific screens, or even single widgets this way.
Reading Environment Values
Struct Views
Views that implement View directly receive &Environment in their body method:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::env::Environment;
#[derive(Clone)]
struct AppConfig { api_url: String, timeout_seconds: u64 }
#[derive(Clone)]
struct Theme {
primary_color: waterui::Color,
background_color: waterui::Color,
}
struct ApiStatusView;
impl View for ApiStatusView {
fn body(self, env: &Environment) -> impl View {
let config = env.get::<AppConfig>().expect("AppConfig provided");
let theme = env.get::<Theme>().expect("Theme provided");
vstack((
text(config.api_url.clone())
.foreground(theme.primary_color.clone()),
text(format!("Timeout: {}s", config.timeout_seconds))
.size(14.0),
))
.background(waterui::background::Background::color(
theme.background_color.clone(),
))
}
}
}
Function Views with use_env
Functions (which already implement View) can still access the environment by wrapping their content in use_env:
#![allow(unused)]
fn main() {
use waterui::env::{use_env, Environment};
use waterui::prelude::*;
use waterui::Color;
#[derive(Clone)]
struct Theme { primary_color: Color }
pub fn themed_button(label: &'static str) -> impl View {
use_env(move |env: Environment| {
let theme = env.get::<Theme>().unwrap();
button(label).background(theme.primary_color.clone())
})
}
}
Event Handlers (action_with)
Handlers can extract typed values with waterui::core::extract::Use<T>:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::Binding;
use waterui_core::extract::Use;
use waterui::reactive::binding;
#[derive(Clone)]
pub struct Message(&'static str);
pub fn click_me() -> impl View {
let value: Binding<String> = binding(String::new());
vstack((
button("Read message")
.action_with(&value, |binding: Binding<String>, Use(Message(text)): Use<Message>| {
binding.set(text.to_string());
}),
text!("{value}"),
))
.with(Message("I'm Lexo"))
}
}
Namespaced Keys with Store
If the same type needs to appear multiple times (e.g., two independent Theme structs), wrap them in Store<K, V> so the key includes a phantom type:
#![allow(unused)]
fn main() {
use waterui::env::{Environment, Store};
use waterui::Color;
#[derive(Clone)]
struct Theme { primary_color: Color, background_color: Color }
pub struct AdminTheme;
pub struct UserTheme;
pub fn install_themes() -> Environment {
Environment::new()
.store::<AdminTheme, _>(Theme {
primary_color: Color::srgb(230, 50, 50),
background_color: Color::srgb(0, 0, 0),
})
.store::<UserTheme, _>(Theme {
primary_color: Color::srgb(0, 102, 255),
background_color: Color::srgb(255, 255, 255),
})
}
pub fn maybe_admin_theme(env: &Environment) -> Option<&Theme> {
env.query::<AdminTheme, Theme>()
}
}
Plugins and Hooks
Plugins encapsulate environment setup. Implement Plugin and call .install to register hooks, services, or other values:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui_core::plugin::Plugin;
use waterui::component::button::ButtonConfig;
use waterui::AnyView;
use waterui::layout::stack::hstack;
use waterui_text::Text;
use waterui::view::ViewConfiguration;
struct ThemePlugin;
impl Plugin for ThemePlugin {
fn install(self, env: &mut Environment) {
env.insert_hook(|_, mut config: ButtonConfig| {
config.label = AnyView::new(hstack((
text("🌊"),
config.label,
)));
config.render()
});
}
}
let env = Environment::new().install(ThemePlugin);
}
Hooks intercept ViewConfiguration types before they render, making the environment the perfect place to implement themes, logging, or feature flags that span your entire view hierarchy.
Resolvers, Environment Hooks, and Dynamic Views
The WaterUI core works because values can be resolved lazily against the Environment and streamed into the renderer. This chapter explains the pieces you will see throughout the book.
The Resolvable Trait
#![allow(unused)]
fn main() {
use waterui_core::{Environment, Signal, constant, resolve::Resolvable};
#[derive(Debug, Clone)]
struct LocalizedTitle;
impl Resolvable for LocalizedTitle {
type Resolved = String;
fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
let locale = env
.get::<String>()
.cloned()
.unwrap_or_else(|| "en".to_string());
constant(match locale.as_str() {
"en" => "Hello".to_string(),
"fr" => "Bonjour".to_string(),
_ => "Hello".to_string(),
})
}
}
}
Resolvable types take an environment reference and produce any Signal. When WaterUI stores them inside AnyResolvable<T>, the system can defer value creation until the view tree is mounted on a backend. This is how themes, localization, and other contextual data flow without explicit parameters.
Hooks and Metadata
Environment::insert_hook injects Hook<ViewConfig> values that can wrap every view of a given configuration type. Hooks are typically installed by plugins (e.g., a theming system) so that they can read additional metadata and rewrite the view before rendering. Because hooks remove themselves from the environment while running, recursion is avoided.
Dynamic Views
Dynamic::watch bridges any Signal into the View world. The helper subscribes to the signal, sends the initial value, and keeps the view tree alive via a guard stored in With metadata. You will use it heavily when wiring nami bindings into text, lists, or network-backed UIs.
Throughout the component chapters we will point back here whenever a control takes an impl Resolvable or internally creates a Dynamic to stream updates.
Nami - The Reactive Heart of WaterUI
Reactive state management is the core of any interactive WaterUI application. When your data changes, the UI should automatically update to reflect it. This chapter teaches you how to master WaterUI’s reactive system, powered by the nami crate.
All examples assume the following imports:
#![allow(unused)] fn main() { use waterui::prelude::*; use waterui::reactive::binding; }
The Signal Trait: A Universal Language
Everything in nami’s reactive system implements the Signal trait. It represents any value that can be observed for changes.
#![allow(unused)]
fn main() {
use nami::watcher::Context;
pub trait Signal: Clone + 'static {
type Output;
type Guard;
// Get the current value of the signal
fn get(&self) -> Self::Output;
// Watch for changes (used internally by the UI)
fn watch(&self, watcher: impl Fn(Context<Self::Output>) + 'static) -> Self::Guard;
}
}
A Signal is a reactive value that knows how to:
- Provide its current value (
get()). - Notify observers when it changes (
watch()).
Types of Signals
1. Binding<T>: Mutable, Two-Way State
A Binding<T> is the most common way to manage mutable reactive state. It holds a value that can be changed, and it will notify any part of the UI that depends on it.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
// Create mutable reactive state with automatic type conversion
let counter: Binding<i32> = binding(0);
let name: Binding<String> = binding("Alice".to_string());
// Set new values, which triggers UI updates
counter.set(42);
name.set("Bob".to_string());
}
2. Computed<T>: Derived, Read-Only State
A Computed<T> is a signal that is derived from one or more other signals. It automatically updates its value when its dependencies change. You create computed signals using the methods from the SignalExt trait.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::Binding;
let first_name: Binding<String> = binding("Alice".to_string());
let last_name: Binding<String> = binding("Smith".to_string());
// Create a computed signal that updates automatically
let full_name = first_name.zip(last_name).map(|(first, last)| {
format!("{} {}", first, last)
});
// `full_name` will re-compute whenever `first_name` or `last_name` changes.
}
The binding(value) helper is re-exported from WaterUI, giving you a concise way to
initialize bindings with automatic Into conversions (e.g. binding("hello") -> Binding<String>).
Once you have a binding, reach for its convenience methods like .toggle(), or use .update() / .with_mut() for more complex in-place modifications. Some convenience methods like .increment() or .push() might be available as extension traits on Binding for specific types (e.g., numeric types or collections) within the broader waterui crate.
When Type Inference Needs Help
Sometimes the compiler can’t deduce the target type—especially when starting from None,
Default::default(), or other type-agnostic values. In those cases, add an explicit type
with the turbofish syntax:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
#[derive(Clone)]
struct User;
// Starts as None, so we spell out the final type.
let selected_user = binding::<Option<User>>(None);
// Empty collection with an explicit element type.
let log_messages = binding::<Vec<String>>(Vec::new());
}
The rest of the ergonomics (methods like .set, .toggle, .push) remain exactly the same.
3. Constants: Signals That Never Change
Even simple, non-changing values can be treated as signals. This allows you to use them seamlessly in a reactive context.
#![allow(unused)]
fn main() {
use waterui::reactive::constant;
let fixed_name = constant("WaterUI"); // Never changes
let literal_string = "Hello World"; // Also a signal!
}
The Golden Rule: Avoid .get() in UI Code
Calling .get() on a signal extracts a static, one-time snapshot of its value. When you do this, you break the reactive chain. The UI will be built with that snapshot and will never update when the original signal changes.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let name: Binding<String> = binding("Alice");
// ❌ WRONG: Using .get() breaks reactivity
let broken_message = format!("Hello, {}", name.get());
text(broken_message); // This will NEVER update when `name` changes!
// ✅ CORRECT: Pass the signal directly to keep the reactive chain intact
let reactive_message = s!("Hello, {name}");
text(reactive_message); // This updates automatically when `name` changes.
}
When should you use .get()? Only when you need to pass the value to a non-reactive system, such as:
- Logging or debugging.
- Sending the data over a network.
- Performing a one-off calculation outside the UI.
Mastering Binding<T>: Your State Management Tool
Binding<T> is more than just a container. It provides a rich set of convenience methods to handle state updates ergonomically.
Basic Updates: .set()
The simplest way to update a binding is with .set().
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
let counter = binding(0);
counter.set(10); // The counter is now 10
}
In-Place Updates: .update()
For complex types, .update() allows you to modify the value in-place without creating a new one. It takes a closure that receives a mutable reference to the value.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
#[derive(Clone)]
struct User { name: String, tags: Vec<&'static str> }
let user = binding(User { name: "Alice".to_string(), tags: vec![] });
// Modify the user in-place
user.with_mut(|user: &mut User| {
user.name = "Alicia".to_string();
user.tags.push("admin");
});
// The UI updates once, after the closure finishes.
}
This is more efficient than cloning the value, modifying it, and then calling .set().
Boolean Toggle: .toggle()
For boolean bindings, .toggle() is a convenient shortcut.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let is_visible: Binding<bool> = binding(false);
is_visible.toggle(); // is_visible is now true
}
Mutable Access with a Guard: .get_mut()
For scoped, complex mutations, .get_mut() provides a guard. The binding is marked as changed only when the guard is dropped.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
let data = binding::<Vec<i32>>(vec![1, 2, 3]);
// Get a mutable guard. The update is sent when `guard` goes out of scope.
let mut guard = data.get_mut();
guard.push(4);
guard.sort();
}
The s! Macro: Reactive String Formatting
The s! macro is a powerful tool for creating reactive strings. It automatically captures signals from the local scope and creates a computed string that updates whenever any of the captured signals change.
Without s! (manual & verbose):
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::Binding;
let name: Binding<String> = binding("John");
let age: Binding<i32> = binding(30);
let message = name.zip(age).map(|(n, a)| {
format!("{} is {} years old.", n, a)
});
}
With s! (concise & reactive):
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let name: Binding<String> = binding("John");
let age: Binding<i32> = binding(30);
let message = s!("{} is {} years old.", name, age);
}
The s! macro also supports named arguments for even greater clarity:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let name: Binding<String> = binding("John");
let age: Binding<i32> = binding(30);
let message = s!("{name} is {age} years old.");
}
Feeding Signals Into Views
Signals become visible through views. WaterUI ships a few bridges:
- Formatting macros (
text!,s!,format_signal!) capture bindings automatically. - Dynamic views (
Dynamic::watchorwatch) subscribe to anySignaland rebuild their child when it changes. - Resolver-aware components consume
impl Resolvable, which often wrap signals resolved against theEnvironment.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Signal;
use waterui::Binding;
use waterui_core::dynamic::watch;
pub fn temperature(reading: impl Signal<Output = i32>) -> impl View {
watch(reading, |value| text!("Current: {value}°C"))
}
pub fn profile(name: &str) -> impl View {
let name: Binding<String> = binding(name.to_string());
text!("Hello, {name}!")
}
}
Whenever possible, pass signals directly into these helpers instead of calling .get(). That keeps the UI diffing fast and narrowly scoped to the widgets that truly care.
Transforming Signals with SignalExt
The SignalExt trait provides a rich set of combinators for creating new computed signals.
.map(): Transform the value of a signal..zip(): Combine two signals into one..filter(): Update only when a condition is met..debounce(): Wait for a quiet period before propagating an update..throttle(): Limit updates to a specific time interval.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::SignalExt;
let query: Binding<String> = binding(String::new());
// Trim whitespace reactively so searches only fire when meaningful.
let trimmed_query = query.clone().map(|q| q.trim().to_string());
// A derived signal that performs a search when the query is not empty.
let search_results = trimmed_query.map(|q| {
if q.is_empty() {
vec![]
} else {
// perform_search(&q)
vec!["Result 1".to_string()]
}
});
}
Watching Signals Manually
Occasionally you need to run imperative logic whenever a value changes—logging, analytics, or triggering a network request. Every signal exposes watch for that purpose:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::reactive::watcher::Context;
use waterui::Binding;
let name: Binding<String> = binding("Alice");
let _guard = name.watch(|ctx: Context<String>| {
println!("Name changed to {}", ctx.value());
println!("Metadata: {:?}", ctx.metadata());
});
}
Keep the returned guard alive as long as you need the subscription. Dropping it unsubscribes automatically. Inside view code, WaterUI keeps these guards alive for you (e.g., Dynamic::watch stores the guard alongside the rendered view via With metadata), but it is useful to know how the primitive operates when you build custom integrations.
By mastering these fundamental concepts, you can build complex, efficient, and maintainable reactive UIs with WaterUI.
Conditional Rendering
Declarative UI is all about letting data drive what appears on screen. WaterUI’s conditional
widgets allow you to branch on reactive Binding/Signal values without leaving the view tree or
breaking reactivity. This chapter covers the when helper and its siblings, demonstrates practical
patterns, and highlights best practices drawn from real-world apps.
Choosing the Right Tool
| Scenario | Recommended API | Notes |
|---|---|---|
Show a block only when a boolean is true | `when(condition, | |
Provide an else branch | `.or( | |
Toggle based on an Option<T> | `when(option.map( | opt |
| Show a loading indicator while work happens | `when(is_ready.clone(), |
Basic Usage
use waterui::prelude::*;
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::condition::when;
use waterui::reactive::binding;
use waterui::Binding;
pub fn status_card() -> impl View {
let is_online: Binding<bool> = binding(true);
when(is_online.clone(), || {
text("All systems operational")
.foreground(Color::srgb(68, 207, 95))
})
.or(|| {
text("Offline".to_string())
.foreground(Color::srgb(220, 76, 70))
})
}
}
when evaluates the condition reactively. Whenever is_online flips, WaterUI rebuilds only the
branch that needs to change.
Negation and Derived Conditions
Binding<bool> implements Not, so you can negate without extra helpers:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
let show_help = binding(false);
when(!show_help.clone(), || text("Need help?"));
}
For complex logic, derive a computed boolean with SignalExt:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::SignalExt;
#[derive(Clone)]
struct CartItem;
pub fn cart_section() -> impl View {
let cart_items = binding::<Vec<CartItem>>(Vec::new());
let has_items = cart_items.clone().map(|items| !items.is_empty());
when(has_items, || button("Checkout"))
.or(|| text("Your cart is empty"))
}
}
The key guideline is never call .get() inside the view tree; doing so breaks reactivity. Always
produce another Signal<bool>.
Option-Based Rendering
Options are ubiquitous. Transform them into booleans with map or unwrap them inline using
option.then_some convenience methods:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::SignalExt as WaterSignalExt;
#[derive(Clone)]
struct User {
name: &'static str,
}
impl User {
fn placeholder() -> Self {
Self { name: "Guest" }
}
}
pub fn user_panel() -> impl View {
let selected_user = binding::<Option<User>>(None);
let has_selection = selected_user.clone().map(|user| user.is_some());
when(has_selection.clone(), {
let selected_user = selected_user.clone();
move || {
let profile = selected_user.clone().unwrap_or_else(User::placeholder);
let profile_name = WaterSignalExt::map(profile, |user| user.name);
text!("Viewing {profile_name}")
}
})
.or(|| text("Select a user to continue"))
}
}
Binding<Option<T>>::unwrap_or_else (from nami) returns a new binding that always contains a value
and wraps writes in Some(_), which can simplify nested UI.
Conditional Actions
Conditional widgets are themselves views, so you can embed them anywhere a normal child would appear:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::Binding;
pub fn dashboard() -> impl View {
let has_error: Binding<bool> = binding(false);
vstack((
text("Dashboard"),
when(has_error.clone(), || text("Something went wrong")),
text("All clear!"),
))
}
}
Combine when with button actions for toggles:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::Binding;
pub fn expandable_panel() -> impl View {
let expanded: Binding<bool> = binding(false);
vstack((
button("Details").action_with(&expanded, |state: Binding<bool>| {
state.set(!state.get());
}),
when(expanded.clone(), || text("Here are the details")),
))
}
}
Avoid Side-Effects Inside Closures
The closures you pass to when should be pure view builders. Mutating external state or launching
async work from inside introduces hard-to-debug behaviour. Instead, trigger those effects from
button handlers or tasks, then let the binding drive the conditional view.
Advanced Patterns
- Multiple Conditions – Nest
whencalls or build amatch-style dispatcher usingmatchon an enum and return different views for each variant. - Animations & Transitions – Wrap the conditional content in your animation view or attach a custom environment hook. WaterUI will destroy and recreate the branch when toggled, so animations should capture their state in external bindings if you want continuity.
- Layouts with Placeholders – Sometimes you want the layout to remain stable even when the
branch is hidden. Instead of removing the view entirely, render a transparent placeholder using
when(condition, || view).or(|| spacer())or aFramewith a fixed size.
Troubleshooting
- Blinking Content – If you see flashing during rapid toggles, ensure the heavy computation
lives outside the closure (e.g. precompute data in a
Computedbinding). - Impossible Branch – When you know only one branch should appear, log unexpected states in
the
orclosure so you catch logic issues early. - Backend Differences – On some targets (notably Web) changing the DOM tree may reset native controls. Preserve user input by keeping the control alive and toggling visibility instead of removing it entirely.
Conditional views are a small API surface, but mastering them keeps your UI declarative and predictable. Use them liberally to express application logic directly alongside the view structure.
Layout Components
Layouts determine how views measure themselves and where they end up on screen. WaterUI follows
a two-stage process similar to SwiftUI and Flutter: first the framework proposes sizes to each
child, then it places those children inside the final bounds returned by the renderer. This
chapter documents the high-level containers you will reach for most often and explains how they
map to the lower-level layout primitives exposed in waterui_layout.
How the Layout Pass Works
- Proposal – A parent view calls
Layout::proposeon its children with the size it is willing to offer. Children can accept the full proposal, clamp it, or ignore it entirely. - Measurement – Each child reports back an intrinsic
Sizebased on the proposal. Stacks, grids, and other composite containers aggregate those answers to determine their own size. - Placement – The container receives a rectangle (
Rect) that represents the concrete space granted by the renderer. It positions every child within that rectangle viaLayout::place.
Understanding these stages helps you reason about why a view grows or shrinks, and which modifier
(padding, alignment, Frame) to reach for when the default behaviour does not match your
expectation.
Stack Layouts
Stacks are the bread and butter of WaterUI. They arrange children linearly or on top of each other and are zero-cost abstractions once the layout pass completes.
Vertical Stacks (vstack / VStack)
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::stack::{vstack, HorizontalAlignment};
use waterui::reactive::binding;
use waterui::Binding;
pub fn profile_card() -> impl View {
let name: Binding<String> = binding("Ada Lovelace".to_string());
let followers: Binding<i32> = binding(128_000);
vstack((
text!("{name}"),
text!("Followers: {followers}"),
))
.spacing(12.0) // Vertical gap between rows
.alignment(HorizontalAlignment::Leading)
.padding()
}
}
Key points:
- Children are measured with the parent’s width proposal and natural height.
.spacing(distance)sets the inter-row gap..alignment(...)controls horizontal alignment, usingLeading,Center, orTrailing.- To contribute flexible space within a stack, insert a
spacer()(discussed later).
Horizontal Stacks (hstack / HStack)
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::{spacer, stack::hstack};
use waterui::layout::stack::VerticalAlignment;
pub fn toolbar() -> impl View {
hstack((
text("WaterUI"),
spacer(),
button("Docs"),
button("Blog"),
))
.spacing(16.0)
.alignment(VerticalAlignment::Center)
.padding_with(EdgeInsets::symmetric(8.0, 16.0))
}
}
Horizontal stacks mirror vertical stacks but swap the axes: alignment describes vertical behaviour, spacing applies horizontally, and spacers expand along the x-axis.
Overlay Stacks (zstack / ZStack)
zstack draws every child in the same rectangle. It is perfect for badges, overlays, and
background effects.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::padding::EdgeInsets;
use waterui::layout::stack::{zstack, Alignment};
use waterui::media::Photo;
pub fn photo_with_badge() -> impl View {
zstack((
Photo::new("https://example.com/cover.jpg"),
text("LIVE")
.padding_with(EdgeInsets::symmetric(4.0, 8.0))
.background(waterui::background::Background::color(Color::srgb_f32(0.9, 0.1, 0.1)))
.alignment(Alignment::TopLeading)
.padding_with(EdgeInsets::new(8.0, 0.0, 0.0, 0.0)),
))
.alignment(Alignment::Center)
}
}
Overlay stacks honour their Alignment setting (Center by default) when positioning children.
Combined with padding you can fine-tune overlay offsets without writing custom layout code.
Spacers and Flexible Space
spacer() expands to consume all remaining room along the stack’s main axis. It behaves like
SwiftUI’s spacer or Flutter’s Expanded with a default flex of 1.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::{spacer, stack::hstack};
pub fn pagination_controls() -> impl View {
hstack((
button("Previous"),
spacer(),
text("Page 3 of 10"),
spacer(),
button("Next"),
))
}
}
Need a spacer that never shrinks below a certain size? Use spacer_min(120.0) to guarantee the
minimum gap.
Padding and Insets
Any view gains padding via ViewExt::padding() or padding_with(EdgeInsets).
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::padding::EdgeInsets;
use waterui::layout::stack::Alignment;
use waterui::Str;
fn message_bubble(content: impl Into<Str>) -> impl View {
let content: Str = content.into();
text(content)
.padding_with(EdgeInsets::symmetric(8.0, 12.0))
.background(waterui::background::Background::color(Color::srgb_f32(0.18, 0.2, 0.25)))
.alignment(Alignment::Leading)
}
}
EdgeInsets helpers:
EdgeInsets::all(value)– identical padding on every edge.EdgeInsets::symmetric(vertical, horizontal)– separate vertical and horizontal padding.EdgeInsets::new(top, bottom, leading, trailing)– full control per edge.
Scroll Views
WaterUI exposes scroll containers that delegate behaviour to the active renderer. Use them when content might overflow the viewport:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::scroll::{scroll, scroll_horizontal, scroll_both};
pub fn article(body: impl View) -> impl View {
scroll(body.padding())
}
}
scroll(content)– vertical scrolling (typical for lists, articles).scroll_horizontal(content)– horizontal carousels.scroll_both(content)– panning in both axes for large canvases or diagrams.
Remember that actual scroll physics depend on the backend (SwiftUI, GTK4, Web, …). Keep your content pure; avoid embedding interactive gestures that require platform-specific hooks until the widget surfaces them.
Grid Layouts
The grid API arranges rows and columns with consistent spacing. Every row is a GridRow, and the
container needs the number of columns up front.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::grid::{grid, row};
use waterui::layout::stack::Alignment;
pub fn emoji_palette() -> impl View {
grid(4, [
row(("😀", "😁", "😂", "🤣")),
row(("😇", "🥰", "😍", "🤩")),
row(("🤔", "🤨", "🧐", "😎")),
])
.spacing(12.0) // Uniform horizontal + vertical spacing
.alignment(Alignment::Center) // Align cells inside their slots
.padding()
}
}
Notes:
- Grids require a concrete width proposal. On desktop, wrap them in a parent that constrains width
(e.g.
.max_width(...)) when needed. - Each row may contain fewer elements than the declared column count; the layout simply leaves the trailing cells empty.
- Use
Alignment::Leading/Trailing/Top/Bottomto align items inside each grid cell.
Frames and Explicit Sizing
WaterUI provides direct modifiers for pinning views to explicit sizes, similar to SwiftUI.
Use .width(), .height(), or .size() to constrain dimensions:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::stack::Alignment;
fn gallery_thumbnail(content: impl View) -> impl View {
content
.size(160.0, 120.0)
.alignment(Alignment::Center)
}
}
Frames are most helpful when mixing flexible and fixed-size widgets (for example, pinning an avatar while the surrounding text wraps naturally). Combine frames with stacks, grids, and padding to create predictable compositions.
Layout Troubleshooting Checklist
- Unexpected stretching – Make sure there isn’t an extra
spacer()or a child returning an infinite proposal. Wrapping the content in.padding_with(EdgeInsets::all(0.0))can help visualise what area the view thinks it owns. - Grid clipping – Provide a finite width (wrap in a parent frame) and watch for rows with taller content than their neighbours.
- Overlapping overlays –
zstackhonours alignment. Apply additional.padding_withor wrap the child in aFrameto fine-tune positions. - Platform differences – Remember that scroll behaviour is delegated to backends. Test on each target platform when tweaking scrollable layouts.
Where to Go Next
Explore the advanced layout chapter for details on implementing custom Layout types, or scan the
waterui_layout crate for lower-level primitives like Container and ProposalSize. Armed with
stacks, spacers, padding, grids, and frames you can replicate the majority of everyday UI
structures in a clear, declarative style.
Text and Typography
Text is the backbone of most interfaces. WaterUI gives you two complementary approaches:
lightweight labels for quick strings, and the configurable Text view for styled, reactive
content. Think of the split the same way Apple distinguishes between Text and bare strings in
SwiftUI, or Flutter differentiates Text from const literals.
Quick Reference
| Need | Use | Notes |
|---|---|---|
| Static copy, no styling | string literal / String / Str | Lowest overhead; respects the surrounding layout but cannot change font or colour. |
| Styled or reactive text | Text / text! macro | Full typography control and automatic updates when bound data changes. |
| Format existing signals | text!("Total: {amount:.2}", amount) | Uses the nami::s! formatter under the hood. |
| Display non-string signals | Text::display(binding_of_number) | Wraps any Display value, recalculating when the binding updates. |
| Custom formatter (locale-aware, currency, dates) | Text::format(value, Formatter) | See waterui_text::locale for predefined formatters. |
Labels: Zero-Cost Strings
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::stack::vstack;
pub fn hero_copy() -> impl View {
vstack((
"WaterUI", // &'static str
String::from("Rust-first UI"), // Owned String
Str::from("Lightning fast"), // WaterUI's rope-backed string
))
}
}
Labels have no styling hooks and stay frozen after construction. Use them for static headings,
inline copy, or when you wrap them in other views (button("OK")).
The Text View
Text is a configurable view exported by waterui::text. Create instances via the
text function, the text! macro, or constructors such as Text::display.
Reactive Text with text!
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::SignalExt;
pub fn welcome_banner() -> impl View {
let name: Binding<String> = binding("Alice".to_string());
let unread: Binding<i32> = binding(5);
vstack((
text!("Welcome back, {name}!"),
text!("You have {unread} unread messages."),
))
}
}
text! captures any signals referenced in the format string and produces a reactive Text view.
Avoid format!(…) + text(...); the one-off string will not update when data changes.
Styling and Typography
Text exposes chainable modifiers that mirror SwiftUI:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::text::font::FontWeight;
pub fn ticker(price: Binding<f32>) -> impl View {
text!("${price:.2}")
.size(20.0)
.weight(FontWeight::Medium)
.foreground(Color::srgb(64, 196, 99))
}
}
Available modifiers include:
.size(points)– font size in logical pixels..weight(FontWeight::…)or.bold()– typographic weight..italic(binding_of_bool)– toggle italics reactively..font(Font)– swap entire font descriptions (custom families, monospaced, etc)..content()returns the underlyingComputed<StyledStr>for advanced pipelines.
Combine with ViewExt helpers for layout and colouring, e.g. .padding(), .background(...), or
.alignment(Alignment::Trailing).
Displaying Arbitrary Values
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn stats() -> impl View {
let active_users: Binding<i32> = binding(42_857);
let uptime: Binding<f32> = binding(99.982);
vstack((
Text::display(active_users),
Text::display(uptime.map(|value| format!("{value:.2}% uptime"))),
))
}
}
Text::display converts any Signal<Output = impl Display> into a reactive string. For complex
localised formatting (currency, dates), Text::format interoperates with the formatters in
waterui::text::locale.
Working with Binding<Option<T>>
When the text source may be absent, leverage nami’s mapping helpers:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
let maybe_location = binding::<Option<String>>(None);
let fallback = maybe_location.unwrap_or_else(|| "Unknown location".to_string());
text(fallback);
}
unwrap_or_else yields a new Binding<String> that always contains a value, ensuring the view stays
reactive.
Best Practices
- Avoid
.get()inside views – Convert to signals with.map,.zip, orbinding::<T>+ turbofish when the compiler needs help inferring types. - Keep expensive formatting out of the view – Precompute large strings in a
Computedbinding so the closure remains trivial. - Prefer
text!for dynamic content – It keeps formatting expressive and reduces boilerplate. - Use labels for performance-critical lists – Large table rows with static copy render faster as bare strings.
Troubleshooting
- Text truncates unexpectedly – Use
.alignment(Alignment::Leading)or place inside anhstackwithspacer()to control overflow. - Styling missing on one platform – Confirm the backend exposes the property; some early-stage renderers intentionally ignore unsupported font metrics.
- Emoji or wide glyph clipping – Ensure the containing layout provides enough height; padding or a fixed height often resolves baseline differences between fonts.
With these building blocks you can express everything from static headings to live, localised metrics without imperatively updating the UI. Let your data bindings drive the text, and WaterUI handles the rest.
Controls Overview
Buttons, toggles, sliders, text fields, and steppers live inside waterui::component.
They share the same handler ergonomics and reactive bindings you saw in earlier chapters. This
chapter walks through each control, explaining how to wire it to Binding values, style labels, and
compose them into larger workflows.
Buttons
Buttons turn user intent into actions. WaterUI’s button helper mirrors the ergonomics of SwiftUI
while keeping the full power of Rust’s closures. This section explains how to build buttons, capture
state, coordinate with the environment, and structure handlers for complex flows.
Anatomy of a Button
button(label) returns a Button view. The label can be any view—string literal, Text, or a
fully custom composition. Attach behaviour with .action or .action_with.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
fn simple_button() -> impl View {
button("Click Me").action(|| {
println!("Button was clicked!");
})
}
}
Behind the scenes, WaterUI converts the closure into a HandlerFn. Handlers can access the
Environment or receive state via .action_with.
Working with State
Buttons often mutate reactive state. Use action_with to borrow a binding without cloning it
manually.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::Binding;
fn counter_button() -> impl View {
let count: Binding<i32> = binding(0);
vstack((
text!("Count: {count}"),
button("Increment").action_with(&count, |binding: Binding<i32>| {
binding.set(binding.get() + 1);
}),
))
}
}
.action_with(&binding, handler) clones the binding for you (bindings are cheap handles). Inside
the handler you can call any of the binding helpers—set, set_from, with_mut, etc.—to keep the
state reactive.
Custom Labels and Composition
Because labels are just views, you can craft rich buttons with icons, nested stacks, or dynamic content.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::{padding::EdgeInsets, stack::hstack};
fn hero_button() -> impl View {
button(
hstack((
text("🚀"),
text("Launch")
.size(18.0)
.padding_with(EdgeInsets::new(0.0, 0.0, 0.0, 8.0)),
))
.padding()
)
.action(|| println!("Initiating launch"))
}
}
You can nest buttons inside stacks, grids, navigation views, or conditionals—WaterUI treats them like any other view.
Guarding Actions
WaterUI does not currently ship a built-in .disabled modifier. Instead, guard inside the handler or
wrap the button in a conditional.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::condition::when;
use waterui::Computed;
fn guarded_submit(can_submit: Computed<bool>) -> impl View {
when(can_submit.clone(), || {
button("Submit").action(|| println!("Submitted"))
})
.or(|| text("Complete all fields to submit"))
}
}
For idempotent operations, simply return early:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn pay_button() -> impl View {
let payment_state: Binding<bool> = binding(false);
button("Pay").action_with(&payment_state, |state: Binding<bool>| {
if state.get() {
return;
}
state.set(true);
})
}
}
Asynchronous Workflows
Handlers run on the UI thread. When you need async work, hand it off to a task:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::task::spawn;
pub fn refresh_button() -> impl View {
button("Refresh").action(|| {
spawn(async move {
println!("Refreshing data…");
});
})
}
}
spawn hands the async work to the configured executor so the handler stays lightweight—schedule
work and return immediately.
Best Practices
- Keep handlers pure – Avoid blocking IO or heavy computation directly in the closure.
- Prefer
action_with– It guarantees the binding lives long enough and stays reactive. - Think environment-first – Use extractors when a button needs shared services.
- Make feedback visible – Toggle UI state with bindings (loading spinners, success banners) so the user sees progress.
Buttons may look small, but they orchestrate the majority of user journeys. Combine them with the layout and state tools covered elsewhere in this book to build polished, responsive workflows.
Toggles
Toggles expose boolean bindings with a platform-native appearance.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::Binding;
pub fn settings_toggle() -> impl View {
let wifi_enabled: Binding<bool> = binding(true);
vstack((
toggle("Wi-Fi", &wifi_enabled),
when(wifi_enabled.map(|on| on), || text("Connected to Home"))
.or(|| text("Wi-Fi disabled")),
))
}
}
- Pass the label as any view (string,
Text, etc.) along with aBinding<bool>. - Bind directly to a
Binding<bool>; if you need side effects, react in a separate handler usingwhenortask. - Combine with
whento surface context (“Wi-Fi connected to Home” vs “Wi-Fi off”).
Sliders
Sliders map a numeric range onto a drag gesture. Provide the inclusive range and the bound value.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn volume_control() -> impl View {
let volume: Binding<f64> = binding(0.4_f64);
Slider::new(0.0..=1.0, &volume)
.label(text("Volume"))
}
}
Tips:
.step(value)snaps to increments..label(view)attaches an inline view (e.g.,text("Volume")).- For discrete ranges (0-10), wrap the slider alongside a
Text::display(volume.map(...)).
Steppers
Steppers are ideal for precise numeric entry without a keyboard.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn quantity_selector() -> impl View {
let quantity: Binding<i32> = binding(1);
stepper(&quantity)
.step(1)
.range(1..=10)
.label(text("Quantity"))
}
}
.range(min..=max)clamps the value..step(size)controls increments/decrements.- Because steppers operate on
Binding<i32>/Binding<i64>, convert floats to integers before using.
Text Fields
TextField binds to Binding<String> and exposes placeholder text plus secure-entry modes.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn login_fields() -> impl View {
let username: Binding<String> = binding(String::new());
let password: Binding<String> = binding(String::new());
vstack((
vstack((
text("Username"),
TextField::new(&username).prompt(text("you@example.com")),
)),
vstack((
text("Password"),
TextField::new(&password).prompt(text("••••••")),
)),
))
}
}
WaterUI automatically syncs user edits back into the binding. Combine .on_submit(handler) with the
button patterns above to run validation or send credentials. When you need structured forms, these
controls are exactly what the #[form] macro wires up behind the scenes.
Form Controls
WaterUI provides a comprehensive form system that makes creating interactive forms both simple and powerful. The centerpiece of this system is the FormBuilder derive macro, which automatically generates form UIs from your data structures.
Two-Way Data Binding
WaterUI’s forms are built on a powerful concept called two-way data binding. This means that the state of your data model and the state of your UI controls are always kept in sync automatically.
Here’s how it works:
- You provide a
Bindingof your data structure (e.g.,Binding<LoginForm>) to a form control. - The form control (e.g., a
TextField) reads the initial value from the binding to display it. - When the user interacts with the control (e.g., types into the text field), the control automatically updates the value inside your original
Binding.
This creates a seamless, reactive loop:
- Model → View: If you programmatically change the data in your
Binding, the UI control will instantly reflect that change. - View → Model: If the user changes the value in the UI control, your underlying data
Bindingis immediately updated.
This eliminates a huge amount of boilerplate code. You don’t need to write manual event handlers to update your state for every single input field. The binding handles it for you. All form components in WaterUI, whether used individually or through the FormBuilder, use this two-way binding mechanism.
Quick Start with FormBuilder
The easiest way to create forms in WaterUI is using the #[form] macro. It combines reactivity with automatic UI generation:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::form::{form, FormBuilder};
#[form]
pub struct LoginForm {
/// The user's username
pub username: String,
/// The user's password
pub password: String,
/// Whether to remember the user
pub remember_me: bool,
/// The user's age
pub age: i32,
}
pub fn login_view() -> impl View {
let login_form = LoginForm::binding();
form(&login_form)
}
}
That’s it! WaterUI automatically creates appropriate form controls for each field type:
String→ Text fieldbool→ Toggle switchi32→ Number stepperf64→ Slider- And many more…
Type-to-Component Mapping
The #[form] macro automatically maps Rust types to appropriate form components:
| Rust Type | Form Component | Description |
|---|---|---|
String, &str | TextField | Single-line text input |
bool | Toggle | On/off switch |
i32, i64, etc. | Stepper | Numeric input with +/- buttons |
f64 | Slider | Slider with 0.0-1.0 range |
Color | ColorPicker | Color selection widget |
Complete Example: User Registration Form
Let’s build a more comprehensive form:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::form::{form, FormBuilder};
use waterui::Color;
#[form]
struct RegistrationForm {
/// Full name (2-50 characters)
full_name: String,
/// Email address
email: String,
/// Age (must be 18+)
age: i32,
/// Subscribe to newsletter
newsletter: bool,
/// Account type
is_premium: bool,
/// Profile completion (0.0 to 1.0)
profile_completion: f64,
/// Theme color preference
theme_color: Color,
}
pub fn registration_view() -> impl View {
let form_binding = RegistrationForm::binding();
let validation_message = form_binding.clone().map(|data| -> String {
if data.full_name.len() < 2 {
"Name too short".into()
} else if data.age < 18 {
"Must be 18 or older".into()
} else if !data.email.contains('@') {
"Invalid email".into()
} else {
"Form is valid ✓".into()
}
});
vstack((
"User Registration",
form(&form_binding),
text!("{validation_message}"),
))
}
}
Individual Form Controls
You can also use form controls individually:
Text Fields
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::Str;
pub fn text_field_example() -> impl View {
let name: Binding<String> = binding(String::new());
vstack((
text("Name:"),
TextField::new(&name).prompt(text("you@example.com")),
))
}
}
Toggle Switches
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn toggle_example() -> impl View {
let enabled: Binding<bool> = binding(false);
toggle("Enable notifications", &enabled)
}
}
Number Steppers
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn stepper_example() -> impl View {
let count: Binding<i32> = binding(0);
stepper(&count)
}
}
Sliders
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
fn slider_example() -> impl View {
let volume: Binding<f64> = binding(0.5_f64);
Slider::new(0.0..=1.0, &volume).label(text("Volume"))
}
}
Color Picker
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::Color;
use waterui::form::picker::ColorPicker;
fn theme_selector() -> impl View {
let color: Binding<Color> = binding(Color::srgb_f32(0.25, 0.6, 0.95));
ColorPicker::new(&color)
.label(text("Theme color"))
}
}
Advanced Form Patterns
Multi-Step Forms
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::widget::condition::when;
use waterui::layout::stack::hstack;
use waterui::layout::spacer;
use waterui::Binding;
use waterui::form::{form, FormBuilder};
#[form]
struct PersonalInfo {
first_name: String,
last_name: String,
birth_year: i32,
}
#[form]
struct ContactInfo {
email: String,
phone: String,
preferred_contact: bool, // true = email, false = phone
}
pub fn registration_wizard() -> impl View {
let personal = PersonalInfo::binding();
let contact = ContactInfo::binding();
let current_step = binding(0_usize);
let step_display = waterui::SignalExt::map(current_step.clone(), |value| value + 1);
let show_personal = waterui::SignalExt::map(current_step.clone(), |step| step == 0);
vstack((
text!("Step {} of 2", step_display),
when(show_personal, move || form(&personal))
.or(move || form(&contact)),
hstack((
button("Back").action_with(¤t_step, |state: Binding<usize>| {
state.set(state.get().saturating_sub(1));
}),
spacer(),
button("Next").action_with(¤t_step, |state: Binding<usize>| {
state.set((state.get() + 1).min(1));
}),
)),
))
}
}
Custom Form Layouts
For complete control over form layout, implement FormBuilder manually:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::{AnyView, Binding, Str};
use waterui::form::FormBuilder;
#[derive(Default, Clone)]
struct CustomForm {
title: String,
active: bool,
}
impl FormBuilder for CustomForm {
type View = AnyView;
fn view(binding: &Binding<Self>, _label: AnyView, _placeholder: Str) -> Self::View {
let title_binding = Binding::mapping(binding, |data| Str::from(data.title.clone()), |form, value: Str| {
form.with_mut(|state| state.title = value.to_string());
});
let active_binding = Binding::mapping(binding, |data| data.active, |form, value| {
form.with_mut(|state| state.active = value);
});
AnyView::new(vstack((
TextField::new(&title_binding).label(text("Title")),
Toggle::new(&active_binding).label(text("Active")),
)))
}
}
}
Secure Fields
For sensitive data like passwords:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::form::{secure, SecureField};
use waterui::form::secure::Secure;
pub fn password_form() -> impl View {
let password: Binding<Secure> = binding(Secure::default());
let confirm_password: Binding<Secure> = binding(Secure::default());
vstack((
secure("Password:", &password),
secure("Confirm Password:", &confirm_password),
password_validation(&password, &confirm_password),
))
}
fn password_validation(pwd: &Binding<Secure>, confirm: &Binding<Secure>) -> impl View {
let feedback = pwd.clone().zip(confirm.clone()).map(|(p, c)| {
if p.expose() == c.expose() && !p.expose().is_empty() {
"Passwords match ✓".to_string()
} else {
"Passwords do not match".to_string()
}
});
text(feedback)
}
}
Form Validation Best Practices
Real-time Validation with Computed Signals
For more complex forms, it’s a good practice to encapsulate your validation logic into a separate struct. This makes your code more organized and reusable.
Let’s create a Validation struct that holds computed signals for each validation rule.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::SignalExt;
use waterui::widget::condition::when;
use waterui::form::{form, FormBuilder};
#[form]
struct ValidatedForm {
email: String,
password: String,
age: i32,
}
pub fn validated_form_view() -> impl View {
let form_state = ValidatedForm::binding();
let is_valid_email = form_state.clone().map(|f| f.email.contains('@') && f.email.contains('.'));
let is_valid_password = form_state.clone().map(|f| f.password.len() >= 8);
let is_valid_age = form_state.clone().map(|f| f.age >= 18);
let can_submit = is_valid_email
.clone()
.zip(is_valid_password.clone())
.zip(is_valid_age.clone())
.map(|((email, password), age)| email && password && age);
let email_feedback = is_valid_email.clone().map(|valid| {
if valid {
"✓ Valid email".to_string()
} else {
"✗ Please enter a valid email".to_string()
}
});
let password_feedback = is_valid_password.clone().map(|valid| {
if valid {
"✓ Password is strong enough".to_string()
} else {
"✗ Password must be at least 8 characters".to_string()
}
});
let age_feedback = is_valid_age.clone().map(|valid| {
if valid {
"✓ Age requirement met".to_string()
} else {
"✗ Must be 18 or older".to_string()
}
});
let submit_binding = form_state.clone();
let form_binding = form_state.clone();
vstack((
form(&form_binding),
text(email_feedback),
text(password_feedback),
text(age_feedback),
when(can_submit.clone(), move || {
button("Submit").action_with(&submit_binding, |state: Binding<ValidatedForm>| {
println!("Form submitted: {:?}", state.get());
})
})
.or(|| text("Fill every requirement to enable submission.")),
))
}
}
Integration with State Management
Forms integrate seamlessly with WaterUI’s reactive state system:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::widget::condition::when;
use waterui::Binding;
use waterui::form::{form, FormBuilder};
#[form]
struct UserSettings {
name: String,
theme: String,
notifications: bool,
}
pub fn settings_panel() -> impl View {
let settings = UserSettings::binding();
let has_changes = settings.clone().map(|s| {
s.name != "Default Name" || s.theme != "Light" || s.notifications
});
let settings_summary = settings.clone().map(|s| {
format!(
"User: {} | Theme: {} | Notifications: {}",
s.name,
s.theme,
if s.notifications { "On" } else { "Off" }
)
});
let form_binding = settings.clone();
vstack((
"Settings",
form(&form_binding),
"Preview:",
text(settings_summary),
when(has_changes.clone(), {
let save_binding = settings.clone();
move || {
button("Save Changes").action_with(&save_binding, |state: Binding<UserSettings>| {
save_settings(&state.get());
})
}
})
.or(|| text("No changes to save.")),
))
}
fn save_settings(settings: &UserSettings) {
println!("Saving settings: {settings:?}");
}
}
Lists and Collections
Dynamic data deserves a declarative list view. WaterUI ships a List component plus helpers such as
ForEach, ListItem, and NavigationPath so you can render changing collections with minimal
boilerplate.
Building a List from a Collection
List::for_each wires nami collections into reusable rows. It accepts any Collection
(reactive::collection::List, plain Vec, arrays, etc.) as long as each item exposes a stable
identifier via Identifable. Reach for waterui::reactive::collection::List when you need runtime
mutations (push, remove, sort) that notify the UI automatically.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::component::list::{List, ListItem};
use waterui::reactive::collection::List as ReactiveList;
use waterui::Identifable;
use waterui::AnyView;
#[derive(Clone)]
struct Thread {
id: i32,
subject: String,
}
impl Identifable for Thread {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
}
pub fn inbox() -> impl View {
let threads = ReactiveList::from(vec![
Thread { id: 1, subject: "Welcome".into() },
Thread { id: 2, subject: "WaterUI tips".into() },
]);
List::for_each(threads.clone(), |thread| {
let subject = thread.subject.clone();
ListItem {
content: AnyView::new(text!("{subject}")),
on_delete: None,
}
})
}
}
Whenever the threads binding changes (insert, delete, reorder), the list diffs the identifiers and
only updates the affected rows.
Tip: If your data type lacks a natural identifier, wrap it in a struct that implements
Identifableusing a generatedId.
Handling Deletes
ListItem exposes .on_delete so you can react to destructive actions from the UI (swipe-to-delete
on Apple platforms, context menus on desktop backends).
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::AnyView;
use waterui::component::list::ListItem;
use waterui::reactive::collection::List as ReactiveList;
use waterui::Identifable;
#[derive(Clone)]
struct Thread {
id: i32,
subject: String,
}
impl Identifable for Thread {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
}
fn row(thread: Thread, threads: ReactiveList<Thread>) -> ListItem {
let list = threads.clone();
let subject = thread.subject.clone();
ListItem {
content: AnyView::new(text!("{subject}")),
on_delete: Some(Box::new(move |_, _| {
if let Some(index) = list.iter().position(|t| t.id == thread.id) {
list.remove(index);
}
})),
}
}
}
Set disable_delete() to opt out globally for a given row.
Incremental Rendering with ForEach
ForEach is the engine behind List::for_each, but you can also use it directly whenever you need
to render dynamic tuples without the rest of the list machinery. Wrap primitive types with
id::SelfId (or implement Identifable) so each item has a stable key:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::collection::List as ReactiveList;
use waterui::views::ForEach;
use waterui::id;
use waterui::layout::stack::VStack;
fn chip_row(tags: ReactiveList<id::SelfId<String>>) -> impl View {
VStack::for_each(tags, |tag| {
let label = tag.clone().into_inner();
text!("#{label}")
})
}
}
Inside layout containers (hstack, vstack) you still get diffing and stable identity, which keeps
animations and focus handling consistent.
Virtualisation and Performance
On native backends, List feeds identifiers into platform list views (SwiftUI List, GTK4 list
widgets, DOM diffing on Web). That means virtualization and recycling are handled for you. Keep row
construction pure and cheap; expensive work should happen in signals upstream.
Troubleshooting
- Rows flicker or reorder unexpectedly – Ensure
Identifable::id()stays stable across renders. - Deletes trigger twice – Some backends emit multiple delete actions for the same row to confirm removal. Guard inside the handler by verifying the item still exists before mutating state.
- Nested scroll views – Wrap lists inside
NavigationVieworscrollinstead of stacking multiple scroll surfaces unless the platform explicitly supports nested scrolling.
Lists round out the standard component set: combine them with buttons, navigation links, and form controls to build data-driven experiences that stay reactive as your collections grow.
Media Components
Media surfaces are first-class citizens in WaterUI. The waterui::media crate provides declarative
views for images (Photo), video playback (Video + VideoPlayer), Live Photos, and a unified
Media enum that dynamically chooses the right renderer. This chapter explores the API from basic
usage through advanced configuration.
Photos: Static Images with Placeholders
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::media::Photo;
pub fn cover_image() -> impl View {
Photo::new("https://assets.waterui.dev/cover.png")
.placeholder(text("Loading…"))
}
}
Key features:
Photo::newaccepts anything convertible intowaterui::media::Url(web URLs,file://, etc.)..placeholder(view)renders while the backend fetches the asset..on_failure(view)handles network errors gracefully.- You can compose standard modifiers (
.padding(),.frame(...),.background(...)) around thePhotolike any other view.
Video Playback
Video represents a raw view, while VideoPlayer renders controls.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::media::VideoPlayer;
use waterui::reactive::binding;
pub fn trailer_player() -> impl View {
let muted = binding(false);
vstack((
VideoPlayer::new("https://media.waterui.dev/trailer.mp4").muted(&muted),
button("Toggle Mute").action_with(&muted, |state| state.toggle()),
))
}
}
Muting Model
VideoPlayer::muted(&Binding<bool>)maps a boolean binding onto the player’s internal volume.VideoPlayerstores the pre-mute volume so toggling restores the last audible level.
Styling Considerations
The video chrome (play/pause controls) depends on the backend. SwiftUI renders native controls, whereas Web/Gtk4 use their respective toolkit widgets. Keep platform conventions in mind when layering overlays or gestures on top.
Live Photos
Apple’s Live Photos combine a still image and a short video clip. WaterUI packages the pair inside
LivePhotoSource:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::media::{LivePhoto, LivePhotoSource};
pub fn vacation_memory() -> impl View {
let source = LivePhotoSource::new(
"IMG_1024.jpg".into(),
"IMG_1024.mov".into(),
);
LivePhoto::new(source)
}
}
Backends that don’t support Live Photos fall back to the still image.
The Media Enum
When the media type is decided at runtime, wrap it in Media. Rendering becomes a single view
binding instead of a large match statement.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::media::Media;
use waterui::reactive::binding;
pub fn dynamic_media() -> impl View {
let media = binding(Media::Image("https://example.com/photo.png".into()));
// Later you can switch to Media::Video or Media::LivePhoto and the UI updates automatically.
media
}
}
Media implements View, so you can drop it directly into stacks, grids, or navigation views. To
switch the content, update the binding—WaterUI rebuilds the appropriate concrete view.
Media Picker
Then present the picker:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::media::media_picker::{MediaFilter, MediaPicker, Selected};
use waterui::reactive::binding;
pub fn choose_photo() -> impl View {
let selection = binding::<Option<Selected>>(None);
MediaPicker::new(&selection)
.filter(MediaFilter::Image)
}
}
The Selected binding stores an identifier. Use Selected::load() asynchronously (via task) to
receive the actual Media item and pipe it into your view tree.
#![allow(unused)]
fn main() {
use waterui::media::Media;
use waterui::reactive::binding;
use waterui::task::spawn;
let gallery = binding(Vec::<Media>::new());
button("Import").action_with(&selection, move |selected_binding| {
let gallery = gallery.clone();
if let Some(selected) = selected_binding.get() {
spawn(async move {
let media = selected.load().await;
gallery.push(media);
});
}
});
}
Best Practices
- Defer heavy processing – Image decoding and video playback happen in the backend. Avoid blocking the UI thread; let the renderer stream data.
- Provide fallbacks – Always set
.placeholderso the UI communicates status during network hiccups (future versions of the component will expose explicit failure hooks). - Reuse sources – Clone
Video/LivePhotoSourcehandles instead of recreating them in every recomposition. - Respect platform capabilities – Some backends may not implement Live Photos or media pickers yet. Feature-gate your UI or supply alternate paths.
With these components you can build media-heavy experiences—galleries, video players, immersive feeds—while keeping the code declarative and reactive.
Navigation
waterui::navigation provides lightweight primitives for stacks, bars, and links that map to native
navigation controllers on each backend. This chapter covers the building blocks so you can push and
pop screens declaratively.
NavigationView
Wrap your content in NavigationView (or the navigation helper) to display a persistent bar with
title, actions, and colour configuration.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::navigation::{Bar, NavigationView};
use waterui::reactive::binding;
pub fn inbox() -> impl View {
let unread = binding(42);
NavigationView {
bar: Bar {
title: text!("Inbox ({unread})"),
color: constant(Color::srgb(16, 132, 255)),
hidden: constant(false),
},
content: vstack((
text("Recent messages"),
scroll(list_of_threads()),
))
.anyview(),
}
}
}
Shorter alternative:
#![allow(unused)]
fn main() {
use waterui::navigation::navigation;
pub fn settings() -> impl View {
navigation("Settings", settings_list())
}
}
NavigationLink
Links describe push-style transitions. Provide a label view and a closure that builds the destination.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::navigation::NavigationLink;
fn thread_row(thread: Thread) -> impl View {
NavigationLink::new(
text!("{thread.subject}"),
move || navigation(thread.subject.clone(), thread_detail(thread.clone())),
)
}
}
Backends render platform-specific affordances (chevrons on Apple platforms, row highlighting on GTK and Web). Because the destination builder is a closure, WaterUI only instantiates the view after the link is activated.
Programmatic Navigation
For complex flows use a binding that tracks the active route:
#![allow(unused)]
fn main() {
use waterui::reactive::binding;
use waterui::navigation::{navigation, NavigationPath, NavigationStack};
#[derive(Clone)]
enum Step {
Welcome,
Address,
Summary,
}
pub fn wizard() -> impl View {
let path = binding(vec![Step::Welcome]);
let nav_path = path.map(NavigationPath::from);
NavigationStack::with(nav_path, navigation("Wizard", welcome_screen()))
.destination(|step| match step {
Step::Welcome => navigation("Welcome", welcome_screen()),
Step::Address => navigation("Address", address_screen()),
Step::Summary => navigation("Summary", summary_screen()),
})
}
}
Updating the vector (push/pop) automatically syncs with the rendered stack.
Tabs (Experimental)
The waterui::navigation::tab module exposes a minimal tab bar API:
#![allow(unused)]
fn main() {
use waterui::navigation::tab::{Tab, Tabs};
pub fn home_tabs() -> impl View {
Tabs::new(vec![
Tab::new("Home", || home_screen()),
Tab::new("Discover", || discover_screen()),
Tab::new("Profile", || profile_screen()),
])
}
}
The API is still stabilising; expect future versions to add badges, per-tab navigation stacks, and lazy loading hooks.
Best Practices
- Keep destination builders side-effect free; perform work in handlers and let bindings drive navigation changes.
- Use environment values (
env::use_env) to provide routers or analytics so every link can report navigation events centrally. - Combine navigation with the animation chapter to customize transitions once backends expose those hooks.
With these primitives you can express full navigation stacks declaratively without reaching for imperative routers.
Gestures and Haptics
Many interactions start with a gesture—tap, drag, long press—and finish with tactile feedback. The
waterui::gesture module lets you describe platform-agnostic gestures, observe them, and react with
handlers that can trigger animations, network work, or custom haptics.
Observing Gestures
Attach GestureObserver to any view via ViewExt::event or by wrapping the view in helper widgets.
The observer describes the gesture to track plus a handler that executes when it fires.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::gesture::{Gesture, GestureObserver, TapGesture};
pub fn tappable_card() -> impl View {
let gesture = Gesture::from(TapGesture::new());
text("Favorite")
.padding()
.metadata(GestureObserver::new(gesture, || println!("Tapped!")))
}
}
Gesture handlers support extractors just like buttons. For example, extract the TapEvent payload to
read the tap location:
#![allow(unused)]
fn main() {
use waterui_core::extract::Use;
use waterui::gesture::TapEvent;
GestureObserver::new(TapGesture::new(), |Use(event): Use<TapEvent>| {
println!("Tapped at {}, {}", event.location.x, event.location.y);
})
}
Combining Gestures
Gestures compose via .then(...). The following snippet waits for a successful long press before
enabling drag updates:
#![allow(unused)]
fn main() {
use waterui::gesture::{DragGesture, Gesture, LongPressGesture};
let gesture = LongPressGesture::new(500)
.then(Gesture::from(DragGesture::new(5.0)));
}
Backends recognise the combined structure and only feed drag events once the long press completes.
Drag, Magnification, and Rotation
Drag-related gestures surface DragEvent payloads. Magnification (pinch-to-zoom) and rotation
behave similarly with MagnificationEvent / RotationGesture.
#![allow(unused)]
fn main() {
use waterui_core::extract::Use;
use waterui::gesture::{DragEvent, DragGesture, GesturePhase};
GestureObserver::new(DragGesture::new(5.0), |Use(event): Use<DragEvent>| {
match event.phase {
GesturePhase::Started => println!("Drag started"),
GesturePhase::Updated => println!("Translation {:?}", event.translation),
GesturePhase::Ended => println!("Released"),
GesturePhase::Cancelled => println!("Cancelled"),
}
})
}
Store the translation in a binding to build sortable lists, draggable cards, or zoomable canvases.
Integrating Haptics
WaterUI deliberately keeps haptic APIs in user space so you can tailor feedback per platform. Expose
a Haptics service through the environment and trigger it inside gesture handlers:
#![allow(unused)]
fn main() {
use waterui::env::Environment;
use waterui_core::extract::Use;
pub trait Haptics: Clone + 'static {
fn impact(&self, style: ImpactStyle);
}
#[derive(Clone)]
struct ImpactStyle;
pub fn haptic_button() -> impl View {
button("Favorite")
.action(|Use(haptics): Use<impl Haptics>| {
haptics.impact(ImpactStyle);
})
}
}
Install a platform-specific implementation (e.g., using UIKit’s UIImpactFeedbackGenerator or
Android’s Vibrator) near your entry point:
#![allow(unused)]
fn main() {
Environment::new().with(MyHaptics::default());
}
Because gesture observers share the same extractor system, you can fire haptics when a drag completes or a long press begins without additional glue.
Best Practices
- Keep gesture handlers pure and fast—hand off async work to
task. - Use
.thento avoid gesture conflicts (e.g., drag vs. tap) so backends receive deterministic instructions. - Provide fallbacks for platforms that lack certain gestures; wrap gesture-sensitive views in conditionals that present alternative affordances.
- Treat haptics as optional. If no provider is registered, default to no-op implementations rather than panicking.
With declarative gestures and environment-driven haptics you can build nuanced, platform-appropriate interaction models without sacrificing portability.
Fetching Data
Most apps talk to the network. WaterUI doesn’t impose a networking stack—you can use reqwest,
surf, or any HTTP client—but it provides ergonomic bridges between async tasks and reactive
bindings so UI updates remain declarative.
Wiring Requests into Bindings
The pattern looks like this:
- Store view state in bindings (
Binding<Option<T>>,Binding<FetchState>). - Trigger an async task from a button,
on_appear, or watcher. - Update the binding once the request resolves; the UI reacts automatically.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::{binding, Binding};
use waterui::task::spawn;
#[derive(Clone, Debug)]
enum FetchState<T> {
Idle,
Loading,
Loaded(T),
Failed(String),
}
pub fn weather_card() -> impl View {
let state: Binding<FetchState<Weather>> = binding(FetchState::Idle);
vstack((
text!("Weather"),
content(state.clone()),
button("Refresh").action(move || fetch_weather(state.clone())),
))
}
fn content(state: Binding<FetchState<Weather>>) -> impl View {
match state.get() {
FetchState::Idle => text("Tap refresh"),
FetchState::Loading => text("Loading…"),
FetchState::Loaded(ref data) => text!("{}°C", data.temperature),
FetchState::Failed(ref err) => text!("Error: {err}"),
}
}
fn fetch_weather(state: Binding<FetchState<Weather>>) {
state.set(FetchState::Loading);
spawn(async move {
match reqwest::get("https://api.example.com/weather").await {
Ok(response) => match response.json::<Weather>().await {
Ok(weather) => state.set(FetchState::Loaded(weather)),
Err(err) => state.set(FetchState::Failed(err.to_string())),
},
Err(err) => state.set(FetchState::Failed(err.to_string())),
}
});
}
}
spawn uses the executor configured for your backend (Tokio by default). Because bindings are
clonable handles, you can move them into the async block safely.
Caching and Suspense
Wrap network bindings in Computed<Option<T>> or Suspense for placeholder states:
#![allow(unused)]
fn main() {
use waterui::widget::suspense::Suspense;
Suspense::new(
state.map(|state| matches!(state, FetchState::Loaded(_))),
|| content(state.clone()),
|| text("Loading…"),
);
}
Error Handling Patterns
- Retry buttons – When the binding holds
Failed, show a retrybuttonnext to the error text. - Timeouts – Combine
tokio::time::timeoutwith thereqwestfuture and set the binding to a descriptive error message. - Offline mode – Mirror network results into a persistent store (e.g., SQLite) and hydrate the binding immediately on launch; fire background tasks to refresh when the network becomes available.
Platform Constraints
Backend targets run inside their respective sandboxes:
- Apple / iOS – Requests require ATS-compliant endpoints (https by default). Update your Xcode-generated manifest if you need exceptions.
- Android – Remember to add network permissions to the generated
AndroidManifest.xml. - Web – Consider CORS. Fetching from the browser requires appropriate headers from the server.
WaterUI stays out of the way—you bring the HTTP client—but the combination of bindings and tasks keeps the state management predictable and testable.
Animation
The animation system lives in waterui::animation and is wired into every Signal via the
AnimationExt trait. Instead of imperatively driving tweens, you attach animation metadata to the
binding that powers your view, and the renderer interpolates between old and new values.
Animated Bindings
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::animation::Animation;
use core::time::Duration;
pub fn fading_badge() -> impl View {
let visible = binding(true);
let opacity = visible
.map(|flag| if flag { 1.0 } else { 0.0 })
.with_animation(Animation::ease_in_out(Duration::from_millis(250)));
text!("Opacity: {opacity:.0}")
}
}
.animated()applies the platform-default animation..with_animation(Animation::Linear(..))lets you choose easing..with_animation(Animation::spring(stiffness, damping))yields physically based motion.
When the binding changes, the animation metadata travels with it through map, zip, or any other
combinator.
Coordinated Transitions
Attach animation metadata to multiple bindings and update them together:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::animation::Animation;
let offset = binding((0.0_f32, 0.0_f32));
let font_size = binding(14.0_f32);
let offset = offset.with_animation(Animation::spring(200.0, 15.0));
let font_size = font_size.animated();
vstack((text!("offset: {offset:?}, size: {font_size}"),))
}
Calling offset.set((0.0, 50.0)) and opacity.set(1.0) triggers both animations concurrently.
Testing and Debugging
- Run with the
WATERUI_ANIMATION=offenvironment variable (or a custom hook) to disable animations during snapshot testing. - When a view fails to animate, ensure the binding changed (animations only run when the value differs) and that you applied the animation to the reactive value, not the literal view.
Animations are declarative in WaterUI—keep state updates pure and describe how values should transition. The runtime handles frame-by-frame interpolation on every platform.
Suspense and Async Rendering
waterui::widget::Suspense bridges async work and declarative views. Wrap any async
builder and Suspense takes care of launching the future, showing a placeholder, cancelling the task
when the view disappears, and re-rendering once data arrives.
Basic Usage
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::Suspense;
async fn load_profile() -> impl View {
text("Ada Lovelace")
}
pub fn profile() -> impl View {
Suspense::new(load_profile())
.loading(|| text("Loading profile…"))
}
}
Suspense::new(future)starts the future on first render..loading(|| …)provides a custom placeholder. If omitted, Suspense looks forDefaultLoadingViewin the environment.- The async builder receives a cloned
Environment, so it can read services or locale info during loading.
Default Loading View
Install a global placeholder so every Suspense shares the same skeleton:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::suspense::DefaultLoadingView;
let env = Environment::new().with(DefaultLoadingView::new(|| text("Please wait…")));
}
Wrap your root view with .with_env(env) and all suspense boundaries inherit it.
Caching Results
Suspense rebuilds the future whenever the view is reconstructed. Cache results in bindings if you need persistence:
#![allow(unused)]
fn main() {
let user = binding(None);
fn user_view(user: Binding<Option<User>>) -> impl View {
when(user.map(|u| u.is_some()), || profile_body(user.clone()))
.or(|| Suspense::new(fetch_user(user.clone())))
}
}
fetch_user updates the binding when the network request resolves; the when branch takes over and
the placeholder disappears.
Error Handling
Wrap async results in Result and convert errors into ErrorView:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::error::Error;
Suspense::new(async {
match fetch_feed().await {
Ok(posts) => feed(posts).anyview(),
Err(err) => Error::new(err).anyview(),
}
})
}
You can also stack Suspense boundaries—an outer one fetching user data, an inner one fetching related content—to keep parts of the UI responsive.
Cancellation and Restart
Dropping a Suspense (e.g., navigating away) cancels the running future. Re-rendering recreates the future from scratch. Use this to restart long-polling or subscribe/unsubscribe from streams based on visibility.
Suspense keeps async ergonomic: describe loading and loaded states declaratively, and let WaterUI handle the lifecycles.
Error Handling
WaterUI does not force a bespoke error type. Instead, it lets you turn any std::error::Error into
a renderable view via waterui::widget::error::Error, and customize how errors look using environment
hooks.
From Errors to Views
Wrap errors with Error::new whenever a Result fails:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::error::Error;
fn user_profile(id: u64) -> impl View {
match load_user(id) {
Ok(user) => profile_card(user),
Err(err) => Error::new(err).anyview(),
}
}
}
Error implements View, so you can drop it anywhere inside a stack or navigation view. The
default renderer simply prints the error via Display.
Customizing the Presentation
Inject a DefaultErrorView into the environment to override how errors render globally:
#![allow(unused)]
fn main() {
use waterui::widget::error::DefaultErrorView;
let env = Environment::new().with(DefaultErrorView::new(|err| {
vstack((
text!("⚠️ Something went wrong").bold(),
text!("{err}").foreground(Color::srgb(200, 80, 80)),
button("Retry").action(|| task(retry_last_request())),
))
.padding()
}));
app_root().with_env(env)
}
Now every Error produced inside the tree uses this layout automatically.
Inline Result Helpers
If you prefer chaining, use the error_view helper to map Result<T, E> into either a view or an
Error:
#![allow(unused)]
fn main() {
use waterui::widget::error::ResultExt;
fn result_view<T, E>(result: Result<T, E>, render: impl FnOnce(T) -> AnyView) -> AnyView
where
E: std::error::Error + 'static,
{
match result {
Ok(value) => render(value),
Err(err) => Error::new(err).anyview(),
}
}
}
Use it when composing lists or complex layouts so you do not repeat match expressions everywhere.
Contextual Actions
Because error builders receive the Environment (if you use a custom view that captures it), you can extract services. Or, use use_env inside your error view builder:
#![allow(unused)]
fn main() {
DefaultErrorView::new(|err| {
use_env(move |env: &Environment| {
let telemetry = env.get::<Telemetry>().cloned();
if let Some(t) = telemetry {
t.record_error(&err);
}
text!("{err}")
})
})
}
Pairing with Suspense
When fetching data asynchronously, wrap the result inside Suspense and convert failures into
Error instances. Users get a consistent loading/error pipeline without sprinkling Result
logic throughout the UI.
Consistent, informative error displays keep apps trustworthy. Centralize styling via
DefaultErrorView and lean on Error::new wherever fallible operations occur.
Plugin
WaterUI’s plugin system is built at the top of environment system.
#![allow(unused)]
fn main() {
pub trait Plugin: Sized + 'static {
/// Installs this plugin into the provided environment.
///
/// This method adds the plugin instance to the environment's storage,
/// making it available for later retrieval.
///
/// # Arguments
///
/// * `env` - A mutable reference to the environment
fn install(self, env: &mut Environment) {
env.insert(self);
}
/// Removes this plugin from the provided environment.
///
/// # Arguments
///
/// * `env` - A mutable reference to the environment
fn uninstall(self, env: &mut Environment) {
env.remove::<Self>();
}
}
}
Plugins are just values stored inside the environment. Because they are regular Rust structs, you can bundle services (network clients, analytics, feature flags) and install them once near your entry point.
Example: i18n
waterui_i18n::I18n ships as a plugin that rewrites Text views based on the active locale.
#![allow(unused)]
fn main() {
use waterui_i18n::I18n;
use waterui::prelude::*;
use waterui::text::locale::Locale;
fn install_i18n(env: &mut Environment) {
let mut i18n = I18n::new();
i18n.insert("en", "greeting", "Hello");
i18n.insert("fr", "greeting", "Bonjour");
i18n.install(env); // stores translations + hook
env.insert(Locale("fr".into())); // pick initial locale
}
}
Every text!("greeting") now passes through the hook registered during install, swapping the
content with the localized string.
Building Your Own Plugin
- Create a struct that owns the resources you need.
- Implement
Plugin(the default methods may be enough) and optionally install environment hooks. - Insert helper extractors so views/handlers can access the plugin at runtime.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::plugin::Plugin;
use waterui::impl_extractor;
#[derive(Clone)]
pub struct Telemetry { /* ... */ }
impl Plugin for Telemetry {
fn install(self, env: &mut Environment) {
env.insert(self);
}
}
impl_extractor!(Telemetry);
}
Now handlers can request Use<Telemetry> exactly like bindings or environment values.
Lifecycle Hooks
Override uninstall when the plugin must clean up:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::plugin::Plugin;
struct SessionManager;
impl SessionManager {
fn shutdown(&self) {}
}
impl Plugin for SessionManager {
fn uninstall(self, env: &mut Environment) {
self.shutdown();
env.remove::<Self>();
}
}
}
Although most plugins live for the entire lifetime of the app, uninstall hooks are handy in tests or when swapping environments dynamically (multi-window macOS apps, for example).
Plugins keep cross-cutting concerns modular. Keep their public surface small, expose access through extractors, and leverage environment hooks to integrate with the rest of the view tree.
Accessibility
Every WaterUI control ships with reasonable accessibility metadata, but composite views sometimes
need extra context. The waterui::accessibility module exposes helpers for customizing labels,
roles, and states so assistive technologies accurately describe your UI.
Labels
AccessibilityLabel overrides the spoken name of a view:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
pub fn destructive_button() -> impl View {
button("🗑️").a11y_label("Delete draft")
}
}
The emoji-only button now announces “Delete draft” to VoiceOver/TalkBack users.
Roles
When building custom widgets, mark their semantic role:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::accessibility::AccessibilityRole;
pub fn nav_drawer() -> impl View {
vstack((/* ... */))
.a11y_role(AccessibilityRole::Navigation)
}
}
Available roles cover buttons, list items, landmarks, menus, tabs, sliders, and more. Choose the one that matches the behaviour of your component.
States
AccessibilityState communicates dynamic information (selected, expanded, busy, etc.). Currently,
WaterUI exposes these through specific view modifiers or metadata properties, and they are
often handled automatically by built-in controls.
Patterns
- Lives regions – For toast notifications or progress updates, install an environment hook that
announces changes via
AccessibilityLabelor a platform-specific API. - Custom controls – When composing primitive views into a new control, set the label/role on the container instead of each child to avoid duplicate announcements.
- Reduced motion – Read the platform’s accessibility settings (exposed to backends via the environment) and adjust animation hooks accordingly.
Accessibility metadata is cheap: it is just view metadata that backends translate into the native API. Audit new components by navigating with VoiceOver/TalkBack and apply overrides whenever the defaults fall short.
Internationalization
Translation support lives in the optional waterui_i18n crate. It installs as a plugin, reroutes
Text content through a translation map, and exposes helpers for locale-aware formatting.
Installing the Plugin
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui_i18n::I18n;
use waterui::text::locale::Locale;
fn env_with_i18n() -> Environment {
let mut i18n = I18n::new();
i18n.insert("en", "greeting", "Hello");
i18n.insert("fr", "greeting", "Bonjour");
let mut env = Environment::new();
i18n.install(&mut env);
env.insert(Locale("fr".into()));
env
}
}
Every text!("greeting") now resolves through the i18n map and emits the French string.
Dynamic Locales
Locales are reactive—store a Binding<Locale> in the environment and update it when the user picks
a new language. Because the plugin uses computed signals, existing text views update automatically.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::text::locale::Locale;
pub fn switch_locale(env: &mut Environment) {
let locale = binding(Locale("en".into()));
env.insert(locale.clone());
button("Switch to French").action_with(&locale, |loc| loc.set(Locale("fr".into())));
}
}
Parameterized Strings
Translations can include placeholders; format values client-side before passing them to text!, or
store template keys in the map and handle interpolation in your code:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui_i18n::I18n;
use waterui::text::locale::Locale;
pub fn formatted_count() -> impl View {
let mut i18n = I18n::new();
i18n.insert("en", "items_label", "items");
let locale = Locale("en".into());
let item_count = 3;
// In a real app, you would fetch the translation dynamically
let label = i18n.get(&locale.0, "items_label");
text!("{count} {text}", count = item_count, text = label)
}
}
Future releases will expand the formatter API so you can define ICU-style templates directly in the translation map.
Pluralization and Formatting
For locale-aware numbers and dates, reach for waterui::text::locale:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::text::locale::{DateFormatter, Locale};
use time::Date;
pub fn formatted_date() -> impl View {
let date = binding(Date::from_calendar_date(2025, 1, 1).unwrap());
let formatter = DateFormatter { locale: Locale("en-US".into()) };
Text::format(date, formatter)
}
}
Pair these with I18n so both phrasing and formatting respect the user’s locale.
Internationalization is just an environment plugin—install it once, keep translations in sync with your keys, and the rest of WaterUI’s text pipeline stays reactive and type-safe.
Canvas
WaterUI ships with a first-party immediate-mode canvas API via waterui::graphics::Canvas. It
uses Vello for high-performance GPU rendering of 2D vector graphics.
Basic Usage
The canvas API provides a drawing context that lets you stroke and fill shapes:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::graphics::Canvas;
use waterui::graphics::kurbo::{Circle, Rect, Point};
use waterui::graphics::peniko::Color;
fn custom_drawing() -> impl View {
Canvas::new(|ctx| {
let center = ctx.center();
// Fill a circle
ctx.fill(
Circle::new(center, 50.0),
Color::RED,
);
// Stroke a rectangle
ctx.stroke(
Rect::from_origin_size(Point::new(10.0, 10.0), (100.0, 50.0)),
Color::BLUE,
2.0,
);
})
.height(200.0) // Constrain height if needed
}
}
The drawing closure runs every frame (or when the view needs redrawing). waterui::graphics
re-exports types from kurbo (geometry) and peniko (colors/brushes).
Reactive Drawing
To make the canvas reactive, capture bindings in the closure or use Dynamic::watch to
rebuild the canvas when state changes.
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::graphics::Canvas;
use waterui::graphics::kurbo::Circle;
use waterui::graphics::peniko::Color;
fn reactive_circle(radius: Binding<f64>) -> impl View {
// Rebuild the canvas when radius changes
Dynamic::watch(radius, |r| {
Canvas::new(move |ctx| {
ctx.fill(
Circle::new(ctx.center(), r),
Color::GREEN,
);
})
})
}
}
Advanced Rendering
For more complex needs, waterui::graphics::GpuSurface allows direct access to wgpu
for custom render pipelines, while ShaderSurface simplifies using WGSL fragment shaders.
See the Shaders chapter for details.
Shaders
WaterUI supports custom GPU shaders via waterui::graphics::ShaderSurface. You can write WGSL
fragment shaders that render directly to the view’s surface.
Using ShaderSurface
The easiest way to use shaders is with the shader! macro, which loads a WGSL file at compile time:
#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::graphics::shader;
fn flame_effect() -> impl View {
shader!("flame.wgsl")
.width(400.0).height(500.0)
}
}
Or define the shader inline:
use waterui::graphics::ShaderSurface;
fn gradient() -> impl View {
ShaderSurface::new(r#"
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(uv.x, uv.y, 0.5, 1.0);
}
"#)
}
Built-in Uniforms
ShaderSurface automatically prepends the following uniform definition (and a full-screen vertex shader) to your code. Do not redefine this struct or variable in your WGSL file.
// Auto-generated prelude (available to your shader):
struct Uniforms {
time: f32, // Elapsed time in seconds
resolution: vec2<f32>, // Surface size in pixels
_padding: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
Your shader code should strictly define the fragment entry point:
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let t = uniforms.time;
// ...
}
uv coordinates are normalized (0.0 to 1.0).
Advanced GPU Rendering
ShaderSurface is limited to single-pass fragment shaders. For full control over the rendering pipeline (custom vertex shaders, multiple passes, compute shaders), implement the GpuRenderer trait and use GpuSurface.
#![allow(unused)]
fn main() {
use waterui::graphics::{GpuRenderer, GpuSurface, GpuContext, GpuFrame};
struct MyRenderer;
impl GpuRenderer for MyRenderer {
fn setup(&mut self, ctx: &GpuContext) {
// Create pipelines, buffers, bind groups...
}
fn render(&mut self, frame: &GpuFrame) {
// Create encoder, render passes, submit to queue...
}
}
fn custom_render() -> impl View {
GpuSurface::new(MyRenderer)
}
}
See the flame example for a complete implementation of a multi-pass HDR renderer using GpuSurface and wgpu.
How WaterUI Renders
WaterUI renders your application by translating the Rust view tree into native platform widgets (SwiftUI on Apple platforms, Jetpack Compose on Android). This process is mediated by a C FFI layer (waterui-ffi) that allows the native runtime to traverse and observe the Rust view hierarchy.
The Rendering Pipeline
- Tree Construction: When your app starts, WaterUI creates the initial view tree defined in your
appfunction. This tree is composed ofViewstructs. - FFI Traversal: The native backend (written in Swift or Kotlin) holds a pointer to the root Rust view. It calls
waterui_view_idto identify the type of the view. - Leaf vs. Composite:
- Raw Views (Leafs): If the view is a “Raw View” (like
Text,Button,VStack), the backend downcasts it usingwaterui_force_as_*functions to extract the configuration (e.g., the text string, the button action). It then creates the corresponding native widget (e.g.,SwiftUI.Text). - Composite Views: If the view is a custom component (like your
MyView), the backend callswaterui_view_body, which triggers theView::bodymethod in Rust. This expands the view into its underlying structure. The backend then repeats the process for the returned body.
- Raw Views (Leafs): If the view is a “Raw View” (like
- Reactive Updates: When a view uses a reactive value (like a
BindingorComputed), the native backend registers a watcher. When the value changes in Rust, the watcher notifies the native backend, triggering a UI update for that specific component.
The FFI Layer
The waterui-ffi crate is the bridge between Rust and the host platform. It exports:
- Entry Points:
waterui_initandwaterui_appto bootstrap the runtime. - Type Identification: A stable 128-bit type ID system (
WuiTypeId) that allows the native side to recognize Rust types, even across dynamic library reloads. - Opaque Types: wrappers like
WuiAnyViewandWuiEnvthat allow passing complex Rust objects as pointers. - Property Accessors: C-compatible functions to read properties from configuration structs (e.g.,
waterui_read_binding_i32).
Talking to Native
waterui_core::Native<T> is the wrapper for platform-specific content. Views that implement the NativeView trait can be wrapped in Native<T>. This signals to the framework that this node should be handled by the platform backend directly, rather than being expanded further in Rust.
For example, Text is a NativeView. When you write text("Hello"), it creates a Text struct. The FFI layer exposes this to Swift/Kotlin, which then renders a system label.
Diffing and Identity
For lists and collections, WaterUI uses the Identifable trait. When a list changes, the framework uses these IDs to calculate a diff. This information is passed to the native backend’s list implementation (e.g., ForEach in SwiftUI), ensuring efficient updates and animations for insertions, deletions, and moves.
Performance Implications
- Logic in Rust: Your business logic, state management, and layout calculations happen in Rust.
- Rendering in Native: The expensive work of drawing pixels, text layout, and compositing is handled by the OS’s highly optimized UI toolkit.
- Boundary Crossing: Crossing the FFI boundary has a small cost. WaterUI minimizes this by only crossing when necessary (initial build and reactive updates) and by using efficient pointer-based accessors.
Hot Reload Internals
Hot reload allows you to modify your Rust code and see changes instantly in the running application without restarting the app or losing state. WaterUI achieves this by compiling your code into a dynamic library (.dylib, .so, or .dll) and injecting it into the running process.
Architecture
The system consists of three parts:
- The CLI (
waterui-cli): Watches your source files, rebuilds the project as a dynamic library when changes are detected, and hosts a WebSocket server. - The Runtime (
HotReloadView): A special view component in your app that connects to the CLI, downloads the new library, and swaps the view pointer. - The Macro (
#[hot_reload]): An attribute macro that instruments functions to be individually reloadable.
Per-Function Hot Reload
You can mark specific view functions with #[hot_reload]. This wraps the function body in a HotReloadView.
#![allow(unused)]
fn main() {
#[hot_reload]
fn my_feature() -> impl View {
vstack((
text("Edit me!"),
button("Click me", || println!("Clicked")),
))
}
}
The macro generates a unique C-exported symbol for this function (e.g., waterui_hot_reload_my_feature).
The Reload Process
- Change Detection: The CLI detects a file save.
- Rebuild: It runs
cargo buildwith thewaterui_hot_reload_libconfiguration. - Broadcast: The new binary is broadcast over WebSocket to the running app.
- Load: The app writes the binary to a temporary file and loads it using
libloading. - Swap: The
HotReloadViewlooks up its specific symbol in the new library. If found, it calls the function to get the new view structure and replaces its current content.
State Preservation
Because only the view construction logic is reloaded, the underlying state (held in Bindings or the Environment) is preserved. The new view structure simply re-binds to the existing state.
Limitations
- Symbol Stability: The function signature must return
impl View. - Global State: Changes to global state initialization or
mainentry points usually require a full restart. - Struct Layout: Changing the fields of a struct that is shared between the main app and the hot-reloaded library can cause undefined behavior due to ABI mismatches. It is safest to tweak view logic and local variables.
Layout Internals
WaterUI implements a custom layout protocol that runs in Rust but coordinates with the native backend. This ensures consistent layout logic across all platforms while using native widgets.
The Protocol
The layout system is based on a two-phase “Propose and Response” model, defined by the Layout trait in waterui-core.
#![allow(unused)]
fn main() {
pub trait Layout: Debug {
fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size;
fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect>;
}
}
1. Sizing Phase (size_that_fits)
The parent container proposes a size to the layout. The layout then negotiates with its children to determine its own ideal size.
ProposalSizecan haveOption<f32>dimensions.Nonemeans “unconstrained” (ask for ideal size), whileSome(val)means “constrained” (fit within this size).
2. Placement Phase (place)
Once the size is determined, the parent tells the layout its final bounds. The layout then calculates the Rect (position and size) for each child.
SubView Proxy
Layouts do not interact with Views directly. They interact with SubView proxies. This abstracts the differences between Rust views and native widgets.
#![allow(unused)]
fn main() {
pub trait SubView {
fn size_that_fits(&self, proposal: ProposalSize) -> Size;
fn stretch_axis(&self) -> StretchAxis;
fn priority(&self) -> i32;
}
}
The native backend implements SubView via FFI. When Rust calls size_that_fits on a SubView, it triggers a call to the native platform to measure the actual text or widget.
Stretch Axis
Views declare how they want to behave when there is extra space using StretchAxis:
None: Content-sized (e.g., Text, Button).Horizontal: Expands width (e.g., TextField, Slider).Vertical: Expands height.Both: Fills all space (e.g., Color, ScrollView).MainAxis/CrossAxis: Context-dependent (e.g., Spacer, Divider).
Containers like VStack and HStack use this information to distribute space proportionally among flexible children.
Logical Pixels
All layout calculations in Rust use Logical Pixels (points).
- 1 logical pixel = 1 point in iOS/macOS.
- 1 logical pixel = 1 dp in Android.
The native backend handles the conversion to physical pixels for rendering. This ensures that a width(100.0) looks the same physical size on all devices.
Writing Custom Layouts
To create a custom layout:
- Implement the
Layouttrait. - Wrap it in a
FixedContainer(for static children) orLazyContainer(for dynamicForEachchildren).
#![allow(unused)]
fn main() {
struct MyLayout;
impl Layout for MyLayout {
// ... implement size_that_fits and place
}
pub fn my_layout(content: impl View) -> impl View {
FixedContainer::new(MyLayout, content)
}
}
Best Practices for Library Authors
When building reusable component libraries for WaterUI, follow these patterns to ensure your components are idiomatic, performant, and easy to use.
1. Use the configurable! Macro
For views that have properties (like Text, Button, Slider), use the configurable! macro. This generates the boilerplate for ConfigurableView, ViewConfiguration, and the builder pattern methods.
#![allow(unused)]
fn main() {
use waterui_core::configurable;
pub struct BadgeConfig {
pub label: AnyView,
pub color: Computed<Color>,
}
configurable!(Badge, BadgeConfig);
impl Badge {
pub fn new(label: impl View) -> Self {
Self(BadgeConfig {
label: AnyView::new(label),
color: Color::RED.into_computed(),
})
}
// Builder method
pub fn color(mut self, color: impl IntoComputed<Color>) -> Self {
self.0.color = color.into_computed();
self
}
}
}
2. Accept IntoComputed / IntoSignal
Allow users to pass either static values or reactive bindings by accepting impl IntoComputed<T> or impl IntoSignal<T>.
#![allow(unused)]
fn main() {
pub fn color(mut self, color: impl IntoComputed<Color>) -> Self {
self.0.color = color.into_computed();
self
}
}
This lets users write .color(Color::RED) (static) or .color(my_binding) (reactive) interchangeably.
3. Use Environment for Context
Avoid passing global configuration (like themes) through arguments. Use the Environment instead. Define a struct for your config and implement Extractor.
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct MyTheme {
border_radius: f32,
}
// In your view
use_env(|Use(theme): Use<MyTheme>| {
// use theme.border_radius
})
}
4. Leverage ViewExt
The ViewExt trait provides common modifiers like .padding(), .background(), and .gesture(). Implement your component as a View so users can chain these modifiers.
5. Favor Composition
Build complex components by composing existing primitives (VStack, Text, Shape) rather than trying to implement custom rendering logic, unless absolutely necessary. Composition ensures your component benefits from the native backend’s optimizations and accessibility features.
6. Testing
Use the waterui-macros crate to create testable forms and view structures. Ensure your component works with the reactive system by testing it with Binding updates.
Philosophy
WaterUI’s design balances three goals:
- Native-First – We render through native toolkits (SwiftUI, Jetpack Compose) so apps look, feel, and behave like first-class citizens on every platform. We believe users can tell the difference, and platform conventions matter.
- Reactive, Not Virtual DOM – We use fine-grained reactivity (
nami). Changes in state propagate directly to the specific properties or views that need updating, without diffing a virtual tree. - Rust all the way – You write your UI logic, layout, and state management in Rust. The FFI layer handles the translation to the platform, but you stay in Rust.
Why Native?
Drawing pixels (like Flutter or some game engines) gives you control, but you lose platform features for free: accessibility, text selection, native context menus, keyboard navigation, and OS-specific gestures. By wrapping native widgets, WaterUI ensures your app improves as the OS improves.
Why Reactive?
Immediate mode GUIs are great for games, but retaining state and minimizing CPU usage is crucial for mobile apps. A reactive graph allows us to wake up only when necessary, saving battery and keeping the UI smooth.
The “Water” Metaphor
Just as water takes the shape of its container, WaterUI apps adapt to the platform they run on. A Toggle becomes a UISwitch on iOS and a Switch on Android. A NavigationStack becomes a UINavigationController or a NavHost. You describe the intent, and WaterUI handles the adaptation.
Automation and Troubleshooting
Use this appendix whenever you need to integrate WaterUI workflows into CI or recover from a broken toolchain.
Deterministic CLI Runs
Pass --format json (or --json) to every water command to disable prompts. Provide --yes, --platform, and --device flags explicitly so scripts never block. Capture the resulting JSON to feed dashboards or orchestrators.
water devices --json | jq '.[0].name'
water run --platform web --project water-demo --json --yes
Keeping Examples Green
All chapters rely on mdbook test and cargo test inside the tutorial workspace. Run both locally before opening a pull request:
mdbook test
cargo test -p waterui-tutorial-book
mdbook test compiles each code fence, so avoid ignore blocks—use no_run only when the example cannot execute inside a test harness.
Diagnosing Platforms
water doctor --fixperforms end-to-end checks across Rust, Swift, Android, and the web toolchain. Keep its output in CI logs for future reference.water clean --yesremoves Cargo, Gradle, and DerivedData caches when subtle build issues appear.water build android --release --no-sccacheis helpful when debugging native toolchains separately from packaging.
Use these recipes whenever an exercise references platform-specific behaviour or when you need to automate deployments explained in earlier chapters.