WaterUI Tutorial Book

Welcome to the complete guide for building cross-platform applications with WaterUI! This book will take you from a complete beginner to an advanced WaterUI developer, capable of building sophisticated applications that run on desktop, web, mobile, and embedded platforms.

What is WaterUI?

WaterUI is a modern, declarative UI framework for Rust that enables you to build applications using a single codebase for multiple platforms. It combines the safety and performance of Rust with an intuitive, reactive programming model inspired by SwiftUI and React.

Key Features

  • 🚀 Cross-Platform: Write once, deploy everywhere - desktop, web, mobile, embedded
  • 🦀 Type-Safe: Leverage Rust's powerful type system for compile-time correctness
  • ⚡ Reactive: Automatic UI updates when data changes
  • 📝 Declarative: Describe what your UI should look like, not how to build it

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.

Roadmap

Check our roadmap here!

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

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

Creating Your First Project

Let's create a new WaterUI project from scratch:

cargo new hello-waterui
cd hello-waterui

This creates a new Rust project with the following structure:

hello-waterui/
├── Cargo.toml
├── src/
│   └── main.rs
└── .gitignore

Adding WaterUI Dependencies

Edit your Cargo.toml file to include WaterUI:

Filename: Cargo.toml

[package]
name = "hello-waterui"
version = "0.1.0"
edition = "2024"

[dependencies]
waterui = { path = ".." }
# Backend will be chosen in the future

Web Development (WebAssembly)

For web development, install additional tools:

# Install wasm-pack for building WebAssembly packages
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

# Add WebAssembly target
rustup target add wasm32-unknown-unknown

Hello,world!

Let's create a simple "Hello, World!" application to verify everything works.

Filename: src/main.rs

use waterui::prelude::*;

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

fn main() {
    // Backend-specific initialization will be added here
    // For now, we just define the view
}

Building and Running

Build and run your application:

cargo run

If everything is set up correctly, you should see a window with "Hello, WaterUI! 🌊" displayed.

Troubleshooting Common Issues

Rust Version Too Old

Error: error: package requires Rust version 1.87

Solution: Update Rust:

rustup update

Windows Build Issues

Error: Various Windows compilation errors

Solutions:

  1. Ensure you have the Microsoft C++ Build Tools installed
  2. Use the x86_64-pc-windows-msvc toolchain
  3. Consider using WSL2 for a Linux-like environment

Your First WaterUI App

Now that your development environment is set up, let's build your first interactive WaterUI application! We'll create a counter app that demonstrates the core concepts of views, state management, and user interaction.

What We'll Build

Our counter app will feature:

  • A display showing the current count
  • Buttons to increment and decrement the counter
  • A reset button
  • Dynamic styling based on the counter value

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

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

Setting Up the Project

Create a new project for our counter app:

cargo new counter-app
cd counter-app

Update your Cargo.toml:

Filename: Cargo.toml

[package]
name = "counter-app"
version = "0.1.0"
edition = "2024"

[dependencies]
waterui = { path = "../" }

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/main.rs

use waterui::prelude::*;

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

fn main() {
    // Backend-specific initialization will be added here
    // For now, we just define the view
}

Run this to make sure everything works:

cargo run

You should see a window with "Counter App" displayed.

Step 2: Adding Layout

Now let's add some layout structure using stacks:

use waterui::prelude::*;

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

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

Step 3: Adding Reactive State

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

use waterui::prelude::*;
use waterui::reactive::binding;

fn counter() -> impl View {
    let count = binding(0);
    vstack((
        "Counter App",
        // Use text! macro for reactive text
        text!("Count: {count}"),
        hstack((
            button("- Decrement").action_with(&count, |count| count.decrement(1)),
            button("+ Increment").action_with(&count, |count| count.increment(1)),
        )),
    ))
}

Run this and try clicking the buttons! The counter should update in real-time.

Understanding the Code

Let's break down the key concepts introduced:

Reactive State with binding

let count = 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

// ✅ Use the text! macro for reactive display
text!("Count: {count}")
  • The text! macro automatically handles reactivity
  • The text will update whenever count changes

Event Handling

button("- Decrement").action_with(&count, |count| count.decrement(1))
  • .action_with() attaches an event handler with captured state
  • Binding<i32>::decrement and Binding<i32>::increment provide ergonomic arithmetic updates without manual closures

Layout with Stacks

vstack((...))  // Vertical stack
hstack((...))  // Horizontal stack

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:

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

Built-in Views

WaterUI provides many built-in Views for common UI elements:

Text Views

// Static text
"Hello, World!"

// Reactive text
text!("Hello, {name}!")

// Styled text
waterui_text::Text::new("Important!")
    .size(24.0)

Control Views

use waterui::prelude::*;
use waterui::reactive::binding;
// Button
button("Click me")
    .action(|| println!("Clicked!"))

// Text field
let input = binding(String::new());
text_field(&input)
    .placeholder("Enter text...")

// Toggle switch
let enabled = binding(false);
toggle(&enabled)

Layout Views

// Vertical stack
vstack((
    "First",
    "Second",
    "Third",
))

