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: Learn once, apply anywhere - iOS, Android, macOS.
  • 🦀 Type-Safe: Leverage Rust’s powerful type system for compile-time correctness
  • ⚡ Reactive: Automatic UI updates when data changes
  • 📝 Declarative: Describe what your UI should look like, not how to build it

Meet the water CLI

Every chapter assumes you have the WaterUI CLI installed so you can scaffold, build, and package projects without leaving the terminal.

cargo install --path cli --locked

From there you can bootstrap a playground app and run it on any configured backend:

water create --name "Water Demo" \
  --bundle-identifier com.example.waterdemo \
  --backend swiftui --backend android --backend web --yes

water run --platform web --project water-demo
water package --platform android --project water-demo

Use water doctor --fix whenever you need to validate the local toolchain, and water devices --json to pick a simulator/emulator when scripting. The CLI mirrors the repository layout you are about to explore, so the hands-on examples in each chapter directly match real projects.

Framework Layout

The WaterUI workspace is a set of focused crates:

  • waterui-core: 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-macroswaterui/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 Reference

The water CLI is your primary tool for managing WaterUI projects. It handles scaffolding, running, and packaging your applications across different platforms.

Installation

Ensure you have the CLI installed (see Setup):

cargo install --path cli --locked

Project Modes

WaterUI supports two project modes:

water create "My Experiment" --playground
  • Best for: Prototyping, learning, and simple apps.
  • Structure: Hides native projects in .water/.
  • Experience: Zero-config. Just run water run and go.

2. Full Project

water create "My App" --bundle-id com.example.app --platform ios,android
  • Best for: Production apps needing custom native code (Info.plist, AndroidManifest.xml).
  • Structure: Generates explicit apple/ and android/ folders.
  • Experience: Full control over the native build process.

Commands

create

Scaffolds a new project.

# Playground
water create "Demo" --playground

# Production
water create "Production App" --bundle-id com.org.app --platform ios

run

Builds and runs the application on a connected device or simulator.

# Auto-detect device
water run

# Target specific platform
water run --platform ios
water run --platform android

# Target specific device
water run --device "iPhone 15"

Hot Reload is enabled by default. Save your .rs files to see changes instantly.

package

Produces distributable artifacts (IPA, APK/AAB).

water package --platform ios --release
water package --platform android --release --arch arm64

doctor

Checks your development environment for missing dependencies.

water doctor
# Attempt to fix issues automatically
water doctor --fix

devices

Lists all detected simulators and physical devices.

water devices

Installation and Setup

Before we dive into building applications with WaterUI, let’s set up a proper development environment. This chapter will guide you through installing Rust, setting up your editor, and creating your first WaterUI project.

Installing Rust

WaterUI requires Rust 1.87 or later with the 2024 edition. The easiest way to install Rust is through rustup.

On macOS, Linux, or WSL

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

On Windows

  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 cli --locked
water --version

The first command installs the current checkout; the second verifies that the binary is on your PATH.

Verify Your Toolchain

Run the built-in doctor before continuing:

water doctor --fix

This checks your Rust toolchain plus any configured Apple and Android dependencies. Repeat it whenever you change machines or SDK versions. To discover connected simulators/emulators (useful for later chapters), run:

water devices

Creating Your First Project

We will let the CLI scaffold a Playground project. This is the fastest way to get started, as it manages platform-specific native code for you automatically.

water create "Hello WaterUI" --playground
cd hello-waterui

The generated project is minimal:

hello-waterui/
├── Cargo.toml          # crate manifest
├── Water.toml          # WaterUI-specific metadata
├── src/lib.rs          # starting point for your app views
└── .water/             # Managed native projects (hidden)

Hello, World!

Open src/lib.rs inside the newly created project and replace the body with a tiny view:

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

pub fn home() -> impl View {
    text("Hello, WaterUI! 🌊")
}
}

Building and Running

Use the CLI to run your app. It will detect available simulators or devices:

water run

Once the dev server starts, every change you save in src/lib.rs hot-reloads instantly.

If you prefer to run the Rust crate alone (useful for unit tests), you can still execute cargo test in parallel.

Troubleshooting Common Issues

Rust Version Too Old

Error: error: package requires Rust version 1.87

Solution: Update Rust:

rustup update

Windows Build Issues

Error: Various Windows compilation errors

Solutions:

  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
  • Dynamic styling based on the counter value

By the end of this chapter, you’ll understand:

  • How to create interactive views
  • How to manage reactive state
  • How to handle user events
  • How to compose views together

Setting Up the Project

If you completed the setup chapter you already have a CLI-generated workspace. Otherwise scaffold one now using playground mode:

water create "Counter App" --playground
cd counter-app

We will edit src/lib.rs to build our application. The playground mode handles all the native configuration for us.

Building the Counter Step by Step

Let’s build our counter app incrementally, learning WaterUI concepts along the way.

Step 1: Basic Structure

Start with a simple view structure. Since our initial view doesn’t need state, we can use a function:

Filename: src/lib.rs

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

pub fn counter() -> impl View {
    text("Counter App")
}
}

Run this to make sure everything works:

water run

You should see a window with “Counter App” displayed.

Step 2: Adding Layout

Now let’s add some layout structure using stacks:

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

pub fn counter() -> impl View {
    vstack((
        text("Counter App"),
        text("Count: 0"),
    ))
}
}

Note: 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 binding helper from waterui::prelude and the text! macro for reactive text:

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

pub fn counter() -> impl View {
    let count: Binding<i32> = binding(0);
    
    vstack((
        text("Counter App"),
        text!("Count: {count}"),
        hstack((
            button("- Decrement").action_with(&count, |state: Binding<i32>| {
                state.set(state.get() - 1);
            }),
            button("+ Increment").action_with(&count, |state: Binding<i32>| {
                state.set(state.get() + 1);
            }),
        )),
    ))
}
}

water run will hot reload changes—save the file and keep the terminal open to see updates instantly.

Understanding the Code

Let’s break down the key concepts introduced:

Reactive State with binding

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

pub fn make_counter() -> Binding<i32> {
    binding(0)
}
}

This creates a reactive binding with an initial value of 0. When this value changes, any UI elements that depend on it will automatically update.

Reactive Text Display

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

pub fn reactive_label() -> impl View {
    let count: Binding<i32> = binding(0);
    text!("Count: {count}")
}
}
  • The text! macro automatically handles reactivity
  • The text will update whenever count changes

Event Handling

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

pub fn decrement_button() -> impl View {
    let count: Binding<i32> = binding(0);
    button("- Decrement").action_with(&count, |count: Binding<i32>| {
        count.set(count.get() - 1);
    })
}
}
  • .action_with() attaches an event handler with captured state.
  • Binding<T> implements Clone efficiently (it’s a reference-counted handle), so you can pass it around.

Layout with Stacks

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

pub fn stack_examples() -> impl View {
    vstack((
        text("First"),
        hstack((text("Left"), text("Right"))),
    ))
}
}

Stacks are the primary layout tools in WaterUI, allowing you to arrange views vertically or horizontally.

Understanding Views

The View system is the heart of WaterUI. Everything you see on screen is a View, and understanding how Views work is crucial for building efficient and maintainable applications. In this chapter, we’ll explore the View trait in depth and learn how to create custom components.

What is a View?

A View in WaterUI represents a piece of user interface. It could be as simple as a text label or as complex as an entire application screen. The beauty of the View system is that simple and complex views work exactly the same way.

The View Trait

Every View implements a single trait:

#![allow(unused)]
fn main() {
use waterui::env::Environment;
pub trait View: 'static {
    fn body(self, env: &Environment) -> impl View;
}
}

This simple signature enables powerful composition patterns. Let’s understand each part:

  • '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;
pub fn text_examples() -> impl View {
    let name: Binding<String> = binding("Alice".to_string());
    vstack((
        // Static text
        "Hello, World!",
        // Reactive text
        text!("Hello, {name}!"),
        // Styled text
        text("Important!").size(24.0),
    ))
}
}

Control Views

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::layout::stack::vstack;
pub fn control_examples() -> impl View {
    let enabled = binding(false);
    vstack((
        button("Click me").action(|| println!("Clicked!")),
        toggle(text("Enable notifications"), &enabled),
    ))
}
}

Layout Views

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::stack::{vstack, hstack, zstack};
pub fn layout_examples() -> impl View {
    vstack((
        vstack(("First", "Second", "Third")),
        hstack((button("Cancel"), button("OK"))),
        zstack((text("Base"), text("Overlay"))),
    ))
}
}

Creating Custom Views

The real power of WaterUI comes from creating your own custom Views. Let’s explore different patterns:

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

