Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: Write once, deploy everywhere - desktop, web, mobile, embedded
  • 🦀 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: the View trait, 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)

CratePathHighlights
wateruiwaterui/Facade crate that re-exports the rest of the stack plus hot reload, tasks, and metadata helpers.
waterui-corewaterui/coreView, Environment, resolver system, plugins, hooks, and low-level layout traits.
waterui-controlswaterui/components/controlsButtons, toggles, sliders, steppers, text fields, and shared input handlers.
waterui-layoutwaterui/components/layoutStacks, frames, grids, scroll containers, padding, and alignment primitives.
waterui-textwaterui/components/textThe Text view, typography helpers, and localization-ready formatting APIs.
waterui-mediawaterui/components/mediaPhoto/video/Live Photo renderers plus media pickers.
waterui-navigationwaterui/components/navigationNavigation bars, stacks, programmatic paths, and tab containers.
waterui-formwaterui/components/formFormBuilder derive macro, color pickers, secure fields, and validation helpers.
waterui-graphicswaterui/components/graphicsExperimental drawing primitives and utilities that feed the canvas/shader chapters.
waterui-render-utilswaterui/render_utilsShared GPU/device glue used by multiple backends and native wrappers.
waterui-derivewaterui/deriveProc-macros (FormBuilder, View helpers) consumed by the higher-level crates.
waterui-cliwaterui/cliThe water binary you installed earlier for scaffolding, running, and packaging apps.
waterui-ffiwaterui/ffiFFI bridge used by native runners (Swift, Kotlin, C) plus hot reload integration.
waterui-color, waterui-str, waterui-urlwaterui/utils/{color,str,url}Utility crates for colors, rope strings, and URL handling shared by every component.
windowwaterui/windowCross-platform window/bootstrapper that spins up render loops for each backend.
demowaterui/demoShowcase 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

  1. Clone the repository and run mdbook serve so you can edit and preview chapters locally.
  2. Explore the source under waterui/ whenever you want to dig deeper into a crate.
  3. 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 Workflow

WaterUI ships a first-party CLI named water. Install it from the workspace checkout so that every example in this book can be scaffolded, run, and packaged without leaving your terminal.

cargo install --path cli --locked

Scaffold a Project

Create a new playground app with the runtime backends you need:

water create --name "Water Demo" \
  --bundle-identifier com.example.waterdemo \
  --backend swiftui --backend android --backend web \
  --yes --dev
  • --dev keeps the generated project pinned to the local WaterUI sources while new releases are cooking.
  • --yes skips prompts so commands can run inside scripts.
  • Repeat --backend for each platform you plan to target. You can always run water add-backend <name> later.

The command produces a Rust crate, Water.toml, and backend-specific folders under apple/, android/, and web/.

Run with Hot Reload

water run detects connected simulators and browsers, recompiles your crate, and streams code changes to the selected backend:

water run --platform web --project water-demo
  • Use --device <name> to target a specific simulator/emulator from water devices.
  • Add --release once you need optimized builds for profiling.
  • Disable the file watcher with --no-watch if your CI only needs a single build.

Package Native Artifacts

Produce distributable builds when you are ready to ship:

water package --platform android --project water-demo --release
water package --platform ios --project water-demo

Android packaging accepts --skip-native when custom Rust artifacts are supplied. Apple builds honour the standard Xcode environment variables (CONFIGURATION, BUILT_PRODUCTS_DIR, etc.) when invoked from scheme actions.

Inspect and Repair Toolchains

Run doctor and devices early in each chapter to ensure your environment is healthy:

water doctor --fix
water devices --json
  • doctor validates Rust, Swift, Android, and web prerequisites. With --fix it attempts to repair missing components, otherwise it prints actionable instructions.
  • devices emits a machine-readable list when --json is present, which is perfect for CI pipelines or automation scripts.

Automation Tips

All commands accept --format json (or --json). JSON output disables interactive prompts. Supply --yes, --platform, and --device up front to avoid stalling non-interactive shells.