// Horizontal stack
hstack((
    button("Cancel"),
    button("OK"),
))

// Overlay stack
zstack((
    background_view(),
    content_view(),
    overlay_view(),
))

Creating Custom Views

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

// Simpler and cleaner - no View trait needed!
fn welcome_message(name: &str) -> impl View {
    vstack((
        waterui_text::Text::new("Welcome!").size(24.0),
        waterui_text::Text::new(format!("Hello, {}!", name)),
    ))
}

// Usage - functions are automatically views!
welcome_message("Alice")

// Can also use closures for lazy initialization
let lazy_view = || welcome_message("Bob");

Struct Views (For Components with State)

Only use the View trait when your component needs to store state, or you prefer direct access to the environment in body.

// Only needed when the struct holds state
use waterui::prelude::*;
use waterui::reactive::binding;

struct CounterWidget {
    initial_value: i32,
    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 |count| count.increment(self.step)),
        ))
    }
}

// Usage
CounterWidget { 
    initial_value: 0,
    step: 5,
}

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:

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.

pub trait Signal: Clone + 'static {
    type Output;
    
    // 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.

use waterui::prelude::*;

// Create mutable reactive state with automatic type conversion
let counter = binding(0);
let name = binding("Alice");

// 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.

use nami::SignalExt;

let first_name = binding("Alice");
let last_name = binding("Smith");

// Create a computed signal that updates automatically
let full_name = first_name.zip(last_name).map(|(first, last)| {
    format!("{} {}", first, last)
});

// `full_name` will re-compute whenever `first_name` or `last_name` changes.

The binding(value) helper is re-exported from WaterUI, giving you a concise way to initialize bindings with automatic Into conversions (e.g. binding("hello") -> Binding<String>). Once you have a binding, reach for Binding's convenience methods like .increment(), .toggle(), or .push() to keep your state updates expressive and ergonomic.

When Type Inference Needs Help

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

// 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.

use nami::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.

let name = 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().

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.

let user = binding(User { name: "Alice".to_string(), tags: vec![] });

// Modify the user in-place
user.update(|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.

let is_visible = 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.

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)With s! (Concise & Reactive)
```rust,ignore
let name = binding("John");
let age = binding(30);

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

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


The `s!` macro also supports named arguments for even greater clarity:
```rust,ignore
let message = s!("{name} is {age} years old.");

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.
use std::time::Duration;
use nami::SignalExt;

let query = binding(String::new());

// A debounced signal that only updates 200ms after the user stops typing.
let debounced_query = query.debounce(Duration::from_millis(200));

// A derived signal that performs a search when the debounced query is not empty.
let search_results = debounced_query.map(|q| {
    if q.is_empty() {
        vec![]
    } else {
        // perform_search(&q)
        vec!["Result 1".to_string()]
    }
});

By mastering these fundamental concepts, you can build complex, efficient, and maintainable reactive UIs with WaterUI.

The Environment System

The Environment system is WaterUI's approach to dependency injection and configuration management. It provides a type-safe way to pass data, themes, services, and other dependencies through your view hierarchy without explicit parameter passing. Think of it as a context that flows down through your UI tree.

Basic Environment Usage

Storing Values

The most common way to add values to an environment is with the .with() method:

use waterui::prelude::*;

#[derive(Debug, Clone)]
struct AppConfig {
    api_url: String,
    timeout_seconds: u64,
}

#[derive(Debug, Clone)]
struct Theme {
    primary_color: waterui::core::Color,
    background_color: waterui::core::Color,
}

pub fn entry() -> impl View {
    home()
        .with(AppConfig {
        api_url: "https://api.example.com".to_string(),
        timeout_seconds: 30,
    })
    .with(Theme {
        primary_color: (0.0, 0.4, 1.0).into(),
        background_color: (1.0, 1.0, 1.0).into(),
    })
}

pub fn home() -> impl View{
	// Your home page
}

Accessing Values in Views

For Struct Views

Views can access environment values in their body method:

struct ApiStatusView;

impl View for ApiStatusView {
    fn body(self, env: &Environment) -> impl View {
        // Get configuration from environment
        let config = env.get::<AppConfig>()
            .expect("AppConfig should be provided");
            
        let theme = env.get::<Theme>()
            .expect("Theme should be provided");
        
        vstack((
            waterui_text::Text::new(config.api_url.clone()).foreground(theme.primary_color.clone()),
            waterui_text::Text::new(format!("Timeout: {}s", config.timeout_seconds)).size(14.0),
        ))
        .background(waterui::background::Background::color(theme.background_color.clone()))
    }
}

For Function Views

Function views don't directly receive the env parameter. Instead, you can compose them with struct views that can access the environment. Alternatively, you can use action_with to extract values from the environment in event handlers.

In action

use waterui::prelude::*;
use waterui::reactive::binding;