pub fn welcome_message(name: &str) -> impl View {
    vstack((
        text("Welcome!").size(24.0),
        text(format!("Hello, {}!", name)),
    ))
}

let lazy_view = || welcome_message("Bob");
}

Functions automatically satisfy View, so prefer them for stateless composition or whenever you can lean on existing bindings (as we did in examples::counter_view inside this book’s crate).

Struct Views (For Components with State)

Only reach for a custom struct when the component needs to carry configuration while building its child tree or interact with the Environment directly:

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

pub struct CounterWidget {
    pub initial_value: i32,
    pub step: i32,
}

impl View for CounterWidget {
    fn body(self, _env: &Environment) -> impl View {
        let count = binding(self.initial_value);

        vstack((
            text!("Count: {count}"),
            button("+").action_with(&count, move |state: Binding<i32>| {
                state.set(state.get() + self.step);
            }),
        ))
    }
}
}

Type Erasure with AnyView

When you need to store different view types in the same collection (navigation stacks, list diffing, etc.), use AnyView:

#![allow(unused)]
fn main() {
use waterui::AnyView;
fn welcome_message(name: &str) -> &'static str { "hi" }
let screens: Vec<AnyView> = vec![
    AnyView::new(welcome_message("Alice")),
    AnyView::new(welcome_message("Bob")),
];
}

AnyView erases the concrete type but keeps behaviour intact, letting routers or layout engines manipulate heterogeneous children uniformly.

Configurable Views and Hooks

Many built-in controls implement ConfigurableView, exposing a configuration struct that can be modified globally through hooks:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::env::Environment;
use waterui::AnyView;
use waterui::component::button::ButtonConfig;
use waterui::layout::stack::hstack;
use waterui::text::Text;
use waterui::view::ViewConfiguration;
pub fn install_button_theme(env: &mut Environment) {
    env.insert_hook(|_, mut config: ButtonConfig| {
        config.label = AnyView::new(hstack((
            text("🌊"),
            config.label,
        )));
        config.render()
    });
}
}

Hooks intercept ViewConfiguration types before renderers see them, enabling cross-cutting features like theming, logging, and accessibility instrumentation. Plugins install these hooks automatically, so understanding ConfigurableView prepares you for the advanced chapters on styling and resolver-driven behaviour.

The Environment System

WaterUI’s Environment is a type-indexed map that flows through your entire view hierarchy. It lets you pass themes, services, and configuration data without manually threading parameters through every function.

Seeding the Environment

Create an environment at the root of your app and attach values with .with (for owned values), .store (for namespaced keys), or .install (for plugins):

#![allow(unused)]
fn main() {
use waterui::env::Environment;
use waterui::prelude::*;
use waterui::Color;
#[derive(Clone)]
pub struct AppConfig {
    pub api_url: String,
    pub timeout_seconds: u64,
}
#[derive(Clone)]
pub struct Theme {
    pub primary_color: Color,
    pub background_color: Color,
}
fn home() -> &'static str { "Home" }
pub fn entry() -> impl View {
    let env = Environment::new()
        .with(AppConfig {
            api_url: "https://api.example.com".into(),
            timeout_seconds: 30,
        })
        .with(Theme {
            primary_color: Color::srgb_f32(0.0, 0.4, 1.0),
            background_color: Color::srgb_f32(1.0, 1.0, 1.0),
        });

    home().with_env(env)
}
}

ViewExt::with_env (available 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((
            text(config.api_url.clone())
                .foreground(theme.primary_color.clone()),
            text(format!("Timeout: {}s", config.timeout_seconds))
                .size(14.0),
        ))
        .background(waterui::background::Background::color(
            theme.background_color.clone(),
        ))
    }
}
}

Function Views with use_env

Functions (which already implement View) can still access the environment by wrapping their content in use_env:

#![allow(unused)]
fn main() {
use waterui::env::{use_env, Environment};
use waterui::prelude::*;
use waterui::Color;
#[derive(Clone)]
struct Theme { primary_color: Color }
pub fn themed_button(label: &'static str) -> impl View {
    use_env(move |env: Environment| {
        let theme = env.get::<Theme>().unwrap();
        button(label).background(theme.primary_color.clone())
    })
}
}

Event Handlers (action_with)

Handlers can extract typed values with waterui::core::extract::Use<T>:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::Binding;
use waterui_core::extract::Use;
use waterui::reactive::binding;
#[derive(Clone)]
pub struct Message(&'static str);
pub fn click_me() -> impl View {
    let value: Binding<String> = binding(String::new());
    vstack((
        button("Read message")
            .action_with(&value, |binding: Binding<String>, Use(Message(text)): Use<Message>| {
                binding.set(text.to_string());
            }),
        text!("{value}"),
    ))
    .with(Message("I'm Lexo"))
}
}

Namespaced Keys with Store

If the same type needs to appear multiple times (e.g., two independent Theme structs), wrap them in Store<K, V> so the key includes a phantom type:

#![allow(unused)]
fn main() {
use waterui::env::{Environment, Store};
use waterui::Color;
#[derive(Clone)]
struct Theme { primary_color: Color, background_color: Color }
pub struct AdminTheme;
pub struct UserTheme;

pub fn install_themes() -> Environment {
    Environment::new()
        .store::<AdminTheme, _>(Theme {
            primary_color: Color::srgb(230, 50, 50),
            background_color: Color::srgb(0, 0, 0),
        })
        .store::<UserTheme, _>(Theme {
            primary_color: Color::srgb(0, 102, 255),
            background_color: Color::srgb(255, 255, 255),
        })
}

pub fn maybe_admin_theme(env: &Environment) -> Option<&Theme> {
    env.query::<AdminTheme, Theme>()
}
}

Plugins and Hooks

Plugins encapsulate environment setup. Implement Plugin and call .install to register hooks, services, or other values:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui_core::plugin::Plugin;
use waterui::component::button::ButtonConfig;
use waterui::AnyView;
use waterui::layout::stack::hstack;
use waterui_text::Text;
use waterui::view::ViewConfiguration;
struct ThemePlugin;

impl Plugin for ThemePlugin {
    fn install(self, env: &mut Environment) {
        env.insert_hook(|_, mut config: ButtonConfig| {
            config.label = AnyView::new(hstack((
                text("🌊"),
                config.label,
            )));
            config.render()
        });
    }
}

let env = Environment::new().install(ThemePlugin);
}

Hooks intercept ViewConfiguration types before they render, making the environment the perfect place to implement themes, logging, or feature flags that span your entire view hierarchy.

Resolvers, Environment Hooks, and Dynamic Views

The WaterUI core works because values can be resolved lazily against the Environment and streamed into the renderer. This chapter explains the pieces you will see throughout the book.

The Resolvable Trait

#![allow(unused)]
fn main() {
use waterui_core::{Environment, Signal, constant, resolve::Resolvable};

#[derive(Debug, Clone)]
struct LocalizedTitle;

impl Resolvable for LocalizedTitle {
    type Resolved = String;

    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
        let locale = env
            .get::<String>()
            .cloned()
            .unwrap_or_else(|| "en".to_string());
        constant(match locale.as_str() {
            "en" => "Hello".to_string(),
            "fr" => "Bonjour".to_string(),
            _ => "Hello".to_string(),
        })
    }
}
}

Resolvable types take an environment reference and produce any Signal. When WaterUI stores them inside AnyResolvable<T>, the system can defer value creation until the view tree is mounted on a backend. This is how themes, localization, and other contextual data flow without explicit parameters.

Hooks and Metadata

Environment::insert_hook injects Hook<ViewConfig> values that can wrap every view of a given configuration type. Hooks are typically installed by plugins (e.g., a theming system) so that they can read additional metadata and rewrite the view before rendering. Because hooks remove themselves from the environment while running, recursion is avoided.

Dynamic Views

Dynamic::watch bridges any Signal into the View world. The helper subscribes to the signal, sends the initial value, and keeps the view tree alive via a guard stored in With metadata. You will use it heavily when wiring nami bindings into text, lists, or network-backed UIs.

Throughout the component chapters we will point back here whenever a control takes an impl Resolvable or internally creates a Dynamic to stream updates.

Nami - The Reactive Heart of WaterUI

Reactive state management is the core of any interactive WaterUI application. When your data changes, the UI should automatically update to reflect it. This chapter teaches you how to master WaterUI’s reactive system, powered by the nami crate.

All examples assume the following imports:

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

The Signal Trait: A Universal Language

Everything in nami’s reactive system implements the Signal trait. It represents any value that can be observed for changes.

#![allow(unused)]
fn main() {
use nami::watcher::Context;

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

    // Get the current value of the signal
    fn get(&self) -> Self::Output;

    // Watch for changes (used internally by the UI)
    fn watch(&self, watcher: impl Fn(Context<Self::Output>) + 'static) -> Self::Guard;
}
}