Because every walkthrough in this book starts from a real CLI project, keep this reference handy: it is the quickest path to recreating any example locally.

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

  1. Download the installer from rustup.rs
  2. Run the downloaded .exe file
  3. Follow the installation prompts
  4. 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.

  1. Install VS Code: Download from code.visualstudio.com

  2. 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
  3. Optional Extensions:

    • Error Lens: Inline error messages
    • Bracket Pair Colorizer: Colorizes matching brackets
    • GitLens: Enhanced Git integration

IntelliJ IDEA / CLion:

  • Install the "Rust" plugin
  • Excellent for complex projects and debugging

Vim / Neovim:

  • Use rust.vim for syntax highlighting
  • Use coc-rust-analyzer for LSP support

Emacs:

  • Use rust-mode for syntax highlighting
  • Use lsp-mode with 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 waterui/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, Android, and web dependencies. Repeat it whenever you change machines or SDK versions. To discover connected simulators/emulators (useful for later chapters), run:

water devices --json

Creating Your First Project

We will let the CLI scaffold a runnable playground that already references the in-repo workspace:

water create --name "Hello WaterUI" \
  --directory hello-waterui \
  --bundle-identifier com.example.hellowaterui \
  --backend swiftui --backend web \
  --yes --dev
cd hello-waterui

Flags explained:

  • --directory lets you pick the folder name (matching the rest of this book).
  • --backend can be repeated; choose whatever targets you want to explore first.
  • --dev points dependencies at the checked-out workspace so each chapter’s code compiles against your local sources.
  • --yes accepts the defaults and keeps scripts non-interactive.

The generated project includes:

hello-waterui/
├── Cargo.toml          # crate manifest
├── Water.toml          # WaterUI-specific metadata + enabled backends
├── src/lib.rs          # starting point for your app views
├── apple/, android/, web/ (depending on --backend)
└── .water/             # CLI metadata and cached assets

Web Development (WebAssembly)

When targeting the browser, install the wasm tooling once per machine:

curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
rustup target add wasm32-unknown-unknown

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 {
    "Hello, WaterUI! 🌊"
}
}

Building and Running

Instead of calling cargo run directly, use the CLI so it can manage backends for you:

water run --platform web --project hello-waterui

The same command auto-detects desktop/mobile simulators when you omit --platform and run it from macOS. Once the dev server starts, every change you save in src/lib.rs hot-reloads into the selected target.

If you prefer to run the Rust crate alone (useful for unit tests or CLI tools), you can still execute cargo test or cargo run in parallel with the water commands; both workflows share the same sources.

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:

  1. Ensure you have the Microsoft C++ Build Tools installed
  2. Use the x86_64-pc-windows-msvc toolchain
  3. 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
  • A reset button
  • 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:

water create --name "Counter App" \
  --directory counter-app \
  --bundle-identifier com.example.counterapp \
  --backend web \
  --yes --dev
cd counter-app

We will edit src/lib.rs so the shared code can run on any backend the CLI installed.

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 {
    "Counter App"
}
}

Run this to make sure everything works:

water run --platform web --project counter-app

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((
        "Counter App",
        "Count: 0",
    ))
}
}

Note: vstack creates a vertical stack of views. We'll learn about hstack (horizontal) and zstack (overlay) later.

Step 3: Adding Reactive State

Now comes the exciting part - let's add reactive state! We'll use the re-exported binding helper together with Binding's convenience methods and the text! macro for reactive text:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;