#[derive(Debug, Clone)]
pub struct Message(&'static str);

pub fn click_me() -> impl View {
    let value = binding(String::new());
    vstack((
        button("Show environment value").action_with(&value, |value, msg: waterui::core::extract::Use<Message>| {
            value.set(msg.0 .0.to_string());
        }),
        text!("{}", value),
    ))
    .with(Message("I'm Lexo"))
}

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::*;
use waterui::widget::condition::when;
use waterui::reactive::binding;

pub fn status_card() -> impl View {
    let is_online = 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:

let show_help = binding(false);
when(!show_help.clone(), || text("Need help?"));

For complex logic, derive a computed boolean with SignalExt:

use nami::SignalExt;

let cart_items = binding::<Vec<CartItem>>(Vec::new());
let has_items = cart_items.map(|items| !items.is_empty());

when(has_items, || checkout_button())
    .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:

let selected_user = binding::<Option<User>>(None);

when(selected_user.map(|user| user.is_some()), || {
    // Safe to unwrap because the branch only runs when a user exists.
    let profile = selected_user.unwrap_or_else(User::placeholder);
    profile_view(profile)
})
.or(|| placeholder_tile())

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:

pub fn dashboard() -> impl View {
    let has_error = binding(false);

    vstack((
        header(),
        when(has_error.clone(), || error_banner()),
        content(),
    ))
}

Combine when with button actions for toggles:

let expanded = binding(false);

vstack((
    button("Details").action_with(&expanded, |state| state.toggle()),
    when(expanded, || detailed_view()),
))

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)

use waterui::prelude::*;
use waterui::component::layout::stack::vstack;
use waterui::reactive::binding;

pub fn profile_card() -> impl View {
    let name = binding("Ada Lovelace");
    let followers = 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)

use waterui::prelude::*;
use waterui::component::layout::{spacer, stack::hstack};

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.

use waterui::prelude::*;
use waterui::component::layout::padding::EdgeInsets;
use waterui::component::layout::stack::zstack;
use waterui::components::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((0.9, 0.1, 0.1).into()))
            .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.

use waterui::prelude::*;
use waterui::component::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).

use waterui::prelude::*;
use waterui::component::layout::padding::EdgeInsets;