A Signal is a reactive value that knows how to:

  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 its convenience methods like .toggle(), or use .update() / .with_mut() for more complex in-place modifications. Some convenience methods like .increment() or .push() might be available as extension traits on Binding for specific types (e.g., numeric types or collections) within the broader waterui crate.

When Type Inference Needs Help

Sometimes the compiler can’t deduce the target type—especially when starting from None, Default::default(), or other type-agnostic values. In those cases, add an explicit type with the turbofish syntax:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
#[derive(Clone)]
struct User;
// Starts as None, so we spell out the final type.
let selected_user = binding::<Option<User>>(None);

// Empty collection with an explicit element type.
let log_messages = binding::<Vec<String>>(Vec::new());
}

The rest of the ergonomics (methods like .set, .toggle, .push) remain exactly the same.

3. Constants: Signals That Never Change

Even simple, non-changing values can be treated as signals. This allows you to use them seamlessly in a reactive context.

#![allow(unused)]
fn main() {
use waterui::reactive::constant;
let fixed_name = constant("WaterUI"); // Never changes
let literal_string = "Hello World";   // Also a signal!
}

The Golden Rule: Avoid .get() in UI Code

Calling .get() on a signal extracts a static, one-time snapshot of its value. When you do this, you break the reactive chain. The UI will be built with that snapshot and will never update when the original signal changes.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let name: Binding<String> = binding("Alice");

// ❌ WRONG: Using .get() breaks reactivity
let broken_message = format!("Hello, {}", name.get());
text(broken_message); // This will NEVER update when `name` changes!

// ✅ CORRECT: Pass the signal directly to keep the reactive chain intact
let reactive_message = s!("Hello, {name}");
text(reactive_message); // This updates automatically when `name` changes.
}

When should you use .get()? Only when you need to pass the value to a non-reactive system, such as:

  • Logging or debugging.
  • Sending the data over a network.
  • Performing a one-off calculation outside the UI.

Mastering Binding<T>: Your State Management Tool

Binding<T> is more than just a container. It provides a rich set of convenience methods to handle state updates ergonomically.

Basic Updates: .set()

The simplest way to update a binding is with .set().

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
let counter = binding(0);
counter.set(10); // The counter is now 10
}

In-Place Updates: .update()

For complex types, .update() allows you to modify the value in-place without creating a new one. It takes a closure that receives a mutable reference to the value.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
#[derive(Clone)]
struct User { name: String, tags: Vec<&'static str> }
let user = binding(User { name: "Alice".to_string(), tags: vec![] });

// Modify the user in-place
user.with_mut(|user: &mut User| {
    user.name = "Alicia".to_string();
    user.tags.push("admin");
});
// The UI updates once, after the closure finishes.
}

This is more efficient than cloning the value, modifying it, and then calling .set().

Boolean Toggle: .toggle()

For boolean bindings, .toggle() is a convenient shortcut.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let is_visible: Binding<bool> = binding(false);
is_visible.toggle(); // is_visible is now true
}

Mutable Access with a Guard: .get_mut()

For scoped, complex mutations, .get_mut() provides a guard. The binding is marked as changed only when the guard is dropped.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
let data = binding::<Vec<i32>>(vec![1, 2, 3]);

// Get a mutable guard. The update is sent when `guard` goes out of scope.
let mut guard = data.get_mut();
guard.push(4);
guard.sort();
}

The s! Macro: Reactive String Formatting

The s! macro is a powerful tool for creating reactive strings. It automatically captures signals from the local scope and creates a computed string that updates whenever any of the captured signals change.

Without s! (manual & verbose):

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::Binding;
let name: Binding<String> = binding("John");
let age: Binding<i32> = binding(30);

let message = name.zip(age).map(|(n, a)| {
    format!("{} is {} years old.", n, a)
});
}

With s! (concise & reactive):

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let name: Binding<String> = binding("John");
let age: Binding<i32> = binding(30);

let message = s!("{} is {} years old.", name, age);
}

The s! macro also supports named arguments for even greater clarity:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
let name: Binding<String> = binding("John");
let age: Binding<i32> = binding(30);
let message = s!("{name} is {age} years old.");
}

Feeding Signals Into Views

Signals become visible through views. WaterUI ships a few bridges:

  • Formatting macros (text!, s!, format_signal!) capture bindings automatically.
  • Dynamic views (Dynamic::watch or 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. .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 provides direct modifiers for pinning views to explicit sizes, similar to SwiftUI. Use .width(), .height(), or .size() to constrain dimensions:

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

fn gallery_thumbnail(content: impl View) -> impl View {
    content
        .size(160.0, 120.0)
        .alignment(Alignment::Center)
}
}

Frames are most helpful when mixing flexible and fixed-size widgets (for example, pinning an avatar while the surrounding text wraps naturally). Combine frames with stacks, grids, and padding to create predictable compositions.

Layout Troubleshooting Checklist

  • Unexpected stretching – Make sure there isn’t an extra spacer() or a child returning an infinite proposal. Wrapping the content in .padding_with(EdgeInsets::all(0.0)) can help visualise what area the view thinks it owns.
  • Grid clipping – Provide a finite width (wrap in a parent frame) and watch for rows with taller content than their neighbours.
  • Overlapping 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::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 – Use .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 fixed height often resolves baseline differences between fonts.

With these building blocks you can express everything from static headings to live, localised metrics without imperatively updating the UI. Let your data bindings drive the text, and WaterUI handles the rest.

Controls Overview

Buttons, toggles, sliders, text fields, and steppers live inside waterui::component. They share the same handler ergonomics and reactive bindings you saw in earlier chapters. This chapter walks through each control, explaining how to wire it to Binding values, style labels, and compose them into larger workflows.

Buttons

Buttons turn user intent into actions. WaterUI’s button helper mirrors the ergonomics of SwiftUI while keeping the full power of Rust’s closures. This section explains how to build buttons, capture state, coordinate with the environment, and structure handlers for complex flows.

Anatomy of a Button

button(label) returns a Button view. The label can be any view—string literal, Text, or a fully custom composition. Attach behaviour with .action or .action_with.

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

fn simple_button() -> impl View {
    button("Click Me").action(|| {
        println!("Button was clicked!");
    })
}
}

Behind the scenes, WaterUI converts the closure into a HandlerFn. Handlers can access the Environment or receive state via .action_with.

Working with State

Buttons often mutate reactive state. Use action_with to borrow a binding without cloning it manually.

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

fn counter_button() -> impl View {
    let count: Binding<i32> = binding(0);

    vstack((
        text!("Count: {count}"),
        button("Increment").action_with(&count, |binding: Binding<i32>| {
            binding.set(binding.get() + 1);
        }),
    ))
}
}

.action_with(&binding, handler) clones the binding for you (bindings are cheap handles). Inside the handler you can call any of the binding helpers—set, set_from, with_mut, etc.—to keep the state reactive.

Custom Labels and Composition

Because labels are just views, you can craft rich buttons with icons, nested stacks, or dynamic content.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::layout::{padding::EdgeInsets, stack::hstack};

fn hero_button() -> impl View {
    button(
        hstack((
            text("🚀"),
            text("Launch")
                .size(18.0)
                .padding_with(EdgeInsets::new(0.0, 0.0, 0.0, 8.0)),
        ))
        .padding()
    )
    .action(|| println!("Initiating launch"))
}
}

You can nest buttons inside stacks, grids, navigation views, or conditionals—WaterUI treats them like any other view.

Guarding Actions

WaterUI does not currently ship a built-in .disabled modifier. Instead, guard inside the handler or wrap the button in a conditional.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::widget::condition::when;
use waterui::Computed;
fn guarded_submit(can_submit: Computed<bool>) -> impl View {
    when(can_submit.clone(), || {
        button("Submit").action(|| println!("Submitted"))
    })
    .or(|| text("Complete all fields to submit"))
}
}

For idempotent operations, simply return early:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn pay_button() -> impl View {
    let payment_state: Binding<bool> = binding(false);
    button("Pay").action_with(&payment_state, |state: Binding<bool>| {
        if state.get() {
            return;
        }
        state.set(true);
    })
}
}

Asynchronous Workflows

Handlers run on the UI thread. When you need async work, hand it off to a task:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::task::spawn;
pub fn refresh_button() -> impl View {
    button("Refresh").action(|| {
        spawn(async move {
            println!("Refreshing data…");
        });
    })
}
}

spawn hands the async work to the configured executor so the handler stays lightweight—schedule work and return immediately.

Best Practices

  • Keep handlers pure – Avoid blocking IO or heavy computation directly in the closure.
  • Prefer action_with – It guarantees the binding lives long enough and stays reactive.
  • Think environment-first – Use extractors when a button needs shared services.
  • Make feedback visible – Toggle UI state with bindings (loading spinners, success banners) so the user sees progress.

