| name | waterui |
| description | Build cross-platform apps with WaterUI. Use when writing views, handling state, styling UI, or debugging WaterUI Rust code. Covers reactive bindings, layout, components, and the water CLI. |
WaterUI App Development
Build views with reactive state. When unsure, search examples/*/src/lib.rs for existing patterns.
CRITICAL: Runtime And Testing Semantics
- WaterUI is fine-grained reactive with reconstruction semantics. If parent-driven control flow rebuilds a component instance, that instance's local state resetting is expected and correct.
- Do not fix rebuild-driven resets by caching hidden state across rebuilds. If state must survive, lift it into explicit reactive ownership at the right level.
- Prefer
Binding, Computed, and other signal inputs for dynamic behavior. Avoid reading .get() in view bodies when a reactive API can accept the signal directly.
- Hydrolysis widget chrome is provided by a backend-neutral
WidgetTheme. For Material Design 3 output, install hydrolysis_m3::install(&mut env) before running or previewing a Hydrolysis app.
- Hydrolysis Material 3 colors use Material You system roles for WaterUI theme tokens. Use
hydrolysis_m3::MaterialColorSource::new(source_color) to configure source color, variant, contrast level, spec version, and platform; call .schemes() to generate paired light/dark MaterialColorSchemes, then install one side with install_with_source, install_with_color_schemes, or install_with_colors.
- For Material-specific view colors, use
hydrolysis_m3::color::* role tokens such as Primary, OnPrimary, PrimaryContainer, SurfaceContainerHighest, and OnSurfaceVariant. These resolve from the installed MaterialColorScheme, while WaterUI's generic theme_color::* tokens remain mapped to the closest Material roles.
- Use
water preview --backend hydrolysis --theme material3 for Hydrolysis Material 3 visual checks. Pass --expr when previewing an inline Rust expression; the CLI writes the expression into generated Rust preview code and rustc compiles it normally with waterui::prelude::* and waterui in scope.
CRITICAL: Reactive-First Pattern
WaterUI is a reactive framework. ALWAYS pass Bindings directly to APIs instead of using .get() or watch.
Most WaterUI APIs accept impl Signal or impl IntoSignalF32 - pass bindings directly for automatic reactivity:
Photo::new(url).blur(blur_value.clone())
view.visible(is_visible.clone())
view.opacity(opacity_value.clone())
view.disabled(is_loading.clone())
text!("Count: {count}")
Photo::new(url).blur(blur_value.get())
view.visible(is_visible.get())
watch(count.clone(), |c| text(format!("{c}")))
Rule: If an API accepts a value that might change, check if it accepts impl Signal and pass the binding.
Quick Start
use waterui::prelude::*;
fn main() -> impl View {
let count = Binding::i32(0);
vstack((
text!("Count: {count}").headline(),
button("+1")
.with_state(&count)
.action(|c| c.set(c.get() + 1)),
))
}
Views
Functions and closures are views:
fn card(title: &str) -> impl View {
vstack((text(title).title(), Divider))
}
vstack((card("Hello"), card("World")))
Conditional rendering:
is_new.map(|b| b.then(|| badge("New")))
when(is_logged_in, || dashboard()).otherwise(|| login_form())
when(state.equal_to(0), || "Loading")
.or(state.equal_to(1), || "Ready")
.otherwise(|| "Error")
State
let toggle = Binding::bool(false);
let count = Binding::i32(0);
let value = Binding::f64(1.5);
let name = Binding::container(String::new());
let text = Binding::container(Str::from("hello"));
fn section(count: &Binding<i32>) -> impl View { ... }
Reactive Transforms
Methods on signals (no .clone() needed for transforms):
count.not()
count.select(a, b)
count.equal_to(5)
count.gt(0)
count.is_empty()
count.map(|v| v * 2)
count.zip(&other).map(|(a,b)| a + b)
Convert to Computed: signal.computed()
Reactive Modifiers
Pass bindings directly to modifiers for real-time updates:
let opacity = Binding::f64(1.0);
let blur = Binding::f64(0.0);
let is_visible = Binding::bool(true);
let is_disabled = Binding::bool(false);
let scale_factor = Binding::f64(1.0);
view
.opacity(opacity.clone())
.visible(is_visible.clone())
.disabled(is_disabled.clone())
.scale(scale_factor.clone(), scale_factor.clone())
Photo::new(url)
.blur(blur.clone())
.saturation(saturation.clone())
.brightness(brightness.clone())
Event Handlers
IMPORTANT: Always use .with_state() - never clone bindings manually!
button("Click")
.with_state(&count)
.action(|c| c.set(c.get() + 1))
button("Reset")
.with_state(&x)
.with_state(&y)
.action(|(x, y)| { x.set(0); y.set(0); })
button("Submit")
.with_state(&url)
.with_state(&blur)
.with_state(&status)
.with_state(&handler)
.action(|(((url, blur), status), handler)| {
})
button("Load").action_async(|_| async { fetch().await })
view.on_appear(|| setup())
view.on_change(&signal, |new_val| handle(new_val))
Text
IMPORTANT: Use text() for static text and text! for reactive text. Never use watch() just to build text. Also never write waterui::text!; import the macro and use text! directly.
text("Hello").title()
text!("Count: {count}")
text!("{a} + {b} = {sum}")
text!("Value: {value:.2}")
text!("{FOCUSED_READOUT}")
text!("Status: {status}").sub_headline()
text!("Small: {value}").caption()
Layout
hstack((a, b, c)).spacing(8.0)
vstack((a, b)).padding()
zstack((background, content))
scroll(content)
spacer()
spacer().height(16.0)
let buttons: HStack<_> = items.iter().map(|i| button(i.label)).collect();
Colors
Blue, Green, Red, Orange, Purple, Cyan, Yellow, Pink, Grey
const BRAND: Srgb = Srgb::from_hex("#3B82F6");
view.background(Blue)
view.foreground(BRAND)
Blue.size(80.0, 80.0)
BRAND.with_opacity(0.5)
Theme colors: Foreground, MutedForeground, Accent, Background, Surface, Border
Icons
Icons come from packaged icon-set crates — pick one set per app and depend on it:
waterui-icons-material-icon (Material Symbols), waterui-icons-lucide,
waterui-icons-fontawesome7, waterui-icons-sf-symbol (Apple only).
use waterui_icons_material_icon as mdi;
mdi::check_circle()
mdi::delete().size(20.0, 20.0)
mdi::flag().foreground(Accent)
mdi::calendar_today().tint(Srgb::from_hex("#4A84F6"))
Match the icon set to the design language: a Material 3 (hydrolysis_m3) app uses
Material icons, not Lucide. SystemIcon/SF Symbols are Apple-only — for
portable code depend on a cross-platform set instead.
Modifiers
.padding() / .padding_with(EdgeInsets::all(16.0))
.background(color) / .foreground(color)
.size(w, h) / .width(w) / .height(h)
.scale(x, y) / .rotation(degrees) / .offset(x, y)
.border(color, width) / .shadow() / .clip(shape)
.disabled(bool_signal) / .visible(bool_signal)
.opacity(f64_signal)
Components
| Category | Components |
|---|
| Layout | hstack, vstack, zstack, scroll, spacer, grid |
| Controls | button, toggle, Slider, Stepper, TextField, Menu, Picker |
| Navigation | NavigationStack, NavigationLink, NavigationSplitView, TabView |
| Collections | List, ForEach (see below) |
| Overlays | Snackbar / SnackbarManager, FullScreenOverlayManager |
| Media | Photo, VideoPlayer, MediaPicker |
| Data | Chart, Map, form (#[derive(FormBuilder)]) |
| Platform | WebView |
| Graphics | Canvas, Barcode::qr(), Icon sets (see Icons) |
Collections (dynamic lists)
For a changing set of views, use ForEach/List — NOT watch. The collection
diffs by Identifiable id, so adding/removing one item updates precisely instead of
rebuilding the whole subtree.
#[derive(Clone)]
struct Row { id: u64, title: Str }
impl Identifiable for Row { type Id = u64; fn id(&self) -> u64 { self.id } }
List::for_each(&rows, |row| ListItem::new(text(row.title)))
let rows = nami::collection::List::<Row>::new();
rows.push(Row { id: 1, title: "Hello".into() });
ForEach::new(rows.clone(), |row| text(row.title))
A fixed, known set is just a tuple stack (vstack((a, b, c))) or .collect() from a
slice — no collection type needed.
Snackbars (transient bottom/top messages)
Every Window auto-installs a SnackbarManager; reach it from a handler via
State<SnackbarManager> and call .show(...):
button("Save").action(|State(m): State<SnackbarManager>| {
m.show(Snackbar::new("File saved"));
});
m.show(
Snackbar::new("Item moved to trash")
.icon(mdi::delete())
.action("Undo", || { })
.position(SnackbarPosition::TopCenter)
.closeable()
.duration(Duration::from_secs(5)),
);
Different placements are independent stacks (a top snackbar never evicts a bottom
one). Multiple at the same placement stack and reflow automatically.
CLI Commands
water create my-app
water run --platform ios
water run --platform android
water run --platform macos
water preview my_view
water run --logs debug
Preview System
Use the #[preview] macro to enable instant view previews:
#[preview]
fn my_card() -> impl View {
text!("Hello Preview!")
}
Run previews with water preview my_card --platform macos.
Common Patterns
let blur = Binding::f64(0.0);
vstack((
Photo::new(url).blur(blur.clone()),
Slider::new(0.0..=10.0, &blur),
text!("Blur: {blur:.1}"),
))
let scale = active.select(1.2 as f32, 1.0).with(Animation::spring(300.0, 15.0));
.visible(items.map(|i| !i.is_empty()).computed())
List::for_each(&items, |item| item_view(item))
fn tab_buttons(tabs: &[Tab], selected: &Binding<Tab>) -> HStack<(Vec<AnyView>,)> {
tabs.iter()
.map(|&tab| button(tab.label()).with_state(selected).action(move |s| s.set(tab)))
.collect()
}
when(is_dark, || dark_theme()).otherwise(|| light_theme())
when(!is_loading, || content()).otherwise(|| spinner())
when(state.equal_to(0), || loading_view())
.or(state.equal_to(1), || ready_view())
.or(state.equal_to(2), || error_view())
.otherwise(|| unknown_view())
fn render(mode: Mode) -> AnyView {
match mode {
Mode::A => view_a().anyview(),
Mode::B => view_b().anyview(),
Mode::C => view_c().anyview(),
}
}
#[derive(FormBuilder)]
struct Settings { name: String, volume: f64 }
form(&settings_binding)
let url_input = Binding::container(initial_photo_url);
let blur = Binding::f64(0.0);
let status = Binding::container(String::from("Loading..."));
let (handler, photo_view) = Dynamic::new();
button("Load")
.with_state(&url_input)
.with_state(&blur)
.with_state(&status)
.with_state(&handler)
.action(|(((url, blur), status), handler)| {
let photo = Photo::new(url.get())
.on_event({
let status = status.clone();
move |event| match event {
PhotoEvent::Loaded => status.set(String::from("Loaded")),
PhotoEvent::Error(msg) => status.set(format!("Error: {msg}")),
}
})
.blur(blur.clone());
handler.set(photo);
});
vstack((
text!("{status}"),
photo_view,
Slider::new(0.0..=10.0, &blur),
))
Extension Traits
WaterUI uses *Ext traits. When unsure, search trait.*Ext in codebase.
SignalExt (from nami, works on Binding/Computed):
.map(|v| ...), .zip(&other), .computed(), .cached(), .distinct(), .with(metadata)
.not(), .select(if_true, if_false), .then_some(value)
.equal_to(v), .gt(v), .lt(v), .ge(v), .le(v), .condition(|v| ...)
.is_some(), .is_none(), .unwrap_or(default), .map_some(|v| ...)
.is_empty(), .contains("pattern")
ViewExt: .anyview(), .visible(), .padding(), .background(), etc.
AnimationExt: .animated(), .with(Animation::spring(...))
Gotchas
No Binding::new() - use type-specific constructors:
let count = Binding::new(0);
let count = Binding::i32(0);
let value = Binding::f64(1.5);
let flag = Binding::bool(false);
let name = Binding::container(String::new());
No _f32 suffix - use as f32 cast:
.select(1.0_f32, 0.3)
.select(1.0 as f32, 0.3)
No .get() for reactive values - pass binding directly:
Photo::new(url).blur(blur.get())
view.opacity(opacity.get())
Photo::new(url).blur(blur.clone())
view.opacity(opacity.clone())
Don't reach for watch() — it rebuilds and replaces the whole subtree on every
change (losing that subtree's internal state), so it's almost never what you want.
Three things replace nearly every use:
text!("{status}")
Photo::new(url).blur(blur.clone())
ForEach::new(rows.clone(), |row| row_view(row))
Only reach for watch for a genuinely one-off structural swap with no signal-aware
API and no collection — check those three first.
No manual .clone() for button states - use .with_state():
let count_clone = count.clone();
button("Click").action(move || count_clone.set(...))
button("Click").with_state(&count).action(|c| c.set(...))
Two-param transforms:
.scale(x, y)
.offset(x, y)
.size(w, h)
text! returns LocalizedText - supports all font methods:
text!("{status}").sub_headline()
text!("{value}").caption()
text!("{note}").footnote()
Embedded (dew backend, experimental)
WaterUI runs on microcontrollers through the waterui-dew backend
(CPU rasterization, no GPU, dirty-region flushes sized for SPI panels).
The same views, bindings, and text! reactivity work unchanged.
Develop against the desktop panel simulator — the full embedded rendering
flow in a native window, no cross-compilation:
cargo run -p waterui-dew --example watch_sim --features embedded-simulator
Headless snapshot: waterui_dew::render_view_png(builder, env, w, h).
Currently supported views: stacks/padding, colors, spacers, text.
Unsupported views panic fast with a clear message instead of rendering
wrong. ESP32-S3 firmware lives in examples/embedded/dew-esp32s3/
(see its README for QEMU and real-hardware flows and the current Xtensa
toolchain blocker).