fn message_bubble(text: impl Into<Str>) -> impl View {
    text(text)
        .padding_with(EdgeInsets::symmetric(8.0, 12.0))
        .background(waterui::background::Background::color((0.18, 0.2, 0.25).into()))
        .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:

use waterui::prelude::*;
use waterui::component::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.

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

pub fn emoji_palette() -> impl View {
    grid(4, [
        row(("😀", "😁", "😂", "🤣")),
        row(("😇", "🥰", "😍", "🤩")),
        row(("🤔", "🤨", "🧐", "😎")),
    ])
    .spacing(12.0)                             // Uniform horizontal + vertical spacing
    .alignment(Alignment::Center)              // Align cells inside their slots
    .padding()
}

Notes:

  • Grids require a concrete width proposal. On desktop, wrap them in a parent that constrains width (e.g. .frame().max_width(...)) when needed.
  • Each row may contain fewer elements than the declared column count; the layout simply leaves the trailing cells empty.
  • Use Alignment::Leading / Trailing / Top / Bottom to align items inside each grid cell.

Frames and Explicit Sizing

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

use waterui::prelude::*;
use waterui::component::layout::frame::Frame;
use waterui::component::layout::stack::vstack;

fn gallery_thumbnail(content: impl View) -> impl View {
    Frame::new(content)
        .width(160.0)
        .height(120.0)
        .alignment(Alignment::Center)
}

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

Layout Troubleshooting Checklist

  • Unexpected stretching – Make sure there isn’t an extra spacer() or a child returning an infinite proposal. Wrapping the content in .padding_with(EdgeInsets::all(0.0)) can help visualise what area the view thinks it owns.
  • Grid clipping – Provide a finite width (wrap in a parent frame) and watch for rows with taller content than their neighbours.
  • Overlapping overlayszstack honours alignment. Apply additional .padding_with or wrap the child in a Frame to fine-tune positions.
  • Platform differences – Remember that scroll behaviour is delegated to backends. Test on each target platform when tweaking scrollable layouts.

Where to Go Next

Explore the advanced layout chapter for details on implementing custom Layout types, or scan the waterui_layout crate for lower-level primitives like Container and ProposalSize. Armed with stacks, spacers, padding, grids, and frames you can replicate the majority of everyday UI structures in a clear, declarative style.

Text and Typography

Text is the backbone of most interfaces. WaterUI gives you two complementary approaches: lightweight labels for quick strings, and the configurable Text view for styled, reactive content. Think of the split the same way Apple distinguishes between Text and bare strings in SwiftUI, or Flutter differentiates Text from const literals.

Quick Reference

NeedUseNotes
Static copy, no stylingstring literal / String / StrLowest overhead; respects the surrounding layout but cannot change font or colour.
Styled or reactive textText / text! macroFull typography control and automatic updates when bound data changes.
Format existing signalstext!("Total: {amount:.2}", amount)Uses the nami::s! formatter under the hood.
Display non-string signalsText::display(binding_of_number)Wraps any Display value, recalculating when the binding updates.
Custom formatter (locale-aware, currency, dates)Text::format(value, Formatter)See waterui_text::locale for predefined formatters.

Labels: Zero-Cost Strings

use waterui::prelude::*;
use waterui::component::layout::stack::vstack;

pub fn hero_copy() -> impl View {
    vstack((
        "WaterUI",                      // &'static str
        String::from("Rust-first UI"),  // Owned String
        Str::from("Lightning fast"),    // WaterUI's rope-backed string
    ))
}

Labels have no styling hooks and stay frozen after construction. Use them for static headings, inline copy, or when you wrap them in other views (button("OK")).

The Text View

Text is a configurable view exported by waterui::component::text. Create instances via the text function, the text! macro, or constructors such as Text::display.

Reactive Text with text!

use waterui::prelude::*;
use waterui::reactive::binding;

pub fn welcome_banner() -> impl View {
    let name = binding("Alice");
    let unread = 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:

use waterui::prelude::*;
use waterui::reactive::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

use waterui::prelude::*;
use waterui::reactive::binding;

pub fn stats() -> impl View {
    let active_users = binding(42_857);
    let uptime = binding(99.982);

    vstack((
        Text::display(active_users),
        Text::format(uptime, waterui_text::locale::Percent::default()),
    ))
}

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:

use nami::SignalExt;

let maybe_location = binding::<Option<String>>(None);
let fallback = maybe_location.unwrap_or_else(|| "Unknown location".to_string());
text(fallback);

unwrap_or_else yields a new Binding<String> that always contains a value, ensuring the view stays reactive.

Best Practices

  • Avoid .get() inside views – Convert to signals with .map, .zip, or binding::<T> + turbofish when the compiler needs help inferring types.
  • Keep expensive formatting out of the view – Precompute large strings in a Computed binding so the closure remains trivial.
  • Prefer text! for dynamic content – It keeps formatting expressive and reduces boilerplate.
  • Use labels for performance-critical lists – Large table rows with static copy render faster as bare strings.

Troubleshooting

  • Text truncates unexpectedly – Wrap it in Frame::new(text!(…)).alignment(Alignment::Leading) or place inside an hstack with spacer() to control overflow.
  • Styling missing on one platform – Confirm the backend exposes the property; some early-stage renderers intentionally ignore unsupported font metrics.
  • Emoji or wide glyph clipping – Ensure the containing layout provides enough height; padding or a frame often resolves baseline differences between fonts.

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

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 chapter 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.

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.

use waterui::prelude::*;
use waterui::reactive::binding;

fn counter_button() -> impl View {
    let count = binding(0);

    vstack((
        text!("Count: {count}"),
        button("Increment").action_with(&count, |binding| binding.increment(1)),
    ))
}

.action_with(&binding, handler) clones the binding for you (bindings are cheap handles). Inside the handler you can call any of the convenience methods exposed by nami (.increment, .toggle, .push, .update, …).

Passing Data into Handlers

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

use waterui::prelude::*;
use waterui::core::extract::{Use, UseEnv};

#[derive(Clone)]
struct Analytics;

fn delete_button(item_id: Binding<Option<u64>>) -> impl View {
    button("Delete")
        .action_with(&item_id, |id, (Use(analytics), UseEnv(env)): (Use<Analytics>, UseEnv<Environment>)| {
            if let Some(id) = id.get() {
                analytics.track_delete(id);
                env.log("Item deleted");
            }
        })
}

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

Custom Labels and Composition

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

use waterui::prelude::*;
use waterui::component::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.

use waterui::widget::condition::when;

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:

button("Pay")
    .action_with(&payment_state, |state| {
        if state.is_processing() {
            return;
        }
        state.begin_processing();
    });

Asynchronous Workflows

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

use waterui::prelude::*;
use waterui::task::task;

fn refresh_button() -> impl View {
    button("Refresh").action(|| {
        task(async {
            let data = fetch_from_api().await;
            update_store(data);
        });
    })
}

task spawns onto the executor configured for your app (see the task chapter). Keep the handler lightweight—schedule work and return.

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.

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 #[derive(FormBuilder)] macro:

#![allow(unused)]
fn main() {
use waterui_form::{FormBuilder, form};
use waterui::reactive::Binding;

#[derive(Default, Clone, Debug, FormBuilder)]
pub struct LoginForm {
    /// The user's username
    pub username: String,
    /// The user's password  
    pub password: String,
    /// Whether to remember the user
    pub remember_me: bool,
    /// The user's age
    pub age: i32,
}

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

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

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

Type-to-Component Mapping

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

Rust TypeForm ComponentDescription
String, &strTextFieldSingle-line text input
boolToggleOn/off switch
i32, i64, etc.StepperNumeric input with +/- buttons
f64SliderSlider with 0.0-1.0 range
ColorColorPickerColor selection widget

Complete Example: User Registration Form

Let's build a more comprehensive form:

#![allow(unused)]
fn main() {
use waterui_form::{FormBuilder, form};
use waterui::reactive::Binding;
use waterui::Color;
use waterui::component::layout::stack::vstack;
use waterui_text::text;

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

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

    // Create a computed signal for the validation message
    let validation_message = form_binding.map(|data| {
        if data.full_name.len() < 2 {
            "Name too short"
        } else if data.age < 18 {
            "Must be 18 or older"
        } else if !data.email.contains('@') {
            "Invalid email"
        } else {
            "Form is valid ✓"
        }
    });
    
    vstack((
        "User Registration",
        form(&form_binding),
        // Real-time validation feedback
        text(validation_message),
    ))
}
}

Individual Form Controls

You can also use form controls individually:

Text Fields

#![allow(unused)]
fn main() {
use waterui_form::{TextField, field};
use waterui::reactive::binding;

fn text_field_example() -> impl View {
    let name = binding("".to_string());
    field("Name:", &name)
}
}

Toggle Switches

#![allow(unused)]
fn main() {
use waterui_form::{Toggle, toggle};
use waterui::reactive::binding;

fn toggle_example() -> impl View {
    let enabled = binding(false);
    toggle("Enable notifications", &enabled)
}
}

Number Steppers

#![allow(unused)]
fn main() {
use waterui_form::{Stepper, stepper};
use waterui::reactive::binding;

fn stepper_example() -> impl View {
    let count = binding(0);
    stepper(&count)
}
}

Sliders

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

fn slider_example() -> impl View {
    let volume = binding(0.5);
    Slider::new(0.0..=1.0, &volume)
}
}

Advanced Form Patterns

Multi-Step Forms

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

#[derive(Default, Clone, FormBuilder)]
struct PersonalInfo {
    first_name: String,
    last_name: String,
    birth_year: i32,
}

#[derive(Default, Clone, FormBuilder)]
struct ContactInfo {
    email: String,
    phone: String,
    preferred_contact: bool, // true = email, false = phone
}

#[derive(Default, Clone)]
struct RegistrationWizard {
    personal: PersonalInfo,
    contact: ContactInfo,
    current_step: usize,
}

fn registration_wizard() -> impl View {
    let wizard = binding(RegistrationWizard::default());
    
    let step_display = Dynamic::new(wizard.current_step.map(|step| {
        match step {
            0 => vstack((
                "Personal Information",
                form(wizard.map_project(|w| &w.personal)),
            )).any(),
            1 => vstack((
                "Contact Information", 
                form(wizard.map_project(|w| &w.contact)),
            )).any(),
            _ => text("Registration Complete!").any(),
        }
    }));

    vstack((
        text(s!("Step {} of 2", wizard.current_step.map(|s| s + 1))),
        step_display,
        navigation_buttons(wizard),
    ))
}
}