Buttons may look small, but they orchestrate the majority of user journeys. Combine them with the layout and state tools covered elsewhere in this book to build polished, responsive workflows.

Toggles

Toggles expose boolean bindings with a platform-native appearance.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::widget::condition::when;
use waterui::Binding;
pub fn settings_toggle() -> impl View {
    let wifi_enabled: Binding<bool> = binding(true);

    vstack((
        toggle("Wi-Fi", &wifi_enabled),
        when(wifi_enabled.map(|on| on), || text("Connected to Home"))
            .or(|| text("Wi-Fi disabled")),
    ))
}
}
  • Pass the label as any view (string, Text, etc.) along with 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;

pub fn login_fields() -> impl View {
    let username: Binding<String> = binding(String::new());
    let password: Binding<String> = binding(String::new());

    vstack((
        vstack((
            text("Username"),
            TextField::new(&username).prompt(text("you@example.com")),
        )),
        vstack((
            text("Password"),
            TextField::new(&password).prompt(text("••••••")),
        )),
    ))
}
}

WaterUI automatically syncs user edits back into the binding. Combine .on_submit(handler) with the button patterns above to run validation or send credentials. When you need structured forms, these controls are exactly what the #[form] macro wires up behind the scenes.

Form Controls

WaterUI provides a comprehensive form system that makes creating interactive forms both simple and powerful. The centerpiece of this system is the FormBuilder derive macro, which automatically generates form UIs from your data structures.

Two-Way Data Binding

WaterUI’s forms are built on a powerful concept called two-way data binding. This means that the state of your data model and the state of your UI controls are always kept in sync automatically.

Here’s how it works:

  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 using the #[form] macro. It combines reactivity with automatic UI generation:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::form::{form, FormBuilder};
#[form]
pub struct LoginForm {
    /// The user's username
    pub username: String,
    /// The user's password  
    pub password: String,
    /// Whether to remember the user
    pub remember_me: bool,
    /// The user's age
    pub age: i32,
}

pub fn login_view() -> impl View {
    let login_form = LoginForm::binding();
    form(&login_form)
}
}

That’s it! WaterUI automatically creates appropriate form controls for each field type:

  • String → Text field
  • bool → Toggle switch
  • i32 → Number stepper
  • f64 → Slider
  • And many more…

Type-to-Component Mapping

The #[form] 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;

#[form]
struct RegistrationForm {
    /// Full name (2-50 characters)
    full_name: String,
    /// Email address
    email: String,
    /// Age (must be 18+)
    age: i32,
    /// Subscribe to newsletter
    newsletter: bool,
    /// Account type
    is_premium: bool,
    /// Profile completion (0.0 to 1.0)
    profile_completion: f64,
    /// Theme color preference
    theme_color: Color,
}

pub fn registration_view() -> impl View {
    let form_binding = RegistrationForm::binding();

    let validation_message = form_binding.clone().map(|data| -> String {
        if data.full_name.len() < 2 {
            "Name too short".into()
        } else if data.age < 18 {
            "Must be 18 or older".into()
        } else if !data.email.contains('@') {
            "Invalid email".into()
        } else {
            "Form is valid ✓".into()
        }
    });

    vstack((
        "User Registration",
        form(&form_binding),
        text!("{validation_message}"),
    ))
}
}

Individual Form Controls

You can also use form controls individually:

Text Fields

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::Str;
pub fn text_field_example() -> impl View {
    let name: Binding<String> = binding(String::new());

    vstack((
        text("Name:"),
        TextField::new(&name).prompt(text("you@example.com")),
    ))
}
}

Toggle Switches

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn toggle_example() -> impl View {
    let enabled: Binding<bool> = binding(false);
    toggle("Enable notifications", &enabled)
}
}

Number Steppers

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
pub fn stepper_example() -> impl View {
    let count: Binding<i32> = binding(0);
    stepper(&count)
}
}

Sliders

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
fn slider_example() -> impl View {
    let volume: Binding<f64> = binding(0.5_f64);
    Slider::new(0.0..=1.0, &volume).label(text("Volume"))
}
}

Color Picker

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

fn theme_selector() -> impl View {
    let color: Binding<Color> = binding(Color::srgb_f32(0.25, 0.6, 0.95));

    ColorPicker::new(&color)
        .label(text("Theme color"))
}
}

Advanced Form Patterns

Multi-Step Forms

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::widget::condition::when;
use waterui::layout::stack::hstack;
use waterui::layout::spacer;
use waterui::Binding;
use waterui::form::{form, FormBuilder};
#[form]
struct PersonalInfo {
    first_name: String,
    last_name: String,
    birth_year: i32,
}

#[form]
struct ContactInfo {
    email: String,
    phone: String,
    preferred_contact: bool, // true = email, false = phone
}

pub fn registration_wizard() -> impl View {
    let personal = PersonalInfo::binding();
    let contact = ContactInfo::binding();
    let current_step = binding(0_usize);
    let step_display = waterui::SignalExt::map(current_step.clone(), |value| value + 1);
    let show_personal = waterui::SignalExt::map(current_step.clone(), |step| step == 0);

    vstack((
        text!("Step {} of 2", step_display),
        when(show_personal, move || form(&personal))
        .or(move || form(&contact)),
        hstack((
            button("Back").action_with(&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};
use waterui::form::FormBuilder;

#[derive(Default, Clone)]
struct CustomForm {
    title: String,
    active: bool,
}

impl FormBuilder for CustomForm {
    type View = AnyView;

    fn view(binding: &Binding<Self>, _label: AnyView, _placeholder: Str) -> Self::View {
        let title_binding = Binding::mapping(binding, |data| Str::from(data.title.clone()), |form, value: Str| {
            form.with_mut(|state| state.title = value.to_string());
        });
        let active_binding = Binding::mapping(binding, |data| data.active, |form, value| {
            form.with_mut(|state| state.active = value);
        });

        AnyView::new(vstack((
            TextField::new(&title_binding).label(text("Title")),
            Toggle::new(&active_binding).label(text("Active")),
        )))
    }
}
}

Secure Fields

For sensitive data like passwords:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::form::{secure, SecureField};
use waterui::form::secure::Secure;

pub fn password_form() -> impl View {
    let password: Binding<Secure> = binding(Secure::default());
    let confirm_password: Binding<Secure> = binding(Secure::default());

    vstack((
        secure("Password:", &password),
        secure("Confirm Password:", &confirm_password),
        password_validation(&password, &confirm_password),
    ))
}

fn password_validation(pwd: &Binding<Secure>, confirm: &Binding<Secure>) -> impl View {
    let feedback = pwd.clone().zip(confirm.clone()).map(|(p, c)| {
        if p.expose() == c.expose() && !p.expose().is_empty() {
            "Passwords match ✓".to_string()
        } else {
            "Passwords do not match".to_string()
        }
    });

    text(feedback)
}
}

Form Validation Best Practices

Real-time Validation with Computed Signals

For more complex forms, it’s a good practice to encapsulate your validation logic into a separate struct. This makes your code more organized and reusable.

Let’s create a Validation struct that holds computed signals for each validation rule.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::Binding;
use waterui::SignalExt;
use waterui::widget::condition::when;
use waterui::form::{form, FormBuilder};
#[form]
struct ValidatedForm {
    email: String,
    password: String,
    age: i32,
}

pub fn validated_form_view() -> impl View {
    let form_state = ValidatedForm::binding();
    let is_valid_email = form_state.clone().map(|f| f.email.contains('@') && f.email.contains('.'));
    let is_valid_password = form_state.clone().map(|f| f.password.len() >= 8);
    let is_valid_age = form_state.clone().map(|f| f.age >= 18);

    let can_submit = is_valid_email
        .clone()
        .zip(is_valid_password.clone())
        .zip(is_valid_age.clone())
        .map(|((email, password), age)| email && password && age);

    let email_feedback = is_valid_email.clone().map(|valid| {
        if valid {
            "✓ Valid email".to_string()
        } else {
            "✗ Please enter a valid email".to_string()
        }
    });
    let password_feedback = is_valid_password.clone().map(|valid| {
        if valid {
            "✓ Password is strong enough".to_string()
        } else {
            "✗ Password must be at least 8 characters".to_string()
        }
    });
    let age_feedback = is_valid_age.clone().map(|valid| {
        if valid {
            "✓ Age requirement met".to_string()
        } else {
            "✗ Must be 18 or older".to_string()
        }
    });

    let submit_binding = form_state.clone();
    let form_binding = form_state.clone();

    vstack((
        form(&form_binding),
        text(email_feedback),
        text(password_feedback),
        text(age_feedback),
        when(can_submit.clone(), move || {
            button("Submit").action_with(&submit_binding, |state: Binding<ValidatedForm>| {
                println!("Form submitted: {:?}", state.get());
            })
        })
        .or(|| text("Fill every requirement to enable submission.")),
    ))
}
}

Integration with State Management

Forms integrate seamlessly with WaterUI’s reactive state system:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::binding;
use waterui::SignalExt;
use waterui::widget::condition::when;
use waterui::Binding;
use waterui::form::{form, FormBuilder};
#[form]
struct UserSettings {
    name: String,
    theme: String,
    notifications: bool,
}

pub fn settings_panel() -> impl View {
    let settings = UserSettings::binding();
    let has_changes = settings.clone().map(|s| {
        s.name != "Default Name" || s.theme != "Light" || s.notifications
    });

    let settings_summary = settings.clone().map(|s| {
        format!(
            "User: {} | Theme: {} | Notifications: {}",
            s.name,
            s.theme,
            if s.notifications { "On" } else { "Off" }
        )
    });
    let form_binding = settings.clone();

    vstack((
        "Settings",
        form(&form_binding),
        "Preview:",
        text(settings_summary),
        when(has_changes.clone(), {
            let save_binding = settings.clone();
            move || {
                button("Save Changes").action_with(&save_binding, |state: Binding<UserSettings>| {
                    save_settings(&state.get());
                })
            }
        })
        .or(|| text("No changes to save.")),
    ))
}

fn save_settings(settings: &UserSettings) {
    println!("Saving settings: {settings:?}");
}
}

Lists and Collections

Dynamic data deserves a declarative list view. WaterUI ships a List component plus helpers such as ForEach, ListItem, and NavigationPath so you can render changing collections with minimal boilerplate.

Building a List from a Collection

List::for_each wires nami collections into reusable rows. It accepts any Collection (reactive::collection::List, plain Vec, arrays, etc.) as long as each item exposes a stable identifier via Identifable. Reach for waterui::reactive::collection::List when you need runtime mutations (push, remove, sort) that notify the UI automatically.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::component::list::{List, ListItem};
use waterui::reactive::collection::List as ReactiveList;
use waterui::Identifable;
use waterui::AnyView;

#[derive(Clone)]
struct Thread {
    id: i32,
    subject: String,
}

impl Identifable for Thread {
    type Id = i32;

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

pub fn inbox() -> impl View {
    let threads = ReactiveList::from(vec![
        Thread { id: 1, subject: "Welcome".into() },
        Thread { id: 2, subject: "WaterUI tips".into() },
    ]);

    List::for_each(threads.clone(), |thread| {
        let subject = thread.subject.clone();
        ListItem {
            content: AnyView::new(text!("{subject}")),
            on_delete: None,
        }
    })
}
}

Whenever the threads binding changes (insert, delete, reorder), the list diffs the identifiers and only updates the affected rows.

Tip: If your data type lacks a natural identifier, wrap it in a struct that implements 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::Identifable;
#[derive(Clone)]
struct Thread {
    id: i32,
    subject: String,
}
impl Identifable for Thread {
    type Id = i32;
    fn id(&self) -> Self::Id {
        self.id
    }
}

fn row(thread: Thread, threads: ReactiveList<Thread>) -> ListItem {
    let list = threads.clone();
    let subject = thread.subject.clone();
    ListItem {
        content: AnyView::new(text!("{subject}")),
        on_delete: Some(Box::new(move |_, _| {
            if let Some(index) = list.iter().position(|t| t.id == thread.id) {
                list.remove(index);
            }
        })),
    }
}
}