pub fn counter() -> impl View {
    let count: Binding<i32> = binding(0);
    vstack((
        "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 --platform web --project counter-app 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::reactive::binding;
use waterui::Binding;
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::*;
use waterui::reactive::binding;
use waterui::Binding;
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 count changes

Event Handling

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
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<i32>::decrement and Binding<i32>::increment provide ergonomic arithmetic updates without manual closures

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:

  • 'static lifetime: Views can't contain non-static references, ensuring they can be stored and moved safely
  • self parameter: Views consume themselves when building their body, enabling zero-cost moves
  • env: &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 every View plus tuples, so vstack(("A", "B")) works without wrapping strings manually.
  • TupleViews: converts tuples/arrays into Vec<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;
use waterui::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
        waterui_text::Text::new("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:

#![allow(unused)]
fn main() {
use waterui::prelude::*;

pub fn welcome_message(name: &str) -> impl View {
    vstack((
        waterui_text::Text::new("Welcome!").size(24.0),
        waterui_text::Text::new(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;
use waterui::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::new("🌊"),
            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 through prelude::*) 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((
            waterui_text::Text::new(config.api_url.clone())
                .foreground(theme.primary_color.clone()),
            waterui_text::Text::new(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::new("🌊"),
                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 core::marker::PhantomData;
pub struct Context<T>(PhantomData<T>);

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:

  1. Provide its current value (get()).
  2. 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 Binding's convenience methods like .increment(), .toggle(), or .push() to keep your state updates expressive and ergonomic.

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::watch or waterui::views::watch) subscribe to any Signal and rebuild their child when it changes.
  • Resolver-aware components consume impl Resolvable, which often wrap signals resolved against the Environment.
#![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

ScenarioRecommended APINotes
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 when calls or build a match-style dispatcher using match on 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 a Frame with 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 Computed binding).
  • Impossible Branch – When you know only one branch should appear, log unexpected states in the or closure 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

  1. Proposal – A parent view calls Layout::propose on its children with the size it is willing to offer. Children can accept the full proposal, clamp it, or ignore it entirely.
  2. Measurement – Each child reports back an intrinsic Size based on the proposal. Stacks, grids, and other composite containers aggregate those answers to determine their own size.
  3. Placement – The container receives a rectangle (Rect) that represents the concrete space granted by the renderer. It positions every child within that rectangle via Layout::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, using Leading, Center, or Trailing.
  • 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. .frame().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 / Bottom to align items inside each grid cell.

Frames and Explicit Sizing

WaterUI’s Frame view pins a child to explicit size preferences. view.frame(width, height) is a common SwiftUI pattern; in WaterUI you construct an explicit frame via ViewExt::alignment and the methods on Frame:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::frame::Frame;
use waterui::layout::stack::Alignment;

fn gallery_thumbnail(content: impl View) -> impl View {
    Frame::new(content)
        .width(160.0)
        .height(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 overlayszstack honours alignment. Apply additional .padding_with or wrap the child in a Frame to 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

NeedUseNotes
Static copy, no stylingstring literal / String / StrLowest overhead; respects the surrounding layout but cannot change font or colour.
Styled or reactive textText / text! macroFull typography control and automatic updates when bound data changes.
Format existing signalstext!("Total: {amount:.2}", amount)Uses the nami::s! formatter under the hood.
Display non-string signalsText::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::component::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 underlying Computed<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, or binding::<T> + turbofish when the compiler needs help inferring types.
  • Keep expensive formatting out of the view – Precompute large strings in a Computed binding 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 – Wrap it in Frame::new(text!(…)).alignment(Alignment::Leading) or place inside an hstack with spacer() 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 frame 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::components::controls. 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.

Passing Data into Handlers

Handlers can receive additional state or values from the environment in any order. Compose them with other extractors using tuples:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui_core::extract::Use;

#[derive(Clone)]
struct Analytics;

impl Analytics {
    fn track_delete(&self, _id: u64) {}
}

fn delete_button(item_id: Binding<Option<u64>>) -> impl View {
    button("Delete")
        .action_with(&item_id, |id: Binding<Option<u64>>, Use(analytics): Use<Analytics>| {
            if let Some(id) = id.get() {
                analytics.track_delete(id);
                println!("Deleted item {id}");
            }
        })
}
}

Tip: Extractors live in waterui::core::extract. They let you pull services (analytics, database pools, etc.) from the environment at the moment the handler runs.

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 a Binding<bool>.
  • Bind directly to a Binding<bool>; if you need side effects, react in a separate handler using when or task.
  • Combine with when to 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;
use waterui::Str;

pub fn login_fields() -> impl View {
    let username: Binding<String> = binding(String::new());
    let password: Binding<String> = binding(String::new());
    let username_str = Binding::mapping(&username, |data| Str::from(data), |binding, value: Str| {
        binding.set(value.to_string());
    });
    let password_str = Binding::mapping(&password, |data| Str::from(data), |binding, value: Str| {
        binding.set(value.to_string());
    });

    vstack((
        vstack((
            text("Username"),
            TextField::new(&username_str).prompt(text("[email protected]")),
        )),
        vstack((
            text("Password"),
            TextField::new(&password_str).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 FormBuilder 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:

  1. You provide a Binding of your data structure (e.g., Binding<LoginForm>) to a form control.
  2. The form control (e.g., a TextField) reads the initial value from the binding to display it.
  3. 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 Binding is 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 combining the Project and FormBuilder derives: #[derive(waterui_derive::Project, waterui_derive::FormBuilder)]. Project gives you field-level bindings, while FormBuilder renders the UI automatically:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui_form::{form, FormBuilder};
#[derive(Default, Clone, Debug, waterui_derive::Project, waterui_derive::FormBuilder)]
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 field
  • bool → Toggle switch
  • i32 → Number stepper
  • f64 → Slider
  • And many more...

Type-to-Component Mapping

The FormBuilder macro automatically maps Rust types to appropriate form components:

Rust TypeForm ComponentDescription
String, &strTextFieldSingle-line text input
boolToggleOn/off switch
i32, i64, etc.StepperNumeric input with +/- buttons
f64SliderSlider with 0.0-1.0 range
ColorColorPickerColor 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;

#[derive(Default, Clone, Debug, waterui_derive::Project, waterui_derive::FormBuilder)]
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());
    let name_str = Binding::mapping(&name, |value| Str::from(value), |binding, value: Str| {
        binding.set(value.to_string());
    });
    vstack((
        text("Name:"),
        TextField::new(&name_str).prompt(text("[email protected]")),
    ))
}
}

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};
#[derive(Default, Clone, waterui_derive::Project, waterui_derive::FormBuilder)]
struct PersonalInfo {
    first_name: String,
    last_name: String,
    birth_year: i32,
}

#[derive(Default, Clone, waterui_derive::Project, waterui_derive::FormBuilder)]
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(&current_step, |state: Binding<usize>| {
                state.set(state.get().saturating_sub(1));
            }),
            spacer(),
            button("Next").action_with(&current_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};

#[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};
#[derive(Default, Clone, Debug, waterui_derive::Project, waterui_derive::FormBuilder)]
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};
#[derive(Default, Clone, Debug, waterui_derive::Project, waterui_derive::FormBuilder)]
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_core::id::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 Identifable using a generated Id.

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_core::id::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_core::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 NavigationView or scroll instead 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::components::media::Photo;

pub fn cover_image() -> impl View {
    Photo::new("https://assets.waterui.dev/cover.png")
        .placeholder(text("Loading…"))
}
}

Key features:

  • Photo::new accepts anything convertible into waterui_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 the Photo like any other view.

Video Playback

Video represents a source, while VideoPlayer renders controls. Create one Video per asset and reuse it if multiple players should point at the same file.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::components::media::{Video, VideoPlayer};
use waterui::reactive::binding;

pub fn trailer_player() -> impl View {
    let video = Video::new("https://media.waterui.dev/trailer.mp4");
    let muted = binding(false);

    vstack((
        VideoPlayer::new(video.clone()).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.
  • VideoPlayer stores 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::components::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::components::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 (Feature Flag: media-picker)

Enable the crate feature in Cargo.toml:

[dependencies.waterui]
features = ["media-picker"]

Then present the picker:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::components::media::picker::{MediaFilter, MediaPicker, Selected};
use waterui::reactive::binding;

pub fn choose_photo() -> impl View {
    let selection = binding::<Selected>(Selected(0));

    MediaPicker::new()
        .filter(MediaFilter::Image)
        .selection(selection.clone())
}
}

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::components::media::Media;
use waterui::reactive::binding;
use waterui::task::task;

let gallery = binding(Vec::<Media>::new());

button("Import").action_with(&selection, move |selected| {
    let gallery = gallery.clone();
    task(async move {
        let media = selected.get().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 .placeholder so the UI communicates status during network hiccups (future versions of the component will expose explicit failure hooks).
  • Reuse sources – Clone Video/LivePhotoSource handles 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.

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

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 .then to 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:

  1. Store view state in bindings (Binding<Option<T>>, Binding<FetchState>).
  2. Trigger an async task from a button, on_appear, or watcher.
  3. 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::task;

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

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

task 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 retry button next to the error text.
  • Timeouts – Combine tokio::time::timeout with the reqwest future 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_core::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::reactive::binding;
use waterui_core::{animation::Animation, AnimationExt};
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() {
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.

Animation Hooks

Renderers look for an Animation in the view metadata. If your app needs global animation policy (reduced motion, slower transitions), install a hook:

#![allow(unused)]
fn main() {
use waterui_core::env::Environment;
use waterui_core::{animation::Animation, view::Hook};

pub fn install_reduced_motion(env: &mut Environment) {
    env.insert_hook(|_, config: Animation| Animation::EaseOut(Duration::from_millis(100)));
}
}

Any binding that calls .animated() now receives the shorter ease-out curve.

Testing and Debugging

  • Run with the WATERUI_ANIMATION=off environment 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::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::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 for DefaultLoadingView in 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::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::error::ErrorView;

Suspense::new(async {
    match fetch_feed().await {
        Ok(posts) => feed(posts).anyview(),
        Err(err) => ErrorView::from(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::error::ErrorView, and customize how errors look using environment hooks.

From Errors to Views

Wrap errors with ErrorView::from whenever a Result fails:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::error::ErrorView;

fn user_profile(id: u64) -> impl View {
    match load_user(id) {
        Ok(user) => profile_card(user),
        Err(err) => ErrorView::from(err),
    }
}
}

ErrorView 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 ErrorViewBuilder into the environment to override how errors render globally:

#![allow(unused)]
fn main() {
use waterui::error::ErrorViewBuilder;

let env = Environment::new().with(ErrorViewBuilder::new(|err, _env| {
    AnyView::new(
        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 ErrorView produced inside the tree uses this layout automatically.

Inline Result Helpers

If you prefer chaining, build a helper that maps Result<T, E> into either a view or an ErrorView:

#![allow(unused)]
fn main() {
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) => AnyView::new(ErrorView::from(err)),
    }
}
}

Use it when composing lists or complex layouts so you do not repeat match expressions everywhere.

Contextual Actions

Because error builders receive the Environment, you can extract services (analytics, retry queues, offline caches) with extract::Use<T> just like button handlers. A typical pattern:

#![allow(unused)]
fn main() {
ErrorViewBuilder::new(|err, env| {
    let telemetry = env.get::<Telemetry>().cloned();
    if let Some(t) = telemetry {
        t.record_error(&err);
    }
    AnyView::new(text!("{err}"))
})
}

Pairing with Suspense

When fetching data asynchronously, wrap the result inside Suspense and convert failures into ErrorView 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 ErrorViewBuilder and lean on ErrorView::from 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_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

  1. Create a struct that owns the resources you need.
  2. Implement Plugin (the default methods may be enough) and optionally install environment hooks.
  3. Insert helper extractors so views/handlers can access the plugin at runtime.
#![allow(unused)]
fn main() {
use waterui::core::extract::Extractor;

#[derive(Clone)]
pub struct Telemetry { /* ... */ }

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

impl Extractor for Telemetry {
    fn extract(env: &Environment) -> Option<Self> {
        env.get::<Self>().cloned()
    }
}
}

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() {
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::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.).

#![allow(unused)]
fn main() {
use waterui::accessibility::AccessibilityState;

let state = AccessibilityState::new()
    .selected(true)
    .expanded(Some(false));

accordion_header(name, state)
}

Patterns

  • Lives regions – For toast notifications or progress updates, install an environment hook that announces changes via AccessibilityLabel or 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::env::Environment;
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::env::Environment;
use waterui::prelude::*;
use waterui::reactive::binding;
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;
    text!("{count} {text}", count = item_count, text = i18n.get(&locale.0, "items_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::reactive::binding;
use waterui_text::locale::{Currency, Locale};
use waterui::Binding;
use waterui_text::Text;

pub fn formatted_amount() -> impl View {
    let amount = binding(42.50_f32);
    Text::format(amount, Currency::new(Locale("en".into())))
}
}

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 does not yet ship a first-party immediate-mode canvas, but you can integrate custom renderers today using the same primitives the framework uses internally (Native, Metadata, and platform hooks). This chapter outlines the approach so you can render charts, maps, or drawing surfaces until the dedicated waterui_canvas crate lands.

Wrap a Native View

Backends expose a Native<T> view that hands control to the renderer. Create a minimal wrapper that stores your model and drive it with reactive bindings:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::suspense::Suspense;
use waterui_core::{Environment, Native, View};
use waterui::Binding;

pub struct Chart {
    pub points: Vec<(f32, f32)>,
}

impl View for Chart {
    fn body(self, _env: &Environment) -> impl View {
        Native(self)
    }
}

async fn stream_points(points: Binding<Vec<(f32, f32)>>) -> impl View {
    // Replace with actual async updates (WebSocket, sensor data, etc.)
    Chart { points: points.get() }
}

pub fn realtime_chart() -> impl View {
    let points = binding(vec![(0.0, 0.0)]);
    Suspense::new(stream_points(points.clone()));
    Chart { points: points.get() }
}
}

Each backend implements the trait that turns Native<Chart> into the appropriate drawing surface (SwiftUI Canvas, HTML <canvas>, GTK4 DrawingArea, etc.). The binding keeps the view reactive, and Suspense handles asynchronous updates.

Pointer Input

Combine GestureObserver with your native view to capture pointer coordinates, then forward them to the backing renderer via bindings or environment services.

Looking Ahead

The upcoming waterui_canvas crate will package these patterns with a cross-platform immediate-mode API (paths, fills, gradients) plus hit-testing utilities. Until then, native wrappers provide a bridge for teams that need advanced drawing today.

Shaders

Custom shaders are still experimental in WaterUI, but the architecture already exposes two hooks you can use today:

  1. Native views – Wrap a platform-specific shader view just like the canvas chapter described.
  2. Metadata – Pass shader parameters through the environment so renderers can pipe them into the GPU pipeline.

Embedding a Metal/WebGL View

On Apple platforms, create a SwiftUI Representable that hosts a Metal view. Expose it to Rust as a Native<MyShader> view (see component::native). Provide a lightweight Rust struct with the shader configuration (uniforms, textures) and let the backend translate it to GPU calls.

Declarative Uniforms

Even without an official shader DSL, you can keep uniforms reactive by storing them in bindings:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::core::{Native, View};

pub struct PlasmaUniforms {
    pub time: f32,
    pub intensity: f32,
}

pub fn plasma() -> impl View {
    let time: Binding<f32> = binding(0.0).animated();
    Native(PlasmaUniforms {
        time: time.get(),
        intensity: 0.8,
    })
}
}

The backend reads PlasmaUniforms each frame and updates the shader. Once the official shader API lands, those bindings will plug directly into the declarative shader graph.

Roadmap

  • Shared WGSL shading language compiled to Metal, WebGPU, and Vulkan targets.
  • Editor tooling for hot-reloading shader parameters.
  • Integration with the layout system so shaders participate in hit-testing and accessibility.

For now, treat shaders like any other native integration: define a Rust struct for configuration, render it with a backend-specific view, and keep the data reactive.

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 --fix performs end-to-end checks across Rust, Swift, Android, and the web toolchain. Keep its output in CI logs for future reference.
  • water clean --yes removes Cargo, Gradle, and DerivedData caches when subtle build issues appear.
  • water build android --release --no-sccache is 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.