Custom Form Layouts

For complete control over form layout, implement FormBuilder manually:

#![allow(unused)]
fn main() {
use waterui_form::{FormBuilder, TextField, Toggle};
use waterui::{
    core::Binding,
    component::layout::stack::{vstack, hstack},
};
use waterui::reactive::binding;

struct CustomForm {
    title: String,
    active: bool,
}

impl FormBuilder for CustomForm {
    type View = VStack;

    fn view(binding: &Binding<Self>) -> Self::View {
        vstack((
            hstack((
                "Title:",
                TextField::new(&binding.title),
            )),
            hstack((
                "Active:",
                Toggle::new(&binding.active),
            )),
        ))
    }
}
}

Secure Fields

For sensitive data like passwords:

#![allow(unused)]
fn main() {
use waterui_form::{SecureField, secure};
use waterui::reactive::binding;

fn password_form() -> impl View {
    let password = binding(String::new());
    let confirm_password = binding(String::new());
    
    vstack((
        secure("Password:", &password),
        secure("Confirm Password:", &confirm_password),
        password_validation(&password, &confirm_password),
    ))
}

fn password_validation(pwd: &Binding<String>, confirm: &Binding<String>) -> impl View {
    text(s!("{}", pwd.zip(confirm).map(|(p, c)| {
        if p == c && !p.is_empty() {
            "Passwords match ✓"
        } else {
            "Passwords do not match"
        }
    })))
}
}

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::reactive::binding;

#[derive(Default, Clone, FormBuilder)]
struct ValidatedForm {
    email: String,
    password: String,
    age: i32,
}

struct Validation {
    is_valid_email: Computed<bool>,
    is_valid_password: Computed<bool>,
    is_valid_age: Computed<bool>,
    is_form_valid: Computed<bool>,
}

impl Validation {
    fn new(form: &Binding<ValidatedForm>) -> Self {
        let is_valid_email = form.map(|f| f.email.contains('@') && f.email.contains('.'));
        let is_valid_password = form.map(|f| f.password.len() >= 8);
        let is_valid_age = form.map(|f| f.age >= 18);
        let is_form_valid = is_valid_email.zip(is_valid_password).zip(is_valid_age).map(|((email, pass), age)| email && pass && age);

        Self {
            is_valid_email,
            is_valid_password,
            is_valid_age,
            is_form_valid,
        }
    }
}