Set disable_delete() to opt out globally for a given row.

Incremental Rendering with ForEach

ForEach is the engine behind List::for_each, but you can also use it directly whenever you need to render dynamic tuples without the rest of the list machinery. Wrap primitive types with id::SelfId (or implement Identifable) so each item has a stable key:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::reactive::collection::List as ReactiveList;
use waterui::views::ForEach;
use waterui::id;
use waterui::layout::stack::VStack;

fn chip_row(tags: ReactiveList<id::SelfId<String>>) -> impl View {
    VStack::for_each(tags, |tag| {
        let label = tag.clone().into_inner();
        text!("#{label}")
    })
}
}

Inside layout containers (hstack, vstack) you still get diffing and stable identity, which keeps animations and focus handling consistent.

Virtualisation and Performance

On native backends, List feeds identifiers into platform list views (SwiftUI List, GTK4 list widgets, DOM diffing on Web). That means virtualization and recycling are handled for you. Keep row construction pure and cheap; expensive work should happen in signals upstream.

Troubleshooting

  • Rows flicker or reorder unexpectedly – Ensure Identifable::id() stays stable across renders.
  • Deletes trigger twice – Some backends emit multiple delete actions for the same row to confirm removal. Guard inside the handler by verifying the item still exists before mutating state.
  • Nested scroll views – Wrap lists inside 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::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 raw view, while VideoPlayer renders controls.

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

pub fn trailer_player() -> impl View {
    let muted = binding(false);

    vstack((
        VideoPlayer::new("https://media.waterui.dev/trailer.mp4").muted(&muted),
        button("Toggle Mute").action_with(&muted, |state| state.toggle()),
    ))
}
}

Muting Model

  • VideoPlayer::muted(&Binding<bool>) maps a boolean binding onto the player’s internal volume.
  • 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::media::{LivePhoto, LivePhotoSource};

pub fn vacation_memory() -> impl View {
    let source = LivePhotoSource::new(
        "IMG_1024.jpg".into(),
        "IMG_1024.mov".into(),
    );

    LivePhoto::new(source)
}
}

Backends that don’t support Live Photos fall back to the still image.

The Media Enum

When the media type is decided at runtime, wrap it in Media. Rendering becomes a single view binding instead of a large match statement.

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

pub fn dynamic_media() -> impl View {
    let media = binding(Media::Image("https://example.com/photo.png".into()));

    // Later you can switch to Media::Video or Media::LivePhoto and the UI updates automatically.
    media
}
}

Media implements View, so you can drop it directly into stacks, grids, or navigation views. To switch the content, update the binding—WaterUI rebuilds the appropriate concrete view.

Media Picker

Then present the picker:

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

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

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

The Selected binding stores an identifier. Use Selected::load() asynchronously (via task) to receive the actual Media item and pipe it into your view tree.

#![allow(unused)]
fn main() {
use waterui::media::Media;
use waterui::reactive::binding;
use waterui::task::spawn;

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

button("Import").action_with(&selection, move |selected_binding| {
    let gallery = gallery.clone();
    if let Some(selected) = selected_binding.get() {
        spawn(async move {
            let media = selected.load().await;
            gallery.push(media);
        });
    }
});
}

Best Practices

  • Defer heavy processing – Image decoding and video playback happen in the backend. Avoid blocking the UI thread; let the renderer stream data.
  • Provide fallbacks – Always set .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::spawn;

#[derive(Clone, Debug)]
enum FetchState<T> {
    Idle,
    Loading,
    Loaded(T),
    Failed(String),
}

pub fn weather_card() -> impl View {
    let state: Binding<FetchState<Weather>> = binding(FetchState::Idle);

    vstack((
        text!("Weather"),
        content(state.clone()),
        button("Refresh").action(move || fetch_weather(state.clone())),
    ))
}

fn content(state: Binding<FetchState<Weather>>) -> impl View {
    match state.get() {
        FetchState::Idle => text("Tap refresh"),
        FetchState::Loading => text("Loading…"),
        FetchState::Loaded(ref data) => text!("{}°C", data.temperature),
        FetchState::Failed(ref err) => text!("Error: {err}"),
    }
}

fn fetch_weather(state: Binding<FetchState<Weather>>) {
    state.set(FetchState::Loading);

    spawn(async move {
        match reqwest::get("https://api.example.com/weather").await {
            Ok(response) => match response.json::<Weather>().await {
                Ok(weather) => state.set(FetchState::Loaded(weather)),
                Err(err) => state.set(FetchState::Failed(err.to_string())),
            },
            Err(err) => state.set(FetchState::Failed(err.to_string())),
        }
    });
}
}

spawn uses the executor configured for your backend (Tokio by default). Because bindings are clonable handles, you can move them into the async block safely.

Caching and Suspense

Wrap network bindings in Computed<Option<T>> or Suspense for placeholder states:

#![allow(unused)]
fn main() {
use waterui::widget::suspense::Suspense;

Suspense::new(
    state.map(|state| matches!(state, FetchState::Loaded(_))),
    || content(state.clone()),
    || text("Loading…"),
);
}

Error Handling Patterns

  • Retry buttons – When the binding holds Failed, show a 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::animation and is wired into every Signal via the AnimationExt trait. Instead of imperatively driving tweens, you attach animation metadata to the binding that powers your view, and the renderer interpolates between old and new values.

Animated Bindings

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::animation::Animation;
use core::time::Duration;

pub fn fading_badge() -> impl View {
    let visible = binding(true);
    let opacity = visible
        .map(|flag| if flag { 1.0 } else { 0.0 })
        .with_animation(Animation::ease_in_out(Duration::from_millis(250)));

    text!("Opacity: {opacity:.0}")
}
}
  • .animated() applies the platform-default animation.
  • .with_animation(Animation::Linear(..)) lets you choose easing.
  • .with_animation(Animation::spring(stiffness, damping)) yields physically based motion.

When the binding changes, the animation metadata travels with it through map, zip, or any other combinator.

Coordinated Transitions

Attach animation metadata to multiple bindings and update them together:

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

let offset = binding((0.0_f32, 0.0_f32));
let font_size = binding(14.0_f32);

let offset = offset.with_animation(Animation::spring(200.0, 15.0));
let font_size = font_size.animated();

vstack((text!("offset: {offset:?}, size: {font_size}"),))
}

Calling offset.set((0.0, 50.0)) and opacity.set(1.0) triggers both animations concurrently.

Testing and Debugging

  • Run with the WATERUI_ANIMATION=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 bridges async work and declarative views. Wrap any async builder and Suspense takes care of launching the future, showing a placeholder, cancelling the task when the view disappears, and re-rendering once data arrives.

Basic Usage

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

async fn load_profile() -> impl View {
    text("Ada Lovelace")
}

pub fn profile() -> impl View {
    Suspense::new(load_profile())
        .loading(|| text("Loading profile…"))
}
}
  • Suspense::new(future) starts the future on first render.
  • .loading(|| …) provides a custom placeholder. If omitted, Suspense looks 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::prelude::*;
use waterui::widget::suspense::DefaultLoadingView;

let env = Environment::new().with(DefaultLoadingView::new(|| text("Please wait…")));
}

Wrap your root view with .with_env(env) and all suspense boundaries inherit it.

Caching Results

Suspense rebuilds the future whenever the view is reconstructed. Cache results in bindings if you need persistence:

#![allow(unused)]
fn main() {
let user = binding(None);

fn user_view(user: Binding<Option<User>>) -> impl View {
    when(user.map(|u| u.is_some()), || profile_body(user.clone()))
        .or(|| Suspense::new(fetch_user(user.clone())))
}
}

fetch_user updates the binding when the network request resolves; the when branch takes over and the placeholder disappears.

Error Handling

Wrap async results in Result and convert errors into ErrorView:

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

Suspense::new(async {
    match fetch_feed().await {
        Ok(posts) => feed(posts).anyview(),
        Err(err) => Error::new(err).anyview(),
    }
})
}

You can also stack Suspense boundaries—an outer one fetching user data, an inner one fetching related content—to keep parts of the UI responsive.

Cancellation and Restart

Dropping a Suspense (e.g., navigating away) cancels the running future. Re-rendering recreates the future from scratch. Use this to restart long-polling or subscribe/unsubscribe from streams based on visibility.

Suspense keeps async ergonomic: describe loading and loaded states declaratively, and let WaterUI handle the lifecycles.

Error Handling

WaterUI does not force a bespoke error type. Instead, it lets you turn any std::error::Error into a renderable view via waterui::widget::error::Error, and customize how errors look using environment hooks.

From Errors to Views

Wrap errors with Error::new whenever a Result fails:

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

fn user_profile(id: u64) -> impl View {
    match load_user(id) {
        Ok(user) => profile_card(user),
        Err(err) => Error::new(err).anyview(),
    }
}
}

Error implements View, so you can drop it anywhere inside a stack or navigation view. The default renderer simply prints the error via Display.

Customizing the Presentation

Inject a DefaultErrorView into the environment to override how errors render globally:

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

let env = Environment::new().with(DefaultErrorView::new(|err| {
    vstack((
        text!("⚠️ Something went wrong").bold(),
        text!("{err}").foreground(Color::srgb(200, 80, 80)),
        button("Retry").action(|| task(retry_last_request())),
    ))
    .padding()
}));

app_root().with_env(env)
}

Now every Error produced inside the tree uses this layout automatically.

Inline Result Helpers

If you prefer chaining, use the error_view helper to map Result<T, E> into either a view or an Error:

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

fn result_view<T, E>(result: Result<T, E>, render: impl FnOnce(T) -> AnyView) -> AnyView
where
    E: std::error::Error + 'static,
{
    match result {
        Ok(value) => render(value),
        Err(err) => Error::new(err).anyview(),
    }
}
}

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

Contextual Actions

Because error builders receive the Environment (if you use a custom view that captures it), you can extract services. Or, use use_env inside your error view builder:

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

Pairing with Suspense

When fetching data asynchronously, wrap the result inside Suspense and convert failures into Error instances. Users get a consistent loading/error pipeline without sprinkling Result logic throughout the UI.

Consistent, informative error displays keep apps trustworthy. Centralize styling via DefaultErrorView and lean on Error::new wherever fallible operations occur.

Plugin

WaterUI’s plugin system is built at the top of environment system.

#![allow(unused)]
fn main() {
pub trait Plugin: Sized + 'static {
    /// Installs this plugin into the provided environment.
    ///
    /// This method adds the plugin instance to the environment's storage,
    /// making it available for later retrieval.
    ///
    /// # Arguments
    ///
    /// * `env` - A mutable reference to the environment
    fn install(self, env: &mut Environment) {
        env.insert(self);
    }

    /// Removes this plugin from the provided environment.
    ///
    /// # Arguments
    ///
    /// * `env` - A mutable reference to the environment
    fn uninstall(self, env: &mut Environment) {
        env.remove::<Self>();
    }
}
}

Plugins are just values stored inside the environment. Because they are regular Rust structs, you can bundle services (network clients, analytics, feature flags) and install them once near your entry point.

Example: i18n

waterui_i18n::I18n ships as a plugin that rewrites Text views based on the active locale.

#![allow(unused)]
fn main() {
use waterui_i18n::I18n;
use waterui::prelude::*;
use waterui::text::locale::Locale;

fn install_i18n(env: &mut Environment) {
    let mut i18n = I18n::new();
    i18n.insert("en", "greeting", "Hello");
    i18n.insert("fr", "greeting", "Bonjour");

    i18n.install(env);            // stores translations + hook
    env.insert(Locale("fr".into())); // pick initial locale
}
}

Every text!("greeting") now passes through the hook registered during install, swapping the content with the localized string.

Building Your Own Plugin

  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::prelude::*;
use waterui::plugin::Plugin;
use waterui::impl_extractor;

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

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

impl_extractor!(Telemetry);
}

Now handlers can request Use<Telemetry> exactly like bindings or environment values.

Lifecycle Hooks

Override uninstall when the plugin must clean up:

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

struct SessionManager;

impl SessionManager {
    fn shutdown(&self) {}
}

impl Plugin for SessionManager {
    fn uninstall(self, env: &mut Environment) {
        self.shutdown();
        env.remove::<Self>();
    }
}
}

Although most plugins live for the entire lifetime of the app, uninstall hooks are handy in tests or when swapping environments dynamically (multi-window macOS apps, for example).

Plugins keep cross-cutting concerns modular. Keep their public surface small, expose access through extractors, and leverage environment hooks to integrate with the rest of the view tree.

Accessibility

Every WaterUI control ships with reasonable accessibility metadata, but composite views sometimes need extra context. The waterui::accessibility module exposes helpers for customizing labels, roles, and states so assistive technologies accurately describe your UI.

Labels

AccessibilityLabel overrides the spoken name of a view:

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

pub fn destructive_button() -> impl View {
    button("🗑️").a11y_label("Delete draft")
}
}

The emoji-only button now announces “Delete draft” to VoiceOver/TalkBack users.

Roles

When building custom widgets, mark their semantic role:

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

pub fn nav_drawer() -> impl View {
    vstack((/* ... */))
        .a11y_role(AccessibilityRole::Navigation)
}
}

Available roles cover buttons, list items, landmarks, menus, tabs, sliders, and more. Choose the one that matches the behaviour of your component.

States

AccessibilityState communicates dynamic information (selected, expanded, busy, etc.). Currently, WaterUI exposes these through specific view modifiers or metadata properties, and they are often handled automatically by built-in controls.

Patterns

  • Lives regions – For toast notifications or progress updates, install an environment hook that announces changes via 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::prelude::*;
use waterui_i18n::I18n;
use waterui::text::locale::Locale;