fn validated_form_view() -> impl View {
    let form = binding(ValidatedForm::default());
    let validation = Validation::new(&form);
    
    vstack((
        form(form),
        
        // Validation messages
        text(validation.is_valid_email.map(|is_valid| if is_valid { "✓ Valid email" } else { "✗ Please enter a valid email" })),
        text(validation.is_valid_password.map(|is_valid| if is_valid { "✓ Password is strong enough" } else { "✗ Password must be at least 8 characters" })),
        text(validation.is_valid_age.map(|is_valid| if is_valid { "✓ Age requirement met" } else { "✗ Must be 18 or older" })),
        
        // Submit button - only enabled when form is valid
        when(validation.is_form_valid.clone(), || {
            button("Submit").action(|| {
                println!("Form submitted!");
            })
        })
        .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 nami::s;
use waterui::widget::condition::when;

#[derive(Default, Clone, FormBuilder)]
struct UserSettings {
    name: String,
    theme: String,
    notifications: bool,
}

fn settings_panel() -> impl View {
    let settings = UserSettings::binding();
    
    // Computed values based on form state
    let has_changes = settings.map(|s| {
        s.name != "Default Name" ||
        s.theme != "Light" ||
        s.notifications
    });
    
    let settings_summary = s!("User: {} | Theme: {} | Notifications: {}", 
        settings.map_project(|s| &s.name),
        settings.map_project(|s| &s.theme),
        settings.map_project(|s| &s.notifications).map(|n| if n { "On" } else { "Off" })
    );
    
    vstack((
        "Settings",
        form(&settings),
        
        // Live preview
        "Preview:",
        text(settings_summary),
        
        // Save button
        when(has_changes.clone(), || {
            button("Save Changes").action_with(&settings, |s| {
                save_settings(s);
            })
        })
        .or(|| text("No changes to save.")),
    ))
}

fn save_settings(settings: &UserSettings) {
    println!("Saving settings: {settings:?}");
    // Save to database, file, etc.
}
}

Navigation

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

use waterui::prelude::*;
use waterui::components::media::Photo;

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

Key features:

  • Photo::new accepts anything convertible into waterui_media::Url (web URLs, file://, etc.).
  • .placeholder(view) renders while the backend fetches the asset.
  • .on_failure(view) handles network errors gracefully.
  • You can compose standard modifiers (.padding(), .frame(...), .background(...)) around the Photo like any other view.

Video Playback

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

use waterui::prelude::*;
use waterui::components::media::{Video, VideoPlayer};
use waterui::reactive::binding;

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

    vstack((
        VideoPlayer::new(video.clone()).muted(&muted),
        button("Toggle Mute").action_with(&muted, |state| state.toggle()),
    ))
}

Muting Model

  • VideoPlayer::muted(&Binding<bool>) maps a boolean binding onto the player’s internal volume.
  • VideoPlayer stores the pre-mute volume so toggling restores the last audible level.

Styling Considerations

The video chrome (play/pause controls) depends on the backend. SwiftUI renders native controls, whereas Web/Gtk4 use their respective toolkit widgets. Keep platform conventions in mind when layering overlays or gestures on top.

Live Photos

Apple’s Live Photos combine a still image and a short video clip. WaterUI packages the pair inside LivePhotoSource:

use waterui::prelude::*;
use waterui::components::media::{LivePhoto, LivePhotoSource};

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

    LivePhoto::new(source)
}

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

The Media Enum

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

use waterui::prelude::*;
use waterui::components::media::Media;
use waterui::reactive::binding;

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

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

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

Media Picker (Feature Flag: media-picker)

Enable the crate feature in Cargo.toml:

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

Then present the picker:

use waterui::prelude::*;
use waterui::components::media::picker::{MediaFilter, MediaPicker, Selected};
use waterui::reactive::binding;

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

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

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

use waterui::components::media::Media;
use waterui::reactive::binding;
use waterui::task::task;

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

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

Best Practices

  • Defer heavy processing – Image decoding and video playback happen in the backend. Avoid blocking the UI thread; let the renderer stream data.
  • Provide fallbacks – Always set .placeholder so the UI communicates status during network hiccups (future versions of the component will expose explicit failure hooks).
  • Reuse sources – Clone Video/LivePhotoSource handles instead of recreating them in every recomposition.
  • Respect platform capabilities – Some backends may not implement Live Photos or media pickers yet. Feature-gate your UI or supply alternate paths.

With these components you can build media-heavy experiences—galleries, video players, immersive feeds—while keeping the code declarative and reactive.

[WIP]

Suspense and Asynchronous Loading

Modern applications often need to load data asynchronously from APIs, databases, or other sources. The Suspense component in WaterUI provides an elegant way to handle async content loading while maintaining a responsive user interface. This chapter covers everything you need to know about implementing suspense in your applications.

Basic Suspense Usage

The simplest way to use Suspense is with async functions that return views:

#![allow(unused)]
fn main() {
use waterui::{View};
use waterui_text::text;
use waterui::component::layout::stack::{vstack};
use waterui::widget::suspense::Suspense;

// Async function that loads data
async fn load_user_profile(user_id: u32) -> impl View {
    // Simulate API call
    tokio::time::sleep(Duration::from_secs(2)).await;
    
    let user_data = fetch_user_data(user_id).await;
    
    vstack((
        text!("Name: {}", user_data.name),
        text!("Email: {}", user_data.email),
        text!("Joined: {}", user_data.joined_date),
    ))
}

fn user_profile_view(user_id: u32) -> impl View {
    // Basic suspense with custom loading view
    Suspense::new(load_user_profile(user_id))
        .loading(text!("Loading user profile..."))
}
}

Using Default Loading Views

You can set up default loading views in your application's environment:

#![allow(unused)]
fn main() {
use waterui::{Environment};
use waterui::view::AnyViewBuilder;
use waterui::widget::suspense::{Suspense, DefaultLoadingView};
use waterui_text::text;
use waterui::component::layout::stack::vstack;
use waterui::component::layout::{Edge, Frame};

// Set up default loading view in your app
fn setup_app_environment() -> Environment {
    let loading_view = AnyViewBuilder::new(|_| {
        vstack((
            text!("Loading..."),
        ))
        .frame(Frame::new().margin(Edge::round(20.0)))
    });

    Environment::new().with(DefaultLoadingView(loading_view))
}

// Components can now use the default loading view
fn simple_async_view() -> impl View {
    Suspense::new(load_data) // Uses default loading view from environment
}
}

The SuspendedView Trait

Any type can be used with Suspense by implementing the SuspendedView trait. The trait is automatically implemented for any Future that resolves to a View.

#![allow(unused)]
fn main() {
pub trait SuspendedView: 'static {
    fn body(self, _env: Environment) -> impl Future<Output = impl View>;
}
}