fn env_with_i18n() -> Environment {
    let mut i18n = I18n::new();
    i18n.insert("en", "greeting", "Hello");
    i18n.insert("fr", "greeting", "Bonjour");

    let mut env = Environment::new();
    i18n.install(&mut env);
    env.insert(Locale("fr".into()));
    env
}
}

Every text!("greeting") now resolves through the i18n map and emits the French string.

Dynamic Locales

Locales are reactive—store a Binding<Locale> in the environment and update it when the user picks a new language. Because the plugin uses computed signals, existing text views update automatically.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::text::locale::Locale;
pub fn switch_locale(env: &mut Environment) {
    let locale = binding(Locale("en".into()));
    env.insert(locale.clone());
    button("Switch to French").action_with(&locale, |loc| loc.set(Locale("fr".into())));
}
}

Parameterized Strings

Translations can include placeholders; format values client-side before passing them to text!, or store template keys in the map and handle interpolation in your code:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui_i18n::I18n;
use waterui::text::locale::Locale;
pub fn formatted_count() -> impl View {
    let mut i18n = I18n::new();
    i18n.insert("en", "items_label", "items");
    let locale = Locale("en".into());
    let item_count = 3;
    
    // In a real app, you would fetch the translation dynamically
    let label = i18n.get(&locale.0, "items_label");
    text!("{count} {text}", count = item_count, text = label)
}
}

Future releases will expand the formatter API so you can define ICU-style templates directly in the translation map.

Pluralization and Formatting

For locale-aware numbers and dates, reach for waterui::text::locale:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::text::locale::{DateFormatter, Locale};
use time::Date;

pub fn formatted_date() -> impl View {
    let date = binding(Date::from_calendar_date(2025, 1, 1).unwrap());
    let formatter = DateFormatter { locale: Locale("en-US".into()) };
    Text::format(date, formatter)
}
}

Pair these with I18n so both phrasing and formatting respect the user’s locale.

Internationalization is just an environment plugin—install it once, keep translations in sync with your keys, and the rest of WaterUI’s text pipeline stays reactive and type-safe.

Canvas

WaterUI ships with a first-party immediate-mode canvas API via waterui::graphics::Canvas. It uses Vello for high-performance GPU rendering of 2D vector graphics.

Basic Usage

The canvas API provides a drawing context that lets you stroke and fill shapes:

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::graphics::Canvas;
use waterui::graphics::kurbo::{Circle, Rect, Point};
use waterui::graphics::peniko::Color;

fn custom_drawing() -> impl View {
    Canvas::new(|ctx| {
        let center = ctx.center();
        
        // Fill a circle
        ctx.fill(
            Circle::new(center, 50.0),
            Color::RED,
        );

        // Stroke a rectangle
        ctx.stroke(
            Rect::from_origin_size(Point::new(10.0, 10.0), (100.0, 50.0)),
            Color::BLUE,
            2.0,
        );
    })
    .height(200.0) // Constrain height if needed
}
}

The drawing closure runs every frame (or when the view needs redrawing). waterui::graphics re-exports types from kurbo (geometry) and peniko (colors/brushes).

Reactive Drawing

To make the canvas reactive, capture bindings in the closure or use Dynamic::watch to rebuild the canvas when state changes.

#![allow(unused)]
fn main() {
use waterui::prelude::*;
use waterui::graphics::Canvas;
use waterui::graphics::kurbo::Circle;
use waterui::graphics::peniko::Color;

fn reactive_circle(radius: Binding<f64>) -> impl View {
    // Rebuild the canvas when radius changes
    Dynamic::watch(radius, |r| {
        Canvas::new(move |ctx| {
            ctx.fill(
                Circle::new(ctx.center(), r),
                Color::GREEN,
            );
        })
    })
}
}

Advanced Rendering

For more complex needs, waterui::graphics::GpuSurface allows direct access to wgpu for custom render pipelines, while ShaderSurface simplifies using WGSL fragment shaders. See the Shaders chapter for details.

Shaders

WaterUI supports custom GPU shaders via waterui::graphics::ShaderSurface. You can write WGSL fragment shaders that render directly to the view’s surface.

Using ShaderSurface

The easiest way to use shaders is with the shader! macro, which loads a WGSL file at compile time:

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

fn flame_effect() -> impl View {
    shader!("flame.wgsl")
        .width(400.0).height(500.0)
}
}

Or define the shader inline:

use waterui::graphics::ShaderSurface;

fn gradient() -> impl View {
    ShaderSurface::new(r#"
        @fragment
        fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
            return vec4<f32>(uv.x, uv.y, 0.5, 1.0);
        }
    "#)
}

Built-in Uniforms

ShaderSurface automatically prepends the following uniform definition (and a full-screen vertex shader) to your code. Do not redefine this struct or variable in your WGSL file.

// Auto-generated prelude (available to your shader):
struct Uniforms {
    time: f32,           // Elapsed time in seconds
    resolution: vec2<f32>, // Surface size in pixels
    _padding: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;

Your shader code should strictly define the fragment entry point:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let t = uniforms.time;
    // ...
}

uv coordinates are normalized (0.0 to 1.0).

Advanced GPU Rendering

ShaderSurface is limited to single-pass fragment shaders. For full control over the rendering pipeline (custom vertex shaders, multiple passes, compute shaders), implement the GpuRenderer trait and use GpuSurface.

#![allow(unused)]
fn main() {
use waterui::graphics::{GpuRenderer, GpuSurface, GpuContext, GpuFrame};

struct MyRenderer;

impl GpuRenderer for MyRenderer {
    fn setup(&mut self, ctx: &GpuContext) {
        // Create pipelines, buffers, bind groups...
    }

    fn render(&mut self, frame: &GpuFrame) {
        // Create encoder, render passes, submit to queue...
    }
}

fn custom_render() -> impl View {
    GpuSurface::new(MyRenderer)
}
}

See the flame example for a complete implementation of a multi-pass HDR renderer using GpuSurface and wgpu.

How WaterUI Renders

WaterUI renders your application by translating the Rust view tree into native platform widgets (SwiftUI on Apple platforms, Jetpack Compose on Android). This process is mediated by a C FFI layer (waterui-ffi) that allows the native runtime to traverse and observe the Rust view hierarchy.

The Rendering Pipeline

  1. Tree Construction: When your app starts, WaterUI creates the initial view tree defined in your app function. This tree is composed of View structs.
  2. FFI Traversal: The native backend (written in Swift or Kotlin) holds a pointer to the root Rust view. It calls waterui_view_id to identify the type of the view.
  3. Leaf vs. Composite:
    • Raw Views (Leafs): If the view is a “Raw View” (like Text, Button, VStack), the backend downcasts it using waterui_force_as_* functions to extract the configuration (e.g., the text string, the button action). It then creates the corresponding native widget (e.g., SwiftUI.Text).
    • Composite Views: If the view is a custom component (like your MyView), the backend calls waterui_view_body, which triggers the View::body method in Rust. This expands the view into its underlying structure. The backend then repeats the process for the returned body.
  4. Reactive Updates: When a view uses a reactive value (like a Binding or Computed), the native backend registers a watcher. When the value changes in Rust, the watcher notifies the native backend, triggering a UI update for that specific component.

The FFI Layer

The waterui-ffi crate is the bridge between Rust and the host platform. It exports:

  • Entry Points: waterui_init and waterui_app to bootstrap the runtime.
  • Type Identification: A stable 128-bit type ID system (WuiTypeId) that allows the native side to recognize Rust types, even across dynamic library reloads.
  • Opaque Types: wrappers like WuiAnyView and WuiEnv that allow passing complex Rust objects as pointers.
  • Property Accessors: C-compatible functions to read properties from configuration structs (e.g., waterui_read_binding_i32).

Talking to Native

waterui_core::Native<T> is the wrapper for platform-specific content. Views that implement the NativeView trait can be wrapped in Native<T>. This signals to the framework that this node should be handled by the platform backend directly, rather than being expanded further in Rust.

For example, Text is a NativeView. When you write text("Hello"), it creates a Text struct. The FFI layer exposes this to Swift/Kotlin, which then renders a system label.

Diffing and Identity

For lists and collections, WaterUI uses the Identifable trait. When a list changes, the framework uses these IDs to calculate a diff. This information is passed to the native backend’s list implementation (e.g., ForEach in SwiftUI), ensuring efficient updates and animations for insertions, deletions, and moves.

Performance Implications

  • Logic in Rust: Your business logic, state management, and layout calculations happen in Rust.
  • Rendering in Native: The expensive work of drawing pixels, text layout, and compositing is handled by the OS’s highly optimized UI toolkit.
  • Boundary Crossing: Crossing the FFI boundary has a small cost. WaterUI minimizes this by only crossing when necessary (initial build and reactive updates) and by using efficient pointer-based accessors.