Automatic Implementation for Futures

#![allow(unused)]
fn main() {
use waterui::widget::suspense::{Suspense, SuspendedView};
use waterui_text::text;

// These all work with Suspense automatically:

// 1. Async functions
async fn fetch_weather() -> impl View {
    let weather = get_weather_data().await;
    text!("Temperature: {}°F", weather.temperature)
}

// 2. Async closures
let load_news = async move || {
    let articles = fetch_news_articles().await;
    news_list_view(articles)
};

// 3. Future types
use std::future::Future;
use std::pin::Pin;

type BoxedFuture = Pin<Box<dyn Future<Output = impl View>>>;

fn get_async_content() -> BoxedFuture {
    Box::pin(async {
        text!("Async content loaded!")
    })
}

// All work with Suspense:
let weather_view = Suspense::new(fetch_weather);
let news_view = Suspense::new(load_news);
let content_view = Suspense::new(get_async_content());
}

Custom SuspendedView Implementation

For more complex scenarios, you can implement SuspendedView manually:

#![allow(unused)]
fn main() {
use waterui::{Environment, View};
use waterui::widget::suspense::SuspendedView;
use waterui_text::text;
use waterui::component::layout::stack::vstack;

struct DataLoader {
    user_id: u32,
    include_posts: bool,
}

impl SuspendedView for DataLoader {
    async fn body(self, _env: Environment) -> impl View {
        // Custom loading logic with environment access
        let user = fetch_user(self.user_id).await;
        
        if self.include_posts {
            let posts = fetch_user_posts(self.user_id).await;
            vstack((
                user_profile_view(user),
                posts_list_view(posts),
            ))
        } else {
            user_profile_view(user)
        }
    }
}

// Usage
fn user_dashboard(user_id: u32, show_posts: bool) -> impl View {
    Suspense::new(DataLoader {
        user_id,
        include_posts: show_posts,
    })
    .loading(text!("Loading dashboard..."))
}
}

Error Handling

Error handling in WaterUI is designed to integrate seamlessly with the declarative view system, allowing you to convert standard Rust errors into renderable UI components while maintaining type safety and customization flexibility.

Core Concepts

The Error Type

The Error type is a type-erased wrapper that can hold any error implementing the standard Error trait and render it as a view:

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

// Convert any standard error to a renderable Error
let io_error = io::Error::new(io::ErrorKind::NotFound, "Config file not found");
let ui_error = Error::new(io_error);
}

Environment-Based Error Styling

WaterUI uses the environment system to configure how errors are displayed throughout your application:

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

let env = Environment::new()
    .with(DefaultErrorView::new(|error: BoxedStdError| {
        VStack((
            "⚠️ Application Error",
            format!("Details: {}", error),
            "Please contact support if this persists."
                .color(Color::SECONDARY)
        ))
    }));
}

Basic Usage Patterns

Converting Results to Views

The ResultExt trait provides convenient methods for converting Result types to views:

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

fn load_user_data(user_id: u32) -> Result<String, DatabaseError> {
    // Simulate database operation
    if user_id == 0 {
        Err(DatabaseError::InvalidId)
    } else {
        Ok(format!("User {}", user_id))
    }
}

fn user_profile_view(user_id: u32) -> impl View {
    match load_user_data(user_id)
        .error_view(|err| text!("Failed to load user: {}", err))
    {
        Ok(user_data) => text!(user_data),
        Err(error_view) => error_view.any_view(),
    }
}
}

Inline Error Handling

You can handle errors inline within view construction:

#![allow(unused)]
fn main() {
fn network_status_view() -> impl View {
    vstack((
        "Network Status",
        match check_connection() {
            Ok(status) => text!(status),
            Err(error) => Error::new(error).any_view(),
        }
    ))
}
}