Hot Reload Internals

Hot reload allows you to modify your Rust code and see changes instantly in the running application without restarting the app or losing state. WaterUI achieves this by compiling your code into a dynamic library (.dylib, .so, or .dll) and injecting it into the running process.

Architecture

The system consists of three parts:

  1. The CLI (waterui-cli): Watches your source files, rebuilds the project as a dynamic library when changes are detected, and hosts a WebSocket server.
  2. The Runtime (HotReload View): A special view component in your app that connects to the CLI, downloads the new library, and swaps the view pointer.
  3. The Macro (#[hot_reload]): An attribute macro that instruments functions to be individually reloadable.

Per-Function Hot Reload

You can mark specific view functions with #[hot_reload]. This wraps the function body in a HotReloadView.

#![allow(unused)]
fn main() {
#[hot_reload]
fn my_feature() -> impl View {
    vstack((
        text("Edit me!"),
        button("Click me", || println!("Clicked")),
    ))
}
}

The macro generates a unique C-exported symbol for this function (e.g., waterui_hot_reload_my_feature).

The Reload Process

  1. Change Detection: The CLI detects a file save.
  2. Rebuild: It runs cargo build with the waterui_hot_reload_lib configuration.
  3. Broadcast: The new binary is broadcast over WebSocket to the running app.
  4. Load: The app writes the binary to a temporary file and loads it using libloading.
  5. Swap: The HotReloadView looks up its specific symbol in the new library. If found, it calls the function to get the new view structure and replaces its current content.

State Preservation

Because only the view construction logic is reloaded, the underlying state (held in Bindings or the Environment) is preserved. The new view structure simply re-binds to the existing state.

Limitations

  • Symbol Stability: The function signature must return impl View.
  • Global State: Changes to global state initialization or main entry points usually require a full restart.
  • Struct Layout: Changing the fields of a struct that is shared between the main app and the hot-reloaded library can cause undefined behavior due to ABI mismatches. It is safest to tweak view logic and local variables.

Layout Internals

WaterUI implements a custom layout protocol that runs in Rust but coordinates with the native backend. This ensures consistent layout logic across all platforms while using native widgets.

The Protocol

The layout system is based on a two-phase “Propose and Response” model, defined by the Layout trait in waterui-core.

#![allow(unused)]
fn main() {
pub trait Layout: Debug {
    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size;
    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect>;
}
}

1. Sizing Phase (size_that_fits)

The parent container proposes a size to the layout. The layout then negotiates with its children to determine its own ideal size.

  • ProposalSize can have Option<f32> dimensions. None means “unconstrained” (ask for ideal size), while Some(val) means “constrained” (fit within this size).

2. Placement Phase (place)

Once the size is determined, the parent tells the layout its final bounds. The layout then calculates the Rect (position and size) for each child.

SubView Proxy

Layouts do not interact with Views directly. They interact with SubView proxies. This abstracts the differences between Rust views and native widgets.

#![allow(unused)]
fn main() {
pub trait SubView {
    fn size_that_fits(&self, proposal: ProposalSize) -> Size;
    fn stretch_axis(&self) -> StretchAxis;
    fn priority(&self) -> i32;
}
}

The native backend implements SubView via FFI. When Rust calls size_that_fits on a SubView, it triggers a call to the native platform to measure the actual text or widget.

Stretch Axis

Views declare how they want to behave when there is extra space using StretchAxis:

  • None: Content-sized (e.g., Text, Button).
  • Horizontal: Expands width (e.g., TextField, Slider).
  • Vertical: Expands height.
  • Both: Fills all space (e.g., Color, ScrollView).
  • MainAxis / CrossAxis: Context-dependent (e.g., Spacer, Divider).

Containers like VStack and HStack use this information to distribute space proportionally among flexible children.

Logical Pixels

All layout calculations in Rust use Logical Pixels (points).

  • 1 logical pixel = 1 point in iOS/macOS.
  • 1 logical pixel = 1 dp in Android.

The native backend handles the conversion to physical pixels for rendering. This ensures that a width(100.0) looks the same physical size on all devices.

Writing Custom Layouts

To create a custom layout:

  1. Implement the Layout trait.
  2. Wrap it in a FixedContainer (for static children) or LazyContainer (for dynamic ForEach children).
#![allow(unused)]
fn main() {
struct MyLayout;

impl Layout for MyLayout {
    // ... implement size_that_fits and place
}

pub fn my_layout(content: impl View) -> impl View {
    FixedContainer::new(MyLayout, content)
}
}

Best Practices for Library Authors

When building reusable component libraries for WaterUI, follow these patterns to ensure your components are idiomatic, performant, and easy to use.

1. Use the configurable! Macro

For views that have properties (like Text, Button, Slider), use the configurable! macro. This generates the boilerplate for ConfigurableView, ViewConfiguration, and the builder pattern methods.

#![allow(unused)]
fn main() {
use waterui_core::configurable;

pub struct BadgeConfig {
    pub label: AnyView,
    pub color: Computed<Color>,
}

configurable!(Badge, BadgeConfig);

impl Badge {
    pub fn new(label: impl View) -> Self {
        Self(BadgeConfig {
            label: AnyView::new(label),
            color: Color::RED.into_computed(),
        })
    }

    // Builder method
    pub fn color(mut self, color: impl IntoComputed<Color>) -> Self {
        self.0.color = color.into_computed();
        self
    }
}
}

2. Accept IntoComputed / IntoSignal

Allow users to pass either static values or reactive bindings by accepting impl IntoComputed<T> or impl IntoSignal<T>.

#![allow(unused)]
fn main() {
pub fn color(mut self, color: impl IntoComputed<Color>) -> Self {
    self.0.color = color.into_computed();
    self
}
}

This lets users write .color(Color::RED) (static) or .color(my_binding) (reactive) interchangeably.

3. Use Environment for Context

Avoid passing global configuration (like themes) through arguments. Use the Environment instead. Define a struct for your config and implement Extractor.

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct MyTheme {
    border_radius: f32,
}

// In your view
use_env(|Use(theme): Use<MyTheme>| {
    // use theme.border_radius
})
}

4. Leverage ViewExt

The ViewExt trait provides common modifiers like .padding(), .background(), and .gesture(). Implement your component as a View so users can chain these modifiers.

5. Favor Composition

Build complex components by composing existing primitives (VStack, Text, Shape) rather than trying to implement custom rendering logic, unless absolutely necessary. Composition ensures your component benefits from the native backend’s optimizations and accessibility features.

6. Testing

Use the waterui-macros crate to create testable forms and view structures. Ensure your component works with the reactive system by testing it with Binding updates.

Philosophy

WaterUI’s design balances three goals:

  1. Native-First – We render through native toolkits (SwiftUI, Jetpack Compose) so apps look, feel, and behave like first-class citizens on every platform. We believe users can tell the difference, and platform conventions matter.
  2. Reactive, Not Virtual DOM – We use fine-grained reactivity (nami). Changes in state propagate directly to the specific properties or views that need updating, without diffing a virtual tree.
  3. Rust all the way – You write your UI logic, layout, and state management in Rust. The FFI layer handles the translation to the platform, but you stay in Rust.

Why Native?

Drawing pixels (like Flutter or some game engines) gives you control, but you lose platform features for free: accessibility, text selection, native context menus, keyboard navigation, and OS-specific gestures. By wrapping native widgets, WaterUI ensures your app improves as the OS improves.

Why Reactive?

Immediate mode GUIs are great for games, but retaining state and minimizing CPU usage is crucial for mobile apps. A reactive graph allows us to wake up only when necessary, saving battery and keeping the UI smooth.

The “Water” Metaphor

Just as water takes the shape of its container, WaterUI apps adapt to the platform they run on. A Toggle becomes a UISwitch on iOS and a Switch on Android. A NavigationStack becomes a UINavigationController or a NavHost. You describe the intent, and WaterUI handles the adaptation.

Automation and Troubleshooting

Use this appendix whenever you need to integrate WaterUI workflows into CI or recover from a broken toolchain.

Deterministic CLI Runs

Pass --format json (or --json) to every water command to disable prompts. Provide --yes, --platform, and --device flags explicitly so scripts never block. Capture the resulting JSON to feed dashboards or orchestrators.

water devices --json | jq '.[0].name'
water run --platform web --project water-demo --json --yes

Keeping Examples Green

All chapters rely on mdbook test and cargo test inside the tutorial workspace. Run both locally before opening a pull request:

mdbook test
cargo test -p waterui-tutorial-book

mdbook test compiles each code fence, so avoid ignore blocks—use no_run only when the example cannot execute inside a test harness.

Diagnosing Platforms

  • water doctor --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.