Advanced Features

Type Downcasting

The error system preserves type information, allowing you to downcast to specific error types for specialized handling:

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

fn handle_file_error(error: Error) -> impl View {
    match error.downcast::<io::Error>() {
        Ok(io_error) => {
            match io_error.kind() {
                io::ErrorKind::NotFound => {
                    vstack((
                        "File Not Found",
                        "The requested file could not be located.",
                        button("Browse for File", browse_action)
                    )).any_view()
                }
                io::ErrorKind::PermissionDenied => {
                    vstack((
                        "Permission Denied",
                        "You don't have permission to access this file.",
                        button("Request Access", request_access_action)
                    )).any_view()
                }
                _ => text!("IO Error: {}", io_error).any_view()
            }
        }
        Err(original_error) => {
            // Handle as generic error
            text!("Error: {}", original_error).any_view()
        }
    }
}
}

Custom Error Views

Create errors directly from custom views for complete control over presentation:

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

fn validation_error_view(field: &str, message: &str) -> Error {
    Error::from_view(
        hstack((
            Icon::warning().foreground(Color::WARNING),
            vstack((
                text!("Validation Error: {}", field),
                text!(message).foreground(Color::SECONDARY)
            ))
        ))
        .padding(16.0)
        .background(Color::WARNING.opacity(0.1))
    )
}

// Usage in form validation
fn validate_email(email: &str) -> Result<String, Error> {
    if email.contains('@') {
        Ok(email.to_string())
    } else {
        Err(validation_error_view("Email", "Must contain @ symbol"))
    }
}
}

Error Handling Patterns

Loading States with Error Handling

Combine error handling with loading states for better user experience:

#![allow(unused)]
fn main() {
enum LoadingState<T> {
    Loading,
    Loaded(T),
    Error(Error),
}

fn data_view(state: LoadingState<UserData>) -> impl View {
    match state {
        LoadingState::Loading => {
            hstack((
                ProgressIndicator::spinning(),
                "Loading user data..."
            )).any_view()
        }
        LoadingState::Loaded(data) => {
            user_profile_component(data).any_view()
        }
        LoadingState::Error(error) => {
            error.any_view()
        }
    }
}
}

Contextual Error Information

Provide context-aware error messages based on the current view:

#![allow(unused)]
fn main() {
fn api_request_view(endpoint: &str) -> impl View {
    match make_api_request(endpoint)
        .error_view(|err| {
            vstack((
                "Network Request Failed",
                text!("Endpoint: {}", endpoint),
                text!("Error: {}", err),
                hstack((
                    button("Retry", retry_action),
                    button("Go Offline", offline_mode_action)
                ))
            ))
            .padding(20.0)
            .background(Color::ERROR.opacity(0.1))
        })
    {
        Ok(response) => response_view(response).any_view(),
        Err(error_view) => error_view.any_view(),
    }
}
}

Best Practices

1. Configure Global Error Styling

Set up a consistent error presentation style at your app's root:

#![allow(unused)]
fn main() {
fn app_root() -> impl View {
    ContentView::new()
        .with(DefaultErrorView::new(|error| {
            vstack((
                Icon::error().size(24),
                text!("{}", error),
                text!("If this problem persists, please contact support.")
                    .foreground(Color::SECONDARY)
            ))
            .padding(16.0)
        }))
        .body(main_content_view())
}
}

2. Provide Actionable Error Messages

Include relevant actions users can take to resolve errors:

#![allow(unused)]
fn main() {
fn network_error_view(error: NetworkError) -> impl View {
    vstack((
        "Connection Problem",
        text!("{}", error),
        hstack((
            button("Check Connection", check_connection_action),
            button("Work Offline", enable_offline_mode),
            button("Retry", retry_last_action)
        ))
    ))
}
}

3. Use Appropriate Error Granularity

Handle different error types at appropriate levels in your view hierarchy:

#![allow(unused)]
fn main() {
fn user_dashboard() -> impl View {
    vstack((
        // Handle critical errors at component level
        match load_user_session() {
            Ok(session) => session_header(session).any_view(),
            Err(auth_error) => login_prompt(auth_error).any_view(),
        },
        
        // Handle non-critical errors inline
        hstack((
            user_avatar().unwrap_or_else(|_| default_avatar()),
            user_stats().unwrap_or_else(|err| {
                text!("Stats unavailable").foreground(Color::SECONDARY)
            })
        ))
    ))
}
}

ErrorView

The ErrorView is an internal component that wraps views to be used as errors. It's primarily used internally by the error system but can be useful in advanced scenarios.

DefaultErrorView

DefaultErrorView is the environment-based configuration mechanism that allows you to define how errors should be rendered throughout your application. It acts as a fallback when no specific error handling is provided, ensuring consistent error presentation across your entire UI.

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

By intersting environment, plugin can achieve something interesting purpose.

Implement an i18n Plugin

#![allow(unused)]
fn main() {
[WIP]
}

[WIP]

[WIP]

[WIP]

[WIP]