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

GUIdo GUIdo

Guido

A reactive Rust GUI library for Wayland layer shell widgets

Guido is a GPU-accelerated GUI library built with Rust and wgpu, designed specifically for creating Wayland layer shell applications like status bars, panels, overlays, and desktop widgets.

Key Features

  • Reactive System - Fine-grained reactivity inspired by SolidJS with signals, computed values, and effects
  • GPU Rendering - Hardware-accelerated rendering using wgpu with SDF-based shapes for crisp anti-aliasing
  • Wayland Layer Shell - Native support for layer shell positioning, anchoring, and exclusive zones
  • Multi-Surface Apps - Create multiple surfaces (windows) that share reactive state
  • State Layer API - Declarative hover/pressed states with automatic animations and ripple effects
  • Transform System - Translate, rotate, and scale widgets with spring physics animations
  • Composable Widgets - Build complex UIs from simple, composable primitives
  • Image Widget - Display PNG, JPEG, WebP, and SVG images with GPU texture caching
  • Scrollable Containers - Vertical and horizontal scrolling with customizable scrollbars

Quick Example

extern crate guido;
use guido::prelude::*;

fn main() {
    App::new().run(|app| {
        let count = create_signal(0);

        app.add_surface(
            SurfaceConfig::new()
                .width(300)
                .height(100)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            move || {
                container()
                    .height(fill())
                    .layout(Flex::row().center())
                    .child(
                        container()
                            .padding(16.0)
                            .background(Color::rgb(0.2, 0.2, 0.3))
                            .corners(8.0)
                            .when_hovered(|s| s.lighter(0.1))
                            .when_pressed(|s| s.ripple())
                            .on_click(move || count.update(|c| *c += 1))
                            .child(text(move || format!("Clicked {} times", count.get())))
                    )
            },
        );
    });
}

What Can You Build?

Guido is ideal for:

  • Status bars - System information displays anchored to screen edges
  • Panels - Application launchers and taskbars
  • Notifications - Overlay popups and alerts
  • Desktop widgets - Clocks, system monitors, media controls
  • Layer shell utilities - Any Wayland layer shell application

Platform Support

Currently, Guido supports Wayland on Linux with layer shell protocol. The library uses:

  • wgpu for GPU-accelerated rendering
  • smithay-client-toolkit for Wayland protocol handling
  • calloop for event loop integration
  • glyphon for text rendering

Getting Started

Ready to build your first Guido application? Head to the Installation guide to set up your development environment.

Getting Started

This section will guide you through setting up Guido and creating your first application.

What You’ll Learn

  • Installation - Add Guido to your project and set up dependencies
  • Hello World - Build your first Guido application step by step
  • Running Examples - Explore the included examples to learn different features

Prerequisites

Before you begin, ensure you have:

  • Rust (1.70 or later) - Install via rustup
  • Wayland compositor - A compositor that supports the layer shell protocol (Sway, Hyprland, etc.)
  • System dependencies - Development libraries for Wayland and graphics

Quick Start

If you’re eager to get started, here’s the fastest path:

# Create a new project
cargo new my-guido-app
cd my-guido-app

# Add Guido dependency
cargo add guido

# Run the app
cargo run

Then replace src/main.rs with a simple Guido application. See the Hello World guide for the complete code.

Installation

This guide walks you through setting up Guido for development.

System Requirements

Wayland Compositor

Guido requires a Wayland compositor with layer shell protocol support. Compatible compositors include:

  • Sway - i3-compatible Wayland compositor
  • Hyprland - Dynamic tiling Wayland compositor
  • river - Dynamic tiling Wayland compositor
  • wayfire - 3D Wayland compositor

Note: X11 is not supported. Guido is designed specifically for Wayland layer shell applications.

System Dependencies

Install the required development libraries for your distribution:

Arch Linux:

sudo pacman -S wayland wayland-protocols libxkbcommon

Debian/Ubuntu:

sudo apt install libwayland-dev libxkbcommon-dev

Fedora:

sudo dnf install wayland-devel libxkbcommon-devel

Adding Guido to Your Project

New Project

Create a new Rust project and add Guido:

cargo new my-app
cd my-app
cargo add guido

Existing Project

Add Guido to your Cargo.toml:

[dependencies]
guido = "0.1"

Or use cargo:

cargo add guido

Verifying Installation

Create a minimal test application to verify everything works:

extern crate guido;
// src/main.rs
use guido::prelude::*;

fn main() {
    App::new().run(|app| {
        app.add_surface(
            SurfaceConfig::new()
                .width(200)
                .height(100)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            || {
                container()
                    .padding(20.0)
                    .background(Color::rgb(0.2, 0.3, 0.4))
                    .child(container().child(text("Hello, Guido!").color(Color::WHITE)))
            },
        );
    });
}

Run the application:

cargo run

If you see a small window with “Hello, Guido!” text, the installation is successful.

Troubleshooting

“No Wayland display” Error

Ensure you’re running in a Wayland session, not X11:

echo $XDG_SESSION_TYPE  # Should output "wayland"

Compositor Doesn’t Support Layer Shell

Some Wayland compositors (like GNOME’s Mutter) don’t support the layer shell protocol. Use a compatible compositor listed above.

Missing Libraries

If you get linker errors about missing libraries, ensure the system dependencies are installed. The error messages usually indicate which library is missing.

Next Steps

Now that Guido is installed, continue to the Hello World tutorial to build your first application.

Hello World

Let’s build a simple status bar to understand the basics of Guido. By the end of this tutorial, you’ll have a working application like this:

Creating the Project

Start with a new Rust project:

cargo new hello-guido
cd hello-guido
cargo add guido

The Complete Code

Replace src/main.rs with:

extern crate guido;
use guido::prelude::*;

fn main() {
    App::new().run(|app| {
        app.add_surface(
            SurfaceConfig::new()
                .height(32)
                .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            || {
                container()
                    .height(fill())
                    .layout(
                        Flex::row()
                            .spacing(8.0)
                            .main_alignment(MainAlignment::SpaceBetween)
                            .cross_alignment(CrossAlignment::Center),
                    )
                    .child(
                        container()
                            .padding(8.0)
                            .background(Color::rgb(0.2, 0.2, 0.3))
                            .corners(4.0)
                            .child(text("Guido")),
                    )
                    .child(container().padding(8.0).child(text("Hello World!")))
                    .child(
                        container()
                            .padding(8.0)
                            .background(Color::rgb(0.3, 0.2, 0.2))
                            .corners(4.0)
                            .child(text("Status Bar")),
                    )
            },
        );
    });
}

Run it with cargo run.

Understanding the Code

Let’s break down each part:

The Prelude

extern crate guido;
fn main() {
use guido::prelude::*;
;
}

The prelude imports everything you need: widgets, colors, layout types, and reactive primitives.

Surface Configuration

extern crate guido;
use guido::prelude::*;
fn main() {
App::new().run(|app| {
    let _surface_id = app.add_surface(
        SurfaceConfig::new()
            .height(32)
            .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
            .background_color(Color::rgb(0.1, 0.1, 0.15)),
        || container(),
    );
});
;
}

The SurfaceConfig defines how the window appears:

  • Height - 32 pixels tall
  • Anchor - Attached to top, left, and right edges (full width)
  • Background color - Dark background for the bar

Note: run() takes a setup closure where you add surfaces. add_surface() returns a SurfaceId that can be used to get a SurfaceHandle for modifying surface properties dynamically, and the surface’s own widgets can read that handle with expect_context::<SurfaceHandle>().

Building the View

The view is built using containers - the primary building block in Guido:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .height(fill())
    .layout(
        Flex::row()
            .spacing(8.0)
            .main_alignment(MainAlignment::SpaceBetween)
            .cross_alignment(CrossAlignment::Center),
    )
;
}

This creates a container that:

  • Fills the available height with fill()
  • Uses a horizontal flex layout
  • Centers children vertically with cross_alignment
  • Spreads children across the space with SpaceBetween

Adding Children

Each section of the status bar is a child container:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.child(
    container()
        .padding(8.0)
        .background(Color::rgb(0.2, 0.2, 0.3))
        .corners(4.0)
        .child(text("Guido")),
)
;
}

The container has:

  • Padding - 8 pixels of space around the content
  • Background - A dark purple-gray color
  • Corner radius - Rounded corners
  • Child - A text widget

Text Widgets

extern crate guido;
use guido::prelude::*;
fn main() {
text("Hello World!")
;
}

The text() function creates a text widget. It carries the content and nothing else: colour, size, family and weight are declared on an enclosing container and inherited from the nearest one that sets them.

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Hello World!").font_size(24.0).color(Color::WHITE))
;
}

With nothing declared above it, text is white at 14 logical pixels.

Adding Interactivity

Let’s make it interactive with a click counter. Update your code:

extern crate guido;
use guido::prelude::*;

fn main() {
    App::new().run(|app| {
        let count = create_signal(0);

        app.add_surface(
            SurfaceConfig::new()
                .height(32)
                .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            move || {
                container()
                    .height(fill())
                    .layout(
                        Flex::row()
                            .spacing(8.0)
                            .main_alignment(MainAlignment::SpaceBetween)
                            .cross_alignment(CrossAlignment::Center),
                    )
                    .child(
                        container()
                            .padding(8.0)
                            .background(Color::rgb(0.2, 0.2, 0.3))
                            .corners(4.0)
                            .when_hovered(|s| s.lighter(0.1))
                            .when_pressed(|s| s.ripple())
                            .on_click(move || count.update(|c| *c += 1))
                            .child(text(move || format!("Clicks: {}", count.get()))),
                    )
                    .child(container().padding(8.0).child(text("Hello World!")))
                    .child(
                        container()
                            .padding(8.0)
                            .background(Color::rgb(0.3, 0.2, 0.2))
                            .corners(4.0)
                            .child(text("Status Bar")),
                    )
            },
        );
    });
}

What Changed?

  1. Signal - create_signal(0) creates a reactive value
  2. Hover state - .when_hovered(|s| s.lighter(0.1)) lightens on hover
  3. Pressed state - .when_pressed(|s| s.ripple()) adds a ripple effect
  4. Click handler - .on_click(...) increments the counter
  5. Reactive text - text(move || format!(...)) updates when the signal changes

Next Steps

You’ve built your first Guido application. Continue learning:

Running Examples

Guido includes several examples that demonstrate different features. Clone the repository and run them to see the library in action.

Getting the Examples

git clone https://github.com/zibo/guido.git
cd guido

Running an Example

cargo run --example <example_name>

Available Examples

status_bar

A simple status bar demonstrating basic layout and containers.

cargo run --example status_bar

Features demonstrated:

  • Horizontal flex layout with SpaceBetween alignment
  • Container backgrounds and corner radius
  • Text widgets

reactive_example

Interactive example showing the reactive system.

cargo run --example reactive_example

Features demonstrated:

  • Signals and reactive text
  • Click handlers with on_click
  • Scroll handlers with on_scroll
  • Background thread updates
  • State layer hover effects
  • Borders and gradients

state_layer_example

Comprehensive demonstration of the state layer API.

cargo run --example state_layer_example

Features demonstrated:

  • Hover states with lighter() and explicit colors
  • Pressed states with transforms
  • Animated transitions
  • Border animations
  • Ripple effects (default, colored, with scale)
  • Transformed containers with ripples

animation_example

Animated properties with spring physics.

cargo run --example animation_example

Features demonstrated:

  • Width animation with spring physics
  • Color animation with easing
  • Combined animations
  • Border animation

transform_example

The 2D transform system.

cargo run --example transform_example

Features demonstrated:

  • Static rotation and scale
  • Click-to-rotate with animation
  • Spring-based scale animation
  • Transform origins (pivot points)
  • Nested transforms

showcase

Corner curvature variations using superellipse rendering.

cargo run --example showcase

Features demonstrated:

  • Squircle corners (iOS-style, K=2)
  • Circular corners (default, K=1)
  • Beveled corners (K=0)
  • Scooped/concave corners (K=-1)
  • Borders and gradients with different curvatures

flex_layout_test

All flex layout alignment options.

cargo run --example flex_layout_test

Features demonstrated:

  • MainAlignment: Start, Center, End, SpaceBetween, SpaceAround, SpaceEvenly
  • CrossAlignment: Start, Center, End, Stretch
  • Row and column layouts

baseline_alignment

The four cross alignments on the same row, so the difference is visible.

cargo run --example baseline_alignment

Features demonstrated:

  • CrossAlignment::Baseline: text of different sizes reading as one line
  • The pair worth comparing is End and Baseline — one aligns the boxes, the other the line the text is written on
  • A box carries no baseline, so it aligns by its bottom edge

component_example

Creating reusable components with the #[component] macro.

cargo run --example component_example

Features demonstrated:

  • #[component] macro for reusable widgets
  • Component props and signals
  • Stateful components with internal signals
  • Composition of components

children_example

Dynamic children and child management patterns.

cargo run --example children_example

Features demonstrated:

  • .child() for single children
  • .children([...]) for multiple children
  • .child(Option) for conditional rendering
  • .children(keyed(..)) for reactive lists

shadow_example

Every degree of freedom a Shadow has, and where a ladder of them lives.

cargo run --example shadow_example

Features demonstrated:

  • A ladder of shadows defined by the application, as const values
  • A shadow thrown sideways, a coloured one, and one with spread — none of which a single elevation number could ask for
  • An inset glow, a control pressed in, and a tight shadow stacked over a soft one
  • A hover that lifts a card, with the motion declared beside the value

image_example

Displaying raster and SVG images.

cargo run --example image_example

Features demonstrated:

  • PNG/WebP raster images
  • SVG vector images
  • ContentFit modes (Contain, Cover, Fill)
  • Images with transforms

scroll_example

Scrollable containers with customizable scrollbars.

cargo run --example scroll_example

Features demonstrated:

  • Vertical and horizontal scrolling
  • Custom scrollbar styling (colors, width, radius)
  • Hidden scrollbars
  • Kinetic/momentum scrolling

scroll_mixed_content

Advanced scrollable container with mixed content types.

cargo run --example scroll_mixed_content

Features demonstrated:

  • Text widgets inside scrollable containers
  • Text input widgets with form fields
  • Raster and SVG images in scrollable content
  • Interactive elements with hover states and ripples
  • Vertical and horizontal scrolling together
  • Scrollbar customization with different styles

multi_surface

Multiple surfaces with shared reactive state.

cargo run --example multi_surface

Features demonstrated:

  • Creating multiple layer shell surfaces
  • Shared reactive signals between surfaces
  • Independent surface configuration

surface_properties_example

Dynamic surface property modification at runtime.

cargo run --example surface_properties_example

Features demonstrated:

  • Changing layer at runtime
  • Modifying keyboard interactivity
  • Adjusting anchor and size dynamically

text_input_example

Text input widgets with editing capabilities.

cargo run --example text_input_example

Features demonstrated:

  • Single and multiline text input
  • Cursor and selection styling
  • Placeholder text
  • Focus states

Exploring the Code

Each example’s source code is in the examples/ directory. Reading through them is a great way to learn Guido patterns:

# View an example's source
cat examples/reactive_example.rs

Next Steps

After exploring the examples, dive deeper into the concepts:


service_example

Background services with automatic cleanup.

cargo run --example service_example

Features demonstrated:

  • create_service for background threads
  • Bidirectional communication with commands
  • Read-only services for periodic updates
  • Automatic cleanup when component unmounts

Core Concepts

This section covers the foundational concepts that make Guido work. Understanding these will help you build effective applications.

The Big Picture

Guido is built on three core ideas:

  1. Reactive Signals - UI state is stored in signals that automatically track dependencies and notify when changed
  2. Composable Widgets - UIs are built by composing simple primitives (containers, text) into complex structures
  3. Declarative Styling - Visual properties are declared through builder methods, not CSS or external files

How They Work Together

extern crate guido;
use guido::prelude::*;
fn main() {
// 1. Create reactive state
let count = create_signal(0);

// 2. Build composable widgets
let view = container()
    .padding(16.0)
    .background(Color::rgb(0.2, 0.2, 0.3))

    // 3. Declarative styling responds to state
    .child(text(move || format!("Count: {}", count.get())));
;
}

When count changes, only the text updates - the container doesn’t need to re-render.

In This Section

  • Reactive Model - Signals, computed values, and effects
  • Widgets - The Widget trait and composition patterns
  • Container - The primary building block
  • Layout - Flexbox-style layout with Flex

Key Insight

Unlike traditional retained-mode GUIs that rebuild widget trees on state changes, Guido’s reactive system means widgets are created once and their properties update automatically. This leads to efficient rendering and simple mental models.

Reactive Model

Guido uses a fine-grained reactive system inspired by SolidJS. This enables efficient updates where only the affected parts of the UI change.

RwSignal (Read-Write)

create_signal() returns an RwSignal<T> — a read-write reactive value (8 bytes, Copy):

extern crate guido;
fn main() {
use guido::prelude::*;

let count = create_signal(0); // RwSignal<i32>

// Read the current value
let value = count.get();

// Set a new value
count.set(5);

// Update based on current value
count.update(|c| *c += 1);
;
}

Key Properties

  • Copy - RwSignal implements Copy, so you can use it in multiple closures without cloning
  • Background updates - Use .writer() to get a WriteSignal<T> for background task updates
  • Automatic tracking - Dependencies are tracked when reading inside reactive contexts
  • Converts to Signal - Call .read_only() or .into() to get a read-only Signal<T>

State vs. Triggers: set and set_always

set publishes state: equal values are deduplicated (PartialEq), so republishing unchanged data costs nothing. set_always fires a trigger: every write notifies, no comparison, no PartialEq bound — the natural write for “something happened” values like an OSD flash:

extern crate guido;
use guido::prelude::*;
fn main() {
let volume = create_signal(0u32);
let osd = create_signal(String::new());
let info = String::from("volume 50%");
volume.set(50);          // state: setting 50 twice notifies once
osd.set_always(info);    // trigger: every flash notifies
;
}

For a data-less pulse there is Trigger:

extern crate guido;
use guido::prelude::*;
fn main() {
let rebuild = move || {};
let refresh = create_trigger();
create_effect(move || {
    refresh.track();
    rebuild();
});
refresh.notify(); // rebuild() runs every time
;
}

Signals hold one value, so rapid set_always calls coalesce to the last one per frame — perfect for UI triggers, wrong for event streams that must not lose emissions (those belong in an async channel).

Signal (Read-Only)

Signal<T> is a read-only reactive value (12 bytes, Copy). It cannot be written to — calling .set() is a compile-time error. There are two ways to create one:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
// Stored: wraps a static value
let name = create_stored("hello".to_string()); // Signal<String>

// Derived: closure-backed, re-evaluates on each read
let doubled = create_derived(move || count.get() * 2); // Signal<i32>
;
}

You can also convert an RwSignal to a Signal:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let read_only: Signal<i32> = count.read_only(); // or count.into()
;
}

Memos

Eagerly computed values that automatically update when their dependencies change. Memos only notify downstream subscribers when the result actually differs (PartialEq), preventing unnecessary updates:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let doubled = create_memo(move || count.get() * 2);

count.set(5);
println!("{}", doubled.get()); // Prints: 10
;
}

Memos are Copy like signals and can be used directly as widget properties:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let label = create_memo(move || format!("Count: {}", count.get()));
text(label)  // Only repaints when the formatted string changes
;
}

A memo is current whenever an effect that reads it runs. Take an effect that reads a list’s length and a memo clamping an index into it: it runs once per write, and sees the new length beside the new index, never the old one.

Effects

Side effects that re-run when tracked signals change:

extern crate guido;
use guido::prelude::*;
fn main() {
let name = create_signal("World".to_string());

create_effect(move || {
    println!("Hello, {}!", name.get());
});

name.set("Guido".to_string()); // Effect re-runs, prints: Hello, Guido!
;
}

Effects are useful for logging, syncing with external systems, or triggering actions.

Each run owns what it makes. A signal, an effect, a timer, a task, an on_cleanup or a context provided inside the body is disposed before the effect runs again, and with the effect when its scope goes. A memo is an effect too, so the same holds for what a memo’s closure makes. To keep something past the run, make it under a scope captured outside:

extern crate guido;
use guido::prelude::*;
fn main() {
let trigger = create_signal(0);
let outer = current_owner().expect("created inside a scope");
create_effect(move || {
    trigger.get();
    let kept = outer.run(|| create_signal(0)); // lives as long as `outer`
});
}

Using Signals in Widgets

Most widget properties accept either static values or reactive sources:

Static Value

extern crate guido;
use guido::prelude::*;
fn main() {
container().background(Color::RED)
;
}

Signal

extern crate guido;
use guido::prelude::*;
fn main() {
let bg = create_signal(Color::RED);
container().background(bg)
;
}

Closure

extern crate guido;
use guido::prelude::*;
fn main() {
let is_active = create_signal(false);
container().background(move || {
    if is_active.get() { Color::GREEN } else { Color::RED }
})
;
}

Reactive Text

Text content can be reactive using closures:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);

text(move || format!("Count: {}", count.get()))
;
}

The text automatically updates when count changes.

The IntoSignal Pattern

Widget properties accept Signal<T> via the IntoSignal<T> trait. Both RwSignal<T> and Signal<T> work, along with raw values and closures:

  • Static values → creates a stored Signal<T> via create_stored()
  • Closures → creates a derived Signal<T> via create_derived()
  • Signal<T> → passed through directly
  • RwSignal<T> → automatically converted to Signal<T>

You don’t need to create signals manually for widget properties — just pass values, closures, or signals directly.

Per-Field Signals

When multiple widgets depend on different fields of the same struct, #[derive(SignalFields)] generates per-field signals so each widget only re-renders when its specific field changes:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq, SignalFields)]
pub struct AppState {
    pub cpu: f64,
    pub memory: f64,
    pub title: String,
}

fn main() {
// Creates individual Signal<T> for each field
let state = AppStateSignals::new(AppState {
    cpu: 0.0, memory: 0.0, title: "App".into(),
});

// Each widget subscribes to only the field it reads
text(move || format!("CPU: {:.0}%", state.cpu.get()));
text(move || format!("MEM: {:.0}%", state.memory.get()));
text(move || state.title.get());
}

Use .writers() to get Send handles for background task updates:

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
let writers = state.writers();

create_task(move |ctx| async move {
    while ctx.is_running() {
        // Each field is set individually with PartialEq change detection.
        // Effects that depend on multiple fields run only once (batched).
        writers.set(AppState {
            cpu: read_cpu(),
            memory: read_memory(),
            title: get_title(),
        });
        tokio::time::sleep(Duration::from_secs(1)).await;
    }
});
;
}

Generic structs are supported — the generated types carry the same generic parameters:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq, SignalFields)]
pub struct Pair<A: Clone + PartialEq + Send + 'static, B: Clone + PartialEq + Send + 'static> {
    pub first: A,
    pub second: B,
}

fn main() {
let pair = PairSignals::new(Pair { first: 1i32, second: "hello".to_string() });
let _ = pair;
}

Untracked Reads

Sometimes you want to read a signal without creating a dependency:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);

// Normal read - creates dependency
let value = count.get();

// Untracked read - no dependency
let value = count.get_untracked();
;
}

This is useful in effects where you want to read initial values without re-running on changes.

Timers

To do something later on the UI thread — hide a toast after a while, show a tooltip after a hover, search once typing pauses — use set_timeout and set_interval. The callback runs on the UI thread, so it can read and write signals like any other UI code:

extern crate guido;
use guido::prelude::*;
use std::time::Duration;
fn main() {
let visible = create_signal(true);
let auto_close = set_timeout(Duration::from_secs(4), move || visible.set(false));

// A close button hides it at once, and stops the timer that would have.
let close = container().on_click(move || {
    auto_close.cancel();
    visible.set(false);
});

let seconds = create_signal(0u32);
set_interval(Duration::from_secs(1), move || seconds.update(|s| *s += 1));
}

A timer stops when it is cancelled or when the scope that created it is disposed — a widget’s timers go with the widget. Dropping the TimerHandle does not stop it. What an effect’s run makes goes when it runs again, so an effect that schedules a timer on every run fires only the last — which is how a debounce is written:

extern crate guido;
use guido::prelude::*;
use std::time::Duration;
fn main() {
let search = |_q: String| {};
let query = create_signal(String::new());
create_effect(move || {
    let q = query.get();
    set_timeout(Duration::from_millis(300), move || search(q));
});
}

Background work that has to leave the UI thread — a socket, a file — is create_task instead.

Ownership & Cleanup

Signals and effects created inside dynamic children are automatically cleaned up when the child is removed. Use on_cleanup to register custom cleanup logic:

extern crate guido;
use guido::prelude::*;
fn main() {
let items = create_signal(vec![1u32, 2, 3]);
container().children(keyed(
    move || items.get(),
    |id| *id,
    |id| {
        // These are automatically owned and disposed
        let count = create_signal(0);
        create_effect(move || println!("Count: {}", count.get()));

        // Register custom cleanup for non-reactive resources
        on_cleanup(move || {
            println!("Child {} removed", id);
        });

        container().child(text(move || count.get().to_string()))
    },
))
;
}

See Dynamic Children for more details on automatic ownership.

Best Practices

Read Close to Usage

Read signals where the value is needed, not at the top of functions:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
// Good: Read in closure where it's used
text(move || format!("Count: {}", count.get()));

// Less optimal: Read early, pass static value
let value = count.get();
text(format!("Count: {}", value))  // Won't update!
;
}

Use Context for App-Wide State

For values that many widgets across different modules need (config, theme, services), use the Context API instead of passing signals through every function:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Default)]
struct Config;
impl Config { fn load() -> Self { Self } }
fn main() {
// In the App::run setup, which is the root scope
provide_context(Config::load());

// In any widget factory below it, in any module
let cfg = expect_context::<Config>();
;
}

A value is declared for the scope that is current — the setup, a surface, a popup, a dynamic list — and read by the scopes below it. For mutable shared state, use provide_signal_context to combine context with reactivity.

Use Memo for Derived State

Instead of manually syncing values:

extern crate guido;
use guido::prelude::*;
fn main() {
// Bad: Manual sync
let count = create_signal(0);
let doubled = create_signal(0);
// Must remember to update doubled when count changes

// Good: Use memo
let count = create_signal(0);
let doubled = create_memo(move || count.get() * 2);
;
}

A memo is also a tracking barrier, which is what makes it the tool for narrowing a rebuild. Signals read inside it belong to it alone, even when the memo is created inside something that is itself tracked — a dynamic-children closure, a paint pass, an effect:

extern crate guido;
use guido::prelude::*;
fn expensive_module() -> Container { container() }
fn main() {
let levels = create_signal(vec![0.2f32, 0.5, 0.9]);
// `levels` is written 60 times a second by an audio service
container().child(move || {
    let active = create_memo(move || levels.with(|l| !l.is_empty()));
    // This closure depends on the bool, not on `levels`: the subtree is
    // rebuilt when the bool flips, not on every write
    active.get().then(|| expensive_module())
})
;
}

Snapshots vs. Reactive Reads

Reading a signal subscribes whoever is currently running. Inside a closure that guido re-runs — a widget property, a child closure, an effect — that means the UI follows the value. Outside one, there is nobody to subscribe, so the read is a snapshot:

extern crate guido;
use guido::prelude::*;
fn main() {
let count_as_string = create_memo(move || String::new());
let count = create_signal(0);
text(format!("{}", count.get()));          // snapshot: never updates again
text(move || format!("{}", count.get()));  // reactive
text(count_as_string)                     // reactive: pass the signal itself
;
}

Rust cannot tell those apart at compile time, so debug builds print a warning at the offending line:

guido: src/main.rs:42:31: signal read with no reactive scope — this value is a
snapshot and will not update. Pass a closure instead …

If the snapshot is what you meant, say so with get_untracked() and the warning goes away. Release builds contain none of this.

API Reference

Signal Creation

// not compiled: a signature listing — these declarations have no bodies.
pub fn create_signal<T: Clone + PartialEq + Send + 'static>(value: T) -> RwSignal<T>;
pub fn create_stored<T: Clone + 'static>(value: T) -> Signal<T>;
pub fn create_derived<T: Clone + 'static>(f: impl Fn() -> T + 'static) -> Signal<T>;
pub fn create_memo<T: Clone + PartialEq + 'static>(f: impl Fn() -> T + 'static) -> Memo<T>;
pub fn create_effect(f: impl Fn() + 'static);

RwSignal Methods

// not compiled: a signature listing — these declarations have no bodies.
impl<T: Clone> RwSignal<T> {
    pub fn get(&self) -> T;           // Read with tracking
    pub fn get_untracked(&self) -> T; // Read without tracking
    pub fn set(&self, value: T);      // Set new value
    pub fn update(&self, f: impl FnOnce(&mut T)); // Update in place
    pub fn writer(&self) -> WriteSignal<T>; // Get Send handle for background threads
    pub fn read_only(&self) -> Signal<T>;   // Convert to read-only Signal
}

Signal Methods

// not compiled: a signature listing — these declarations have no bodies.
impl<T: Clone> Signal<T> {
    pub fn get(&self) -> T;           // Read with tracking
    pub fn get_untracked(&self) -> T; // Read without tracking
    pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R; // Borrow with tracking
    pub fn with_untracked<R>(&self, f: impl FnOnce(&T) -> R) -> R; // Borrow without tracking
    // No set/update/writer — Signal is read-only
}

Memo Methods

// not compiled: a signature listing — these declarations have no bodies.
impl<T: Clone + PartialEq> Memo<T> {
    pub fn get(&self) -> T;           // Read with tracking
    pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R; // Borrow with tracking
}

Cleanup

// not compiled: a signature listing — these declarations have no bodies.
// Register cleanup callback (for use in dynamic children)
pub fn on_cleanup(f: impl FnOnce() + 'static);

Timers

// not compiled: a signature listing — these declarations have no bodies.
// Run once after a delay, on the UI thread; cancelled with its scope
pub fn set_timeout(delay: Duration, f: impl FnOnce() + 'static) -> TimerHandle;
// Run every period, on the UI thread; cancelled with its scope
pub fn set_interval(period: Duration, f: impl FnMut() + 'static) -> TimerHandle;
// TimerHandle is Copy: .cancel(), .is_pending()

Background Services

// not compiled: a signature listing — these declarations have no bodies.
// Create an async background service with automatic cleanup
pub fn create_service<Cmd, F, Fut>(f: F) -> Service<Cmd>
where
    Cmd: Send + 'static,
    F: FnOnce(UnboundedReceiver<Cmd>, ServiceContext) -> Fut + Send + 'static,
    Fut: Future<Output = ()> + Send + 'static;

See Background Tasks for detailed usage.

Widgets

Widgets are the building blocks of Guido UIs. Every visual element is a widget, from simple text to complex layouts.

The Widget Trait

All widgets implement the Widget trait:

// not compiled: a signature listing — these declarations have no bodies.
pub trait Widget {
    fn layout(&mut self, ctx: &mut LayoutCtx, constraints: Constraints) -> Size;
    fn paint(&self, ctx: &mut PaintContext);
    fn event(&mut self, tree: &mut Tree, id: WidgetId, event: &Event) -> EventResponse;
}

Methods

  • layout - Calculate the widget’s size given tree access, widget ID, and constraints
  • paint - Draw the widget using tree for child access
  • event - Handle input events with tree access

Bounds and Origins

Widget bounds and origins are stored in the Tree, not on individual widgets:

  • Use tree.get_bounds(id) to retrieve a widget’s bounding rectangle for hit testing
  • Use tree.set_origin(id, x, y) to position widgets during layout (called by parent layouts)

Built-in Widgets

Container

The primary widget for building UIs. Handles:

  • Backgrounds (solid, gradient)
  • Borders and corner radius
  • Padding and sizing
  • Layout of children
  • Event handling
  • State layers (hover/pressed)
  • Transforms

See Container for details.

Text

Renders text content:

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Hello, World!").font_size(16.0).color(Color::WHITE))
;
}

See Text for styling options.

TextInput

Single-line text editing with selection, clipboard, and undo/redo:

extern crate guido;
use guido::prelude::*;
fn main() {
let username = create_signal(String::new());

container().child(text_input(username).color(Color::WHITE).on_submit(|text| println!("Submitted: {}", text)))
;
}

See Text Input for details.

Composition

Guido UIs are built through composition - nesting widgets inside containers:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::column().spacing(8.0))
    .children([
        container().child(text("Title").font_size(24.0)),
        container()
            .layout(Flex::row().spacing(4.0))
            .children([
                text("Item 1"),
                text("Item 2"),
            ]),
    ])
;
}

This creates:

┌─────────────────┐
│ Title           │
│ ┌─────┬───────┐ │
│ │Item1│ Item2 │ │
│ └─────┴───────┘ │
└─────────────────┘

Widget Functions

Guido provides functions that return configured widgets:

extern crate guido;
use guido::prelude::*;
fn main() {
// Creates a Container
container();

// Creates a Text widget
text("content")
;
}

These use the builder pattern for configuration:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(16.0)          // Returns Container
    .background(Color::RED) // Returns Container
    .corners(8.0)     // Returns Container
;
}

The impl Widget Pattern

Functions often return impl Widget instead of concrete types:

extern crate guido;
use guido::prelude::*;
fn my_button(label: &str) -> impl Widget {
    container()
        .padding(12.0)
        .background(Color::rgb(0.3, 0.5, 0.8))
        .corners(8.0)
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {}

This allows returning any widget type without exposing implementation details.

Constraints and Sizing

During layout, parent widgets pass constraints to children:

// not compiled: a listing of a type the crate owns — a copy of it declared
// here would compile without checking the original.
pub struct Constraints {
    pub min_width: f32,
    pub max_width: f32,
    pub min_height: f32,
    pub max_height: f32,
}

Children choose a size within these constraints. This enables flexible layouts where widgets can expand to fill space or shrink to fit content.

Size Modifiers

Control widget sizing with builder methods:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(100.0)                          // Fixed width
    .height(50.0)                          // Fixed height
    .width(at_least(50.0).at_most(200.0))  // Bounded instead
;
}

Event Flow

Events flow from the platform through the widget tree, and each one goes only where it can matter:

  1. Platform receives input (mouse, keyboard)
  2. Event dispatched to the surface’s root widget
  3. A pointer event is passed down to the widgets that can be under its point, and to those it still owes one — the widget the pointer just left, the one a press landed on. A key is passed down to the focused widget and, if it does not take it, to the containers listening for keys
  4. Each widget handles the event or ignores it; the innermost hears it first

Event Routing has the whole of it.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_click(|| println!("Clicked!"))
    .child(text("Click me"))
;
}

The container receives clicks anywhere within its bounds, including over the text.

Next Steps

Container

The Container is Guido’s primary building block. Nearly everything you build uses containers - they handle layout, styling, events, and child management.

Creating Containers

extern crate guido;
fn main() {
use guido::prelude::*;

let view = container();
;
}

Adding Children

Single Child

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Hello"))
;
}

Multiple Children

extern crate guido;
use guido::prelude::*;
fn main() {
container().children([
    text("First"),
    text("Second"),
    text("Third"),
])
;
}

Conditional Children

An Option is a child, so child takes one — absent when None:

extern crate guido;
use guido::prelude::*;
fn main() {
let show_extra = create_signal(false);

container()
    .child(text("Always shown"))
    .child(show_extra.get().then(|| text("Sometimes shown")))
;
}

That reads the signal once, when the tree is built, so it never changes again. For a child that comes and goes, pass a closure — it re-runs when a signal it read changes:

extern crate guido;
use guido::prelude::*;
fn main() {
let show_extra = create_signal(false);
container().child(move || show_extra.get().then(|| text("Sometimes shown")))
;
}

Styling

Containers support extensive styling options:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    // Background
    .background(Color::rgb(0.2, 0.2, 0.3))

    // Corners
    .corners(Corners::squircle(8.0))
     // iOS-style smooth corners

    // Border
    .border(2.0, Color::WHITE)

    // Spacing
    .padding(16.0)

    // Size
    .width(200.0)
    .height(100.0)
;
}

See Building UI for complete styling reference.

Backdrop Blur

Blur whatever is behind the container — both what this surface already drew and, where the surface is translucent, what the compositor has below it. Pair it with a translucent background so the result shows through:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgba(0.12, 0.12, 0.18, 0.55)) // translucent
    .corners(16.0)
    .backdrop_blur(32.0)
;
}

See Wayland Layer Shell — Backdrop Blur.

Layout

Control how children are arranged:

// not compiled: `[...]` stands in for the children; this block is about
// the layout above them.
extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::row().spacing(8.0).center())
    .children([...])
;
}

See Layout for details on flex layouts and stacks.

Event Handling

Respond to user interactions:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_click(|| println!("Clicked!"))
    .on_hover(|hovered| println!("Hover: {}", hovered))
    .on_scroll(|dx, dy, source| println!("Scroll: {}, {}", dx, dy))
;
}

State Layers

Add hover and pressed visual feedback:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.ripple())
;
}

See Interactivity for the full state layer API.

Transforms

Apply 2D transformations:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    // The three compose, in this order whatever order they are written in
    .translate((10.0, 20.0))
    .rotate(45.0)
    .scale(1.5)
    .pivot(Pivot::TOP_LEFT)
;
}

See Transforms for details.

Animations

The motion rides with the value: declare a property with a transition and it eases to each new value instead of jumping there.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
    .scale(Scale::NONE.transition(Transition::spring(SpringConfig::BOUNCY)))
;
}

See Animations for timing and spring options.

Visibility

Control whether a container is visible. When hidden, it takes up no space in layout, does not paint, and ignores all events. Nothing inside it advances an animation either, so a spinner in a hidden panel costs no frames, and shown again it spins again. An exit still plays out, so that its widget can leave.

extern crate guido;
use guido::prelude::*;
fn main() {
let tab = create_signal(String::from("settings"));
// Static
container().visible(false);

// Reactive signal
let show = create_signal(true);
container().visible(show);

// Reactive closure
container().visible(move || tab.get() == "settings")
;
}

Unlike .child(Option), which adds or removes a child from the tree, .visible() keeps the widget in the tree but hides it completely. This is useful when you want to toggle visibility without recreating the widget and its state.

Scrolling

Make containers scrollable when content overflows:

extern crate guido;
use guido::prelude::*;
fn large_content() -> Container { container() }
fn main() {
container()
    .width(200.0)
    .height(200.0)
    .scroll(Scroll::vertical())
    .child(large_content())
;
}

Scroll Axes

The axis is chosen by constructor, and it is the one part of a Scroll that takes no signal — it decides what kind of widget this is, as layout and control do.

ConstructorDescription
Scroll::vertical()Vertical scrolling only
Scroll::horizontal()Horizontal scrolling only
Scroll::both()Both directions

A container with no .scroll(..) does not scroll, which is what the old ScrollAxis::None said.

What Moves It

Three gestures, and which of them applies depends on what is pointing:

GestureMouseFinger
Wheel or touchpad over the contentyes—
Dragging the scrollbar handle, or clicking its trackyesyes
Dragging the contentnoyes

The last row is the touch gesture, and the pointer kind is why it is one row and not two. A finger drags the content because grabbing a scrollbar is the one gesture a touch interface never uses; a mouse does not, because dragging content with a mouse is how text is selected, and a scrollable that swallowed the drag would take selection away inside itself.

A finger owns nothing until it has moved 18 logical pixels — the slop Flutter’s kTouchSlop settled on. Under that the press belongs to whatever it landed on, so tapping a button inside a list still presses the button. Over it the press is taken back: the child is told the press it saw is void, exactly as it is when the pointer leaves without releasing, so its pressed state clears and its on_click does not fire. The slop is spent once — the move that crosses it scrolls the part of itself beyond it, so the content neither jumps by the threshold nor loses the rest of a fast swipe.

Releasing mid-motion hands the content to the same momentum a touchpad flick gets — it coasts and slows down. A finger landing on content that is still coasting stops it, and that press is the scroller’s: it stops the list rather than pressing what is under it.

A scroller with nothing to scroll claims none of this, so a list that happens to fit today still answers a tap the way it always did.

The keyboard moves it too, the nearest scroller around the focus — a scroller that is .focusable(true) and holds the focus itself, or one with the focused widget somewhere inside it:

KeyScrolls
Up / Downone line, 40 pixels — what a wheel notch scrolls
Left / Rightone line along the horizontal axis
Page Up / Page Downone viewport height
Home / Endto the start or the end of the vertical axis, or of the horizontal one on a Scroll::horizontal() scroller

The key goes to the focus first, as every key does: the focused widget, then the scroller’s own on_key_down, and only what both let through scrolls. A focused text input keeps Left, Right, Home and End for its caret and lets Up and Down through, and a list that moves a selection with the arrows takes them in its own on_key_down. A scroller that cannot move that way — at its end, or with no axis for the key, as a page on a Scroll::horizontal() scroller — lets the key go on, so the next scroller out moves instead, as a browser’s do. With Ctrl, Alt or Super held, the key is left for whoever binds it.

With nothing focused, nothing scrolls. Clicking into a list that is not focusable gives nothing the focus, so a list that should scroll after a click is made focusable:

extern crate guido;
use guido::prelude::*;
fn large_content() -> Container { container() }
fn main() {
container()
    .height(300.0)
    .focusable(true)
    .scroll(Scroll::vertical())
    .child(large_content())
;
}

Nested scrollers go to the innermost one that can move: a finger in a list inside a page drags the list, and the page only where the list is not — or where it has nothing to scroll on that axis. The wheel has always gone to the innermost, and this is the same rule for a finger. A list that reaches its end mid-drag keeps the gesture rather than passing what is left of it outward, as Flutter’s nested scrollables do; CSS calls the other behaviour scroll chaining, and there is no overscroll-behavior here to ask for it.

Filling the Viewport

A scrolled axis has no end, so a fill() child of the content has nothing to fill there and is as big as its own content. fill_viewport gives the content the visible size as a minimum, and a fill child then takes what the rest of the content leaves of it — a footer that sits at the bottom of a short list:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .height(300)
    .scroll(Scroll::vertical().fill_viewport(true))
    .child(text("a short list"))
    .child(container().height(fill()))
    .child(text("footer"))
;
}

Once the content is taller than the viewport the minimum is met, the fill child is back to its content size, and the content scrolls. Content nested a level down has to fill too — container().height(fill()) around the column — since a child that does not fill is as big as it is, minimum or not. Android calls this fillViewport.

Custom Scrollbars

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scroll(
        Scroll::vertical()
            .width(6.0)
            .handle(|h| h
                .background(Color::rgb(0.4, 0.6, 0.9))
                .corners(3.0)
                .when_hovered(|s| s.background(Color::rgb(0.5, 0.7, 1.0)))),
    )
;
}

Hidden Scrollbars

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scroll(Scroll::vertical().visibility(ScrollbarVisibility::Hidden))
;
}

Complete Example

Here’s a fully-styled interactive button:

extern crate guido;
use guido::prelude::*;
fn create_button(label: &str, on_click: impl Fn() + 'static) -> Container {
    container()
        // Layout
        .padding(16.0)

        // Styling
        .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
        .corners(8.0)
        .border(1.0, Color::rgb(0.4, 0.6, 0.9))
        .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)

        // State layers
        .when_hovered(|s| s.lighter(0.1).outline(2.0, Color::rgb(0.5, 0.7, 1.0), 2.0))
        .when_pressed(|s| s.ripple().darker(0.05).scale(0.98))

        // Event
        .on_click(on_click)

        // Content
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {}

Builder Methods Reference

Children

  • .child(widget) - Add single child
  • .children([...]) - Add multiple children
  • .child(Option<widget>) - Conditional child, absent when None
  • .child(move || ..) - Reactive child, rebuilt when a signal it read changes
  • .children(keyed(items, key_fn, view_fn)) - Keyed reactive list

Styling

  • .background(color) - Solid background
  • .gradient(LinearGradient::horizontal(start, end)) - Horizontal gradient
  • .gradient(LinearGradient::vertical(start, end)) / .gradient(LinearGradient::diagonal(start, end))
  • .corners(8.0) / .corners([16.0, 0.0]) - Rounded corners: one, two or four values
  • .corners(Corners::squircle(12.0)) / Corners::bevel(..) / Corners::scoop(..) - the shape of the corner
  • .border(width, color) - Border, inside the box: it takes room, as CSS’s border-box
  • .outline(width, color, offset) - A ring outside the box that takes no room: the focus or hover ring
  • .shadow(shadow) - Shadows: one Shadow or up to four, each an offset, blur, spread and colour, outside the box or inset

Spacing

  • .padding(all) - Uniform padding
  • .padding([v, h]) - CSS two-value shorthand
  • .padding([t, r, b, l]) - CSS four-value shorthand

Sizing

  • .width(w) / .height(h) - Fixed size
  • .width(at_least(w)) / .width(at_most(w)) - Bounded width
  • .height(at_least(h).at_most(h2)) - Bounded height

Layout

  • .layout(Flex::row()) - Horizontal layout
  • .layout(Flex::column()) - Vertical layout
  • .layout(ZStack::new().align(Alignment::CENTER)) - Children stacked, centred
  • .align_self(Alignment::TOP_END) - Where this one sits in a ZStack

Events

  • .on_click(handler) - Click events
  • .on_hover(handler) - Hover enter/leave
  • .on_scroll(handler) - Scroll events

State Layers

  • .when_hovered(|s| s...) - Hover overrides
  • .when_pressed(|s| s...) - Pressed overrides

Transforms

  • .translate((x, y)) - Move
  • .rotate(degrees) - Rotate
  • .scale(factor) - Scale
  • .pivot(origin) - Pivot point

Animations

Declared on the value, not beside it:

  • value.transition(ms) - ease to each new value
  • value.timeline(keyframes.played_by(plays)) - play a sequence on a trigger

Visibility

  • .visible(condition) - Show or hide the container (accepts static, signal, or closure)

Scrolling

  • .scroll(Scroll::vertical() | Scroll::horizontal() | Scroll::both()) - Scroll, and say what the scrollbar looks like. One value: the parts cannot be declared apart from the thing they configure
  • Scroll::width, hover_width, margin, min_handle_size, reserve_gutter / overlay(), visibility(..), fill_viewport(..) - the measurements the container’s layout needs, each taking a static value, a signal or a closure. The bar is 6 wide and grows to 10 under the pointer; an undeclared hover_width keeps that proportion to width, so a widened bar still grows
  • Scroll::track(|t| ..) and Scroll::handle(|h| ..) - the scrollbar is two containers, so these take everything a container takes

Layout

Guido uses a flexbox-style layout system for arranging widgets. The Flex layout handles rows and columns with spacing and alignment options.

Basic Layout

Row (Horizontal)

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::row())
    .children([
        text("Left"),
        text("Center"),
        text("Right"),
    ])
;
}

Column (Vertical)

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::column())
    .children([
        text("Top"),
        text("Middle"),
        text("Bottom"),
    ])
;
}

Spacing

Add space between children:

// not compiled: `[...]` stands in for the children; this block is about
// the layout above them.
extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::row().spacing(8.0))
    .children([...])
;
}

Main Axis Alignment

Control distribution along the layout direction:

extern crate guido;
use guido::prelude::*;
fn main() {
Flex::row().main_alignment(MainAlignment::Center)
;
}

Options

AlignmentDescription
StartPack at the beginning
CenterCenter in available space
EndPack at the end
SpaceBetweenEqual space between, none at edges
SpaceAroundEqual space around each item
SpaceEvenlyEqual space including edges

The three spacing alignments share out the space the flex is given. Along a scrolled axis there is no limit and so nothing to share: the children sit together as with Start, and the content is as long as they are.

A child that is hidden, or half a pixel or less along the flex’s direction, keeps no space around it. When no child keeps space, SpaceAround and SpaceEvenly centre what they hold, and SpaceBetween places what it holds at the start.

Visual Examples

Start:        [A][B][C]
Center:          [A][B][C]
End:                      [A][B][C]
SpaceBetween: [A]      [B]      [C]
SpaceAround:   [A]    [B]    [C]
SpaceEvenly:    [A]   [B]   [C]

Cross Axis Alignment

Control alignment perpendicular to the layout direction:

extern crate guido;
use guido::prelude::*;
fn main() {
Flex::row().cross_alignment(CrossAlignment::Center)
;
}

Options

AlignmentDescription
StartAlign to start of cross axis
CenterCenter on cross axis
EndAlign to end of cross axis
StretchStretch to fill cross axis
BaselineLine children up on the baseline of their text

Baseline

Text of different sizes in a row does not read as one line when the tops are aligned — a 24px label and a 12px one float at unrelated heights. Baseline puts them on the line they are written on:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::row().spacing(8.0).cross_alignment(CrossAlignment::Baseline))
    .children([
        container().child(text("28").font_size(24.0)),
        container().child(text("°C").font_size(12.0)),
    ])
;
}

A box reports no baseline of its own, so it is aligned by its bottom edge, which is what CSS does with it. A container takes the baseline of its content, so wrapping a text for styling does not lose the alignment. The row is deep enough for what the alignment moves down: a label pushed down beside a tall icon keeps its descenders inside the row. In a column there is no shared line to sit on, and Baseline behaves as Start.

Visual Example (Row)

Start:    ┌───┐┌─┐┌──┐
          │ A ││B││ C│
          └───┘│ │└──┘
               └─┘

Center:        ┌─┐
          ┌───┐│B│┌──┐
          │ A │└─┘│ C│
          └───┘   └──┘

End:           ┌─┐
               │B│
          ┌───┐└─┘┌──┐
          │ A │   │ C│
          └───┘   └──┘

Stretch:  ┌───┐┌─┐┌──┐
          │   ││ ││  │
          │ A ││B││ C│
          │   ││ ││  │
          └───┘└─┘└──┘

Centring

center() centres the children on both axes. It is shorthand for main_alignment(MainAlignment::Center) together with cross_alignment(CrossAlignment::Center), and composes with the rest:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::column().spacing(4.0).center())
    .children([text("Locked"), text("Type your password")])
;
}

Complete Example

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(
        Flex::row()
            .spacing(16.0)
            .main_alignment(MainAlignment::SpaceBetween)
            .cross_alignment(CrossAlignment::Center)
    )
    .padding(20.0)
    .children([
        container().child(text("Left").font_size(24.0)),
        container()
            .layout(Flex::column().spacing(4.0))
            .children([
                text("Center"),
                text("Items"),
            ]),
        container().child(text("Right").font_size(24.0)),
    ])
;
}

Nested Layouts

Combine rows and columns for complex layouts:

extern crate guido;
use guido::prelude::*;
fn main_content() -> Container { container() }
fn sidebar() -> Container { container() }
fn main() {
container()
    .layout(Flex::column().spacing(16.0))
    .children([
        // Header row
        container()
            .layout(Flex::row().main_alignment(MainAlignment::SpaceBetween))
            .children([
                text("Logo"),
                text("Menu"),
            ]),
        // Content row
        container()
            .layout(Flex::row().spacing(16.0))
            .children([
                sidebar(),
                main_content(),
            ]),
        // Footer row
        container()
            .layout(Flex::row().main_alignment(MainAlignment::Center))
            .child(text("Footer")),
    ])
;
}

Size Constraints

Control how children size within layouts:

Fixed Size

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(200.0)
    .height(100.0)
;
}

Minimum/Maximum

extern crate guido;
use guido::prelude::*;
fn main() {
container().width(at_least(100.0).at_most(300.0))
;
}

At Least

Request at least a certain size:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(at_least(200.0))  // At least 200px, can grow
;
}

A minimum is a floor, never a cap, as CSS min-width is: content wider than 200 makes the container wider. A child that takes everything it is offered — a fill() child, or a text input — takes everything the container is offered too, so a field meant to be a set width says so with an exact length, or bounds it with at_least(200.0).at_most(400.0).

Fill Available Space

Make a container expand to fill all available space:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .height(fill())  // Fills available height
    .width(fill())   // Fills available width
;
}

This is particularly useful for root containers that should fill their surface, or for creating layouts where children are centered within the full available space:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .height(fill())
    .layout(Flex::row().center())
    .child(text("Centered in available space"))
;
}

Along a scrolled axis there is no end to fill to, so fill() there is the container’s content size. A scroller that should give it the visible area says so with Scroll::vertical().fill_viewport(true) — see the Container chapter.

Natural Size

Every other length is cut to what the parent offers: .width(240.0) in a parent 100 wide is 100 wide, and its content is laid out in 100. natural() is the one that is not. The content is measured with no maximum on that axis — a label neither wraps nor ellipsizes — and the container is that size whatever its parent is, running past the parent’s edge. It is cut off there only if the parent clips, with Overflow::Hidden; otherwise it paints where it runs to. It is CSS’s max-content, SwiftUI’s fixedSize.

That is what a shape that changes size around its content wants. An island that springs from one view’s width to the next would re-wrap its label at every frame of the spring; with the content at its natural size, the clipped island uncovers and covers it instead:

extern crate guido;
use guido::prelude::*;
fn main() {
let expanded = create_signal(false);
container()
    .width((move || if expanded.get() { 320.0 } else { 120.0 }).transition(SpringConfig::SNAPPY))
    .height(40.0)
    .corners(20.0)
    .overflow(Overflow::Hidden)
    .layout(ZStack::new().align(Alignment::CENTER))
    .child(
        container()
            .width(natural())
            .layout(Flex::row().spacing(8.0))
            .child(text("Now playing"))
            .child(text("A song with a long title")),
    )
;
}

A clipping parent asks nothing of its content’s size, so the island can be narrower than what it holds without a layout of its own. The length’s own bounds still apply — natural().at_most(300.0) is the content’s size up to 300 — and in a row too short for its children a natural child is held at its size, as an exact one is.

Children That Do Not Fit

When a row’s children are wider than the row, the children that can be narrower give the room up, in proportion to their widths — CSS’s flex-shrink. None goes below its minimum:

  • a container with an exact width keeps it, however deep it sits — an icon inside a padded button is not squashed by a long title beside the button
  • a container with a natural() width keeps the width of what it holds
  • a text that ellipsizes can go down to the …, one that wraps to its widest word, and one that does neither keeps its whole width
  • a container that clips (Overflow::Hidden) or scrolls can go down to nothing, or to the minimum it declares with at_least; any other container goes no lower than what it holds, plus its padding

A child held at its minimum leaves the rest of the overflow to the others. When even the minimums do not fit, the row runs past its end, as it does when nothing in it can shrink. A row that fits is laid out at its children’s natural sizes, exactly as before. Columns follow the same rule along their height:

extern crate guido;
use guido::prelude::*;
fn header() -> Container { container().height(30.0) }
fn rows() -> Container { container() }
fn main() {
// The list takes what the header leaves, rather than the column's whole height.
container()
    .height(300.0)
    .layout(Flex::column())
    .child(header())
    .child(container().scroll(Scroll::vertical()).child(rows()))
;
}

fill() children are not part of this: they take what is left once the others are laid out, and nothing is left when the others overflow.

Layout Without Explicit Flex

Containers without .layout() use Flex::column():

extern crate guido;
use guido::prelude::*;
fn main() {
// Same as .layout(Flex::column())
container()
    .children([
        text("first"),
        text("second"),
    ])
;
}

Stacking Children (ZStack)

ZStack places every child at the same position, stacked along the Z axis — later children paint on top, and take the pointer first. A layer that draws where the pointer is — a fill, a border, a blur — or listens there covers what is beneath it, even a translucent scrim; one that only lays out its content lets the pointer through:

extern crate guido;
use guido::prelude::*;
fn main() {
fn background() -> Container { container() }
fn content() -> Container { container() }
container()
    .layout(ZStack::new())
    .child(background())
    .child(content())   // drawn on top
;
}

Leaders and Followers

The stack takes the size of its largest child, but a child that declares fill() on an axis follows the stack instead of leading it: it never contributes to that axis and is laid out against the size the other children established.

That is how a decoration is sized to its sibling without measuring anything:

extern crate guido;
use guido::prelude::*;
fn card_content() -> Container { container() }
fn main() {
container()
    .layout(ZStack::new())
    // Follower: exactly as wide and tall as the card below it
    .child(
        container()
            .width(fill())
            .height(fill())
            .background(Color::rgb(0.2, 0.2, 0.3))
    )
    // Leader: the stack is as big as this card
    .child(card_content())
;
}

Without this rule the background would expand to all the available space, and sizing it to its sibling would need a WidgetRef round-trip — one frame behind, and zero on the first frame.

A hidden child leads nothing, just as it takes no space in a flex: while an overlay beside a fill() background is hidden, the stack is sized as if it were not there.

A child on its way out — removed from a .child(move || ..), a .children(move || ..) or a keyed(..) list and playing the exit it declared with exiting_to — does not hold the stack open either: it leads only an axis that nothing staying leads. A shape that sizes to its content cross-fades between two views and starts moving toward the new one’s size at once, rather than when the old one has finished fading. The leaving view keeps its own size, its place by the stack’s alignment and its paint — cut off where the shape clips — and a leaving fill() background shrinks with the stack. With nothing staying, the leaving view is all there is to size by, so a view removed with nothing in its place fades out where it was.

The two axes are decided independently, which is what makes the status-bar case work: a child with only .height(fill()) fills the bar height and still leads the width.

extern crate guido;
use guido::prelude::*;
fn now_playing() -> Container { container() }
fn visualizer() -> Container { container() }
fn main() {
container()
    .height(fill())
    .layout(ZStack::new())
    // Follows both axes
    .child(container().width(fill()).height(fill()).child(visualizer()))
    // Fills the bar height, leads the width
    .child(container().height(fill()).child(now_playing()))
;
}

When every child fills an axis there is no size to follow, and the stack takes all the space it was offered on that axis — or, along a scrolled axis, where there is no end to the space, the size of its largest child.

Positioning Inside the Stack

Every child is placed by an Alignment. The stack gives one to all of its children with ZStack::new().align(..), and a child can declare its own with .align_self(..), the way CSS’s align-self overrides align-items. The stack’s is the top start until it says otherwise, and a child that says nothing takes the stack’s — so a stack nobody aligns puts every child at its origin.

extern crate guido;
use guido::prelude::*;
fn icon_widget() -> Container { container() }
fn main() {
container()
    .layout(ZStack::new().align(Alignment::CENTER))
    .child(icon_widget())
    // Centred over the icon, as the stack says
    .child(text("3"))
    // Alert dot on the icon's top-right corner, as it says itself
    .child(
        container()
            .width(4)
            .height(4)
            .corners(2)
            .background(Color::RED)
            .align_self(Alignment::TOP_END)
    )
;
}

There are nine: TOP_START, TOP_CENTER, TOP_END, CENTER_START, CENTER, CENTER_END, BOTTOM_START, BOTTOM_CENTER and BOTTOM_END. Start is the left and the top.

An alignment places a child in the room the stack has left over. Every leader is offered up to the stack’s size and held to none of it, as Flutter’s Stack does by default, so even in a stack with an exact size a child that sizes to its content keeps that size, and the alignment shares out the rest. A follower that fills the stack on an axis has none there and stays put: a background behind a centred label is still exactly the stack’s size. A child larger than the stack is shared out the same way and overhangs instead — CENTER by the same amount on both sides. A child is only larger than the stack when it is at its natural size, since every other length is cut to what the stack offers:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(100.0)
    .overflow(Overflow::Hidden)
    .layout(ZStack::new().align(Alignment::CENTER))
    // 160 wide in a stack of 100: 30 past each side, and cut there
    .child(container().width(natural()).child(container().width(160.0).height(20.0)))
;
}

API Reference

Flex Builder

Flex::row() -> Flex;                    // Horizontal layout
Flex::column() -> Flex                 // Vertical layout
.spacing(f32) -> Flex                  // Space between children
.main_alignment(MainAlignment) -> Flex
.cross_alignment(CrossAlignment) -> Flex

MainAlignment

extern crate guido;
use guido::prelude::*;
fn main() {
MainAlignment::Start;
MainAlignment::Center;
MainAlignment::End;
MainAlignment::SpaceBetween;
MainAlignment::SpaceAround;
MainAlignment::SpaceEvenly
;
}

CrossAlignment

extern crate guido;
use guido::prelude::*;
fn main() {
CrossAlignment::Start;
CrossAlignment::Center;
CrossAlignment::End;
CrossAlignment::Stretch
;
}

Alignment

extern crate guido;
use guido::prelude::*;
fn main() {
Alignment::TOP_START;
Alignment::TOP_CENTER;
Alignment::TOP_END;
Alignment::CENTER_START;
Alignment::CENTER;
Alignment::CENTER_END;
Alignment::BOTTOM_START;
Alignment::BOTTOM_CENTER;
Alignment::BOTTOM_END
;
}

ZStack

// not compiled: a signature listing — these declarations have no bodies.
extern crate guido;
use guido::prelude::*;
fn main() {
ZStack::new() -> ZStack                // Children stacked, at the top start
.align(Alignment) -> ZStack            // Where every child sits
;
}

Building UI

This section covers the visual styling options in Guido. Learn how to create polished, professional-looking interfaces.

Styling Philosophy

Guido uses a builder pattern for styling - each method returns the widget, allowing chained calls:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(16.0)
    .background(Color::rgb(0.2, 0.2, 0.3))
    .corners(8.0)
    .border(1.0, Color::WHITE)
;
}

All styling is done in Rust code, not external CSS files. This provides type safety and IDE support.

In This Section

Quick Reference

extern crate guido;
use guido::prelude::*;
fn main() {
let start = Color::rgb(0.2, 0.2, 0.3);
let end = Color::rgb(0.3, 0.3, 0.4);
container()
// Background
.background(Color::rgb(0.2, 0.2, 0.3))
.gradient(LinearGradient::horizontal(start, end))

// Corners
.corners(Corners::squircle(8.0))
  // iOS-style smooth

// Border
.border(2.0, Color::WHITE)

// Shadow
.shadow(Shadow::simple((0.0, 4.0), 8.0, Color::rgba(0.0, 0.0, 0.0, 0.2)))

// Spacing
.padding(16.0)
.padding([10.0, 20.0])   // [vertical, horizontal]

// Size
.width(100.0)
.height(50.0)
;
}

Styling Overview

This page provides a complete reference for all styling options available in Guido.

Backgrounds

Solid Color

extern crate guido;
use guido::prelude::*;
fn main() {
container().background(Color::rgb(0.2, 0.2, 0.3))
;
}

Gradients

extern crate guido;
use guido::prelude::*;
fn main() {
// Horizontal (left to right)
container().gradient(LinearGradient::horizontal(Color::RED, Color::BLUE));

// Vertical (top to bottom)
container().gradient(LinearGradient::vertical(Color::RED, Color::BLUE));

// Diagonal
container().gradient(LinearGradient::diagonal(Color::RED, Color::BLUE))
;
}

Corners

Basic Radius

extern crate guido;
use guido::prelude::*;
fn main() {
container().corners(8.0)  // 8px radius on all corners
;
}

Corner Curvature

Control corner shape using CSS K-values:

extern crate guido;
use guido::prelude::*;
fn main() {
container().corners(Corners::squircle(12.0));  // iOS-style (K=2)
container().corners(Corners::scoop(12.0));              // Circular (K=1, default)
container().corners(Corners::bevel(12.0));      // Diagonal (K=0)
container().corners(Corners::superellipse(12.0, 1.5));      // Concave (K=-1)
container().corners(12.0)  // Custom
;
}

Borders

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(2.0, Color::WHITE)  // Width and color
;
}

A border takes room inside the box, as CSS’s box-sizing: border-box does: the children are laid out inside the border and the padding, and a box sized to its content grows by both. A scroller’s viewport and extents are measured inside its border, so its content scrolls from the border’s inner edge to the same edge at the far end. The width is a layout property for that reason: changing it, from a signal, a transition or a state layer, relays the container out and moves what it holds. A ring that should not move anything is an outline.

A container that scrolls or hides its overflow cuts its children inside its border, as CSS cuts at the padding box: the border is never covered by what the container holds, its scrollbar sits inside the border too, and a press on the border does not reach a child under it. The container’s own background, border and shadow are not cut by that clip.

Outlines

A ring outside the box, drawn above everything it holds and taking no room — the focus or hover ring. See Outlines.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(8.0)
    .outline(2.0, Color::WHITE, 2.0)  // Width, colour, offset
;
}

Shadows

Four degrees of freedom: offset, blur, spread and colour. There is no elevation level — a design system’s ladder is a set of Shadow constants the application writes down. See Shadows.

extern crate guido;
use guido::prelude::*;
fn main() {
container().shadow(Shadow::simple((0.0, 2.0), 4.0, Color::rgba(0.0, 0.0, 0.0, 0.16)));
container().shadow(Shadow::simple((0.0, 6.0), 10.0, Color::rgba(0.0, 0.0, 0.0, 0.22)));
container().shadow(Shadow::new((14.0, 0.0), 8.0, 4.0, Color::rgba(0.9, 0.2, 0.3, 0.5)))
;
}

Opacity

opacity fades a container and everything inside it — its background, border and shadow, and its children’s text, images and frost — from 1.0, as declared, down to 0.0, invisible. Nested opacities multiply: a half inside a half is a quarter.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .opacity(0.5)
    .child(text("half as strong"))
;
}

The container fades as one picture, the way CSS’s opacity does: where a label sits on its own background, or a cover under a title, the overlap fades with the rest instead of showing through. To get that, the container is drawn into a texture of its own and laid down once — but only when two of the things it draws actually overlap. Where nothing overlaps, fading each thing on its own is the same picture, and that is what is drawn, for nothing.

opacity_mode chooses the other behaviour: every draw fades on its own, an overlap shows through darker, and nothing is drawn offscreen.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .opacity(0.5)
    .opacity_mode(OpacityMode::Modulate)
    .child(text("the background shows through the letters"))
;
}

A container with a backdrop blur in it fades draw by draw, whatever overlaps: its frost keeps frosting what guido drew beneath it in the surface for the whole of the fade. Faded as one picture, the frost would see only what the container itself drew — CSS calls that a backdrop root — and snap back to the surface when the fade ended. A blur the compositor runs behind the surface is not affected either way.

A faded container still takes up its space and still takes input.

Blur

blur softens everything a container draws, its children included, by a radius in logical pixels — the radius a backdrop blur takes:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .blur(4.0)
    .child(text("out of focus"))
;
}

A blur, like a CSS filter, always draws the container into a texture of its own and blurs that. The texture covers what the container draws, its children included, plus the reach of the blur, cut to the part that can be on the surface. A radius of zero is drawn the same way and looks exactly like no blur. A backdrop blur inside a blurred container frosts only what the container drew beneath it. The blur spreads past everything the container draws — its edges, or a child’s shadow standing outside them — by about one and a quarter of its radius. It animates — see Animated Properties.

Padding

extern crate guido;
use guido::prelude::*;
fn main() {
container().padding(16.0);              // All sides
container().padding(16);                // Integers work too
container().padding([8.0, 16.0]);       // [vertical, horizontal]
container().padding([8, 16]);           // Integer arrays too
container().padding([1.0, 2.0, 3.0, 4.0]);  // [top, right, bottom, left]
container().padding(Padding::all(8.0).with_top(20.0))  // Builder for one edge
;
}

Sizing

Fixed Size

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(100.0)
    .height(50.0)
;
}

Integers work too:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(100)
    .height(50)
;
}

Constraints

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(at_least(50.0).at_most(200.0))
    .height(at_least(30.0).at_most(100.0))
;
}

At Least / At Most

extern crate guido;
use guido::prelude::*;
fn main() {
container().width(at_least(100.0));           // At least 100px
container().width(at_least(100));             // Integers work too
container().width(at_most(400));              // At most 400px
container().width(at_least(100).at_most(400)) // Range
;
}

Fraction of Available Space

fraction(f) takes a fraction (0.0..=1.0) of the space offered by the parent, resolved at layout time. It is the natural tool for value-proportional bars — sliders, gauges, progress — because the width follows the value on the very first frame, with no measured-rect round-trip:

extern crate guido;
use guido::prelude::*;
fn main() {
// A slider fill bar at the current volume
let volume = create_signal(40);
container().width(move || fraction(volume.get() as f32 / 100.0))
;
}

Natural Size

natural() is the size of the content, whatever the parent offers: it is never cut to the parent, and runs past its edge unless the parent clips — see Natural Size in the Layout chapter.

extern crate guido;
use guido::prelude::*;
fn main() {
container().width(natural()).child(text("Never wrapped, never cut"))
;
}

Static or Reactive

Every property that survives to paint takes a signal, a closure, or a plain value — the same IntoSignal shape everywhere, so which spelling you use is never the API’s decision:

extern crate guido;
use guido::prelude::*;
const COOL: Color = Color::rgb(0.2, 0.4, 0.9);
const HOT: Color = Color::rgb(0.9, 0.3, 0.2);
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let collapsed = create_signal(false);
let frosted = create_signal(false);
let hot = create_signal(false);
#[derive(Clone, PartialEq)]
struct Palette;
impl Palette {
    fn header(&self) -> LinearGradient {
        LinearGradient::horizontal(Color::WHITE, Color::BLACK)
    }
}
let palette = create_signal(Palette);
let surface_color = create_signal(Color::rgb(0.1, 0.1, 0.15));
let theme = Theme::default();
container()
    .background(theme.surface)                       // a constant
    .background(surface_color)                       // a signal
    .background(move || if hot.get() { HOT } else { COOL })   // a closure
    .gradient(move || palette.get().header())
    .backdrop_blur(move || if frosted.get() { 24.0 } else { 0.0 })
    .overflow(move || if collapsed.get() { Overflow::Hidden } else { Overflow::Visible })
;
}

A blur radius of 0.0 is “no blur”, on a container and on a text alike, which is what lets one signal switch the effect on and off rather than forcing the caller to rebuild the widget in a Rust branch. That holds wherever the surface is one of the sources, which is everything by default. A container that restricts itself to the compositor has no radius of its own — the protocol carries none — so it asks on its sources alone, and empties them to switch off.

What is not reactive is structural: .layout(..), the axis a .scroll(..) is built with, .control(), and the motion a value is declared with — .transition(..) and .timeline(..). These say what kind of thing the container is; change one and you are describing a different widget, so declare it in the closure that builds the widget instead. The value a motion decorates is as reactive as any other.

Complete Example

extern crate guido;
use guido::prelude::*;
fn styled_card(title: &str, content: &str) -> Container {
    container()
        // Size and padding
        .width(300.0)
        .padding(20.0)

        // Background and corners — the background eases, so the hover below
        // arrives over 200ms rather than in one frame
        .background(Color::rgb(0.15, 0.15, 0.2).transition(200.0))
        .corners(Corners::squircle(12.0))
        

        // Border
        .border(1.0, Color::rgb(0.25, 0.25, 0.3))

        // Shadow
        .shadow(Shadow::simple((0.0, 4.0), 8.0, Color::rgba(0.0, 0.0, 0.0, 0.2)))

        // Layout
        .layout(Flex::column().spacing(12.0))

        // State layers — they supply values; the motion was declared above
        .when_hovered(|s| {
            s.lighter(0.05)
                .shadow(Shadow::simple((0.0, 6.0), 12.0, Color::rgba(0.0, 0.0, 0.0, 0.24)))
        })

        // Children
        .children([
            container().child(text(title).font_size(18.0).color(Color::WHITE)),
            container().child(text(content).font_size(14.0).color(Color::rgb(0.7, 0.7, 0.75))),
        ])
}
fn main() {}

Colors

Guido provides a simple color system for styling widgets.

Creating Colors

RGB (0.0-1.0 range)

extern crate guido;
use guido::prelude::*;
fn main() {
Color::rgb(0.2, 0.4, 0.8)  // R, G, B values from 0.0 to 1.0
;
}

RGBA with Alpha

extern crate guido;
use guido::prelude::*;
fn main() {
Color::rgba(0.2, 0.4, 0.8, 0.5)  // 50% transparent
;
}

From 8-bit Values (0-255)

extern crate guido;
use guido::prelude::*;
fn main() {
Color::from_rgb8(51, 102, 204);       // Same as Color::rgb(0.2, 0.4, 0.8)
Color::from_rgba8(51, 102, 204, 128) // With alpha
;
}

From Hex

extern crate guido;
use guido::prelude::*;
fn main() {
Color::from_hex(0x3366CC)  // Hex RGB value
;
}

Predefined Colors

extern crate guido;
use guido::prelude::*;
fn main() {
Color::WHITE;
Color::BLACK;
Color::RED;
Color::GREEN;
Color::BLUE;
Color::YELLOW;
Color::CYAN;
Color::MAGENTA;
Color::GRAY;
Color::TRANSPARENT
;
}

Color Operations

Lightening and Darkening

extern crate guido;
use guido::prelude::*;
fn main() {
let color = Color::rgb(0.2, 0.4, 0.8);
let lighter = color.lighter(0.1);  // 10% toward white
let darker = color.darker(0.2);    // 20% toward black
;
}

Mixing

Linear interpolation between two colors:

extern crate guido;
use guido::prelude::*;
fn main() {
let color_a = Color::rgb(0.9, 0.2, 0.2);
let color_b = Color::rgb(0.2, 0.2, 0.9);
let blend = color_a.mix(color_b, 0.5);  // 50/50 blend
let mostly_a = color_a.mix(color_b, 0.2);  // 80% A, 20% B
;
}

Invert

extern crate guido;
use guido::prelude::*;
fn main() {
let color = Color::rgb(0.2, 0.4, 0.8);
let inverted = color.invert();  // Flip RGB channels
;
}

Grayscale

Convert to perceptual grayscale using Rec. 709 luminance weights:

extern crate guido;
use guido::prelude::*;
fn main() {
let color = Color::rgb(0.2, 0.4, 0.8);
let gray = color.grayscale();
;
}

Luminance

Get the perceived brightness (0.0 = black, 1.0 = white):

extern crate guido;
use guido::prelude::*;
fn main() {
let color = Color::rgb(0.2, 0.4, 0.8);
let brightness = color.luminance();
;
}

Alpha Manipulation

extern crate guido;
use guido::prelude::*;
fn main() {
let color = Color::rgb(0.2, 0.4, 0.8);
let semi = color.with_alpha(0.5);      // Set alpha to 0.5
let faded = color.scale_alpha(0.5);    // Halve the current alpha
;
}

Convert to 8-bit

extern crate guido;
use guido::prelude::*;
fn main() {
let color = Color::rgb(0.2, 0.4, 0.8);
let (r, g, b, a) = color.to_rgba8();  // Each 0-255
;
}

Using Colors with State Layers

Colors integrate with the state layer API for hover effects:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_hovered(|s| s.lighter(0.1))   // Lighten on hover
    .when_pressed(|s| s.darker(0.1))  // Darken on press
;
}

Or use explicit colors:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8))
    .when_hovered(|s| s.background(Color::rgb(0.4, 0.6, 0.9)))
    .when_pressed(|s| s.background(Color::rgb(0.2, 0.4, 0.7)))
;
}

Reactive Colors

Colors can be reactive using signals:

extern crate guido;
use guido::prelude::*;
fn main() {
let bg_color = create_signal(Color::rgb(0.2, 0.2, 0.3));

container().background(bg_color)
;
}

Or closures:

extern crate guido;
use guido::prelude::*;
fn main() {
let is_active = create_signal(false);

container().background(move || {
    if is_active.get() {
        Color::rgb(0.3, 0.6, 0.4)  // Green when active
    } else {
        Color::rgb(0.2, 0.2, 0.3)  // Gray when inactive
    }
})
;
}

Color Tips

Dark Theme Palette

For dark UIs, use low-saturation colors with subtle variation:

extern crate guido;
use guido::prelude::*;
fn main() {
let background = Color::rgb(0.08, 0.08, 0.12);  // Near black
let surface = Color::rgb(0.12, 0.12, 0.16);     // Slightly lighter
let primary = Color::rgb(0.3, 0.5, 0.8);        // Accent blue
let text = Color::rgb(0.9, 0.9, 0.95);          // Near white
let text_secondary = Color::rgb(0.6, 0.6, 0.7); // Muted text
;
}

Hover States

For hover states, lightening by 5-15% works well:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
// Subtle hover
.when_hovered(|s| s.lighter(0.05))

// Noticeable hover
.when_hovered(|s| s.lighter(0.1))

// Strong hover
.when_hovered(|s| s.lighter(0.15))
;
}

Transparency

Use alpha for overlays and effects:

extern crate guido;
use guido::prelude::*;
fn main() {
// Semi-transparent overlay
let overlay = Color::rgba(0.0, 0.0, 0.0, 0.5);

// Or use with_alpha on an existing color
let overlay = Color::BLACK.with_alpha(0.5);

// Ripple color (40% opacity white)
let ripple = Color::WHITE.with_alpha(0.4);
;
}

Borders & Corners

Guido renders crisp, anti-aliased borders using SDF (Signed Distance Field) techniques.

Basic Border

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(2.0, Color::WHITE)  // 2px white border
;
}

A border is always both halves at once — everywhere. A width with no colour and a colour with no width are the same thing, which is no border, so there is nothing for a half-declaration to mean, and no way to write one. Each half takes a signal of its own, which covers anything that has to change:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let failed = create_signal(false);
let theme = Theme::default();
container().border(1.5, move || if failed.get() { theme.danger } else { theme.line })
;
}

A border takes room inside the box, as CSS’s box-sizing: border-box does: the children are laid out inside the border and the padding, and a scroller’s viewport and extents are measured inside its border. Changing the width, from a signal, a transition or a state layer, relays the container out and moves what it holds by the difference.

A state layer says it the same way, and replaces the whole border:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
container()
    .border(1.5, theme.line)
    .when_focused(|s| s.border(1.5, theme.accent))
;
}

Changing only the colour, as here, moves nothing. A ring that thickens or appears on focus or hover is an outline instead.

Outlines

An outline is a ring drawn outside the box — width wide, offset out from its edge — above everything the box holds, following its corners: CSS’s outline. It takes no room, so showing, hiding or animating one repaints and never relays anything out. That is what a focus or hover ring wants:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
container()
    .corners(8.0)
    .border(1.0, theme.line)
    .outline(2.0, Color::TRANSPARENT.transition(120.0), 2.0)
    .when_focused(|s| s.outline(2.0, theme.accent, 2.0))
;
}

A rounded corner of the ring is the box’s radius grown by offset + width, and a square one stays square. A negative offset draws the ring inside the box, over its edge. All three halves are declared together, on the container and in a state layer alike, and each carries its own timing.

The ring is painting outside the box, so it counts in the reach a parent culls and damages by, as a shadow does; it is cut by the clips above the container, not by a ripple’s.

Corner Radius

Uniform Radius

extern crate guido;
use guido::prelude::*;
fn main() {
container().corners(8.0)  // 8px radius on all corners
;
}

Corner Curvature (Superellipse)

Control the shape of corners using CSS K-values. This determines how the corner curves from the edge to the arc.

Squircle (K=2)

iOS-style smooth corners. The curve starts further from the corner for a smoother transition.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(Corners::squircle(12.0))
    
;
}

Circle (K=1)

Standard circular corners. This is the default.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(12.0)  // Default is circular
;
}

Bevel (K=0)

Diagonal cut corners. Creates a chamfered look.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(Corners::bevel(12.0))
    
;
}

Scoop (K=-1)

Concave/inward corners. Creates a scooped appearance.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(Corners::scoop(12.0))
    
;
}

Custom Curvature

For values between the presets:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(Corners::superellipse(12.0, 1.5))
      // Between circle and squircle
;
}

Curvature Reference

StyleK ValueDescription
Squircle2.0Smooth, iOS-style
Circle1.0Standard rounded (default)
Bevel0.0Diagonal/chamfered
Scoop-1.0Concave inward

Animated Borders

A border is declared as a pair and each half carries its own timing, so the width and the colour can move on curves of their own. A width that moves lays the container out on every frame, and the content follows it; a hover or press ring that should leave the content still is an outline, animated the same way:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(1.0, Color::rgb(0.3, 0.3, 0.4).transition(150.0))
    .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)
    .when_hovered(|s| s
        .border(1.0, Color::rgb(0.5, 0.5, 0.6))
        .outline(2.0, Color::rgb(0.5, 0.5, 0.6), 2.0))
    .when_pressed(|s| s
        .border(1.0, Color::rgb(0.7, 0.7, 0.8))
        .outline(3.0, Color::rgb(0.7, 0.7, 0.8), 2.0))
;
}

Borders with Different Curvatures

Borders respect corner curvature:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(2.0, Color::rgb(0.5, 0.3, 0.7))
    .corners(Corners::squircle(12.0))
      // Border follows squircle shape
;
}

Borders with Gradients

Borders work with gradient backgrounds:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .gradient(LinearGradient::horizontal(
        Color::rgb(0.3, 0.1, 0.4),
        Color::rgb(0.1, 0.3, 0.5),
    ))
    .corners(8.0)
    .border(2.0, Color::rgba(1.0, 1.0, 1.0, 0.3))  // Semi-transparent white
;
}

Complete Example

extern crate guido;
use guido::prelude::*;
fn card_with_border() -> Container {
    container()
        .padding(16.0)
        .background(Color::rgb(0.12, 0.12, 0.16))
        .corners(Corners::squircle(12.0))
        
        .border(1.0, Color::rgb(0.2, 0.2, 0.25).transition(150.0))
        .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)
        .when_hovered(|s| s
            .border(1.0, Color::rgb(0.4, 0.6, 0.9))
            .outline(2.0, Color::rgb(0.4, 0.6, 0.9), 2.0)
            .lighter(0.03)
        )
        .child(container().child(text("Hover to see the ring").color(Color::WHITE)))
}
fn main() {}

Shadows

A shadow is four numbers: an offset, a blur, a spread and a colour. Shadow carries all four, and .shadow(..) is the only way to ask for one — or for several, and for one cast inside the box rather than outside it.

extern crate guido;
use guido::prelude::*;
fn main() {
container().shadow(Shadow::new((0.0, 4.0), 8.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.2)));

// `simple` leaves the spread at zero, which is what most shadows want.
container().shadow(Shadow::simple((0.0, 2.0), 4.0, Color::rgba(0.0, 0.0, 0.0, 0.16)));

// And `none` is a shadow that draws nothing — the resting value to animate out of.
container().shadow(Shadow::none())
;
}

The offset moves the shadow, and it moves in both axes: (12.0, 0.0) throws it to the right, (0.0, -6.0) throws it upward. Blur is the softness of the edge. Spread grows the shadow in every direction before it is blurred. The colour is a colour, alpha included — a shadow does not have to be black.

A shadow is drawn outside the box and never under it, as a browser draws a box-shadow. With an opaque background that makes no difference; with a translucent one it does: the box shows what is behind it, exactly as it would with no shadow, and a coloured glow lights up the box’s surroundings rather than tinting the box.

Inset shadows

.inset() casts the same shadow inside the box, as CSS’s inset keyword does: over the fill and inside the border, dark at the edge and fading inwards. The offset moves it as it moves an outer one, so (0.0, 3.0) darkens the top edge — a control pressed into the surface — and a spread pushes the edge further in. With no blur, a spread is a hard ring just inside the edge.

extern crate guido;
use guido::prelude::*;
fn main() {
// A glow inside the edge, for something that wants attention.
container().shadow(Shadow::new((0.0, 0.0), 12.0, 0.0, Color::rgba(1.0, 0.3, 0.2, 0.6)).inset());

// Pressed in.
container().shadow(Shadow::new((0.0, 3.0), 4.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.35)).inset());

// A ring, 1.5 px deep.
container().shadow(Shadow::new((0.0, 0.0), 0.0, 1.5, Color::WHITE).inset())
;
}

An outer shadow rides the box’s fill and draws nothing without one, since a shadow with no box above it is a smear. An inset shadow is inside the box, and an empty box can glow.

More than one shadow

.shadow(..) holds a Shadows: a list of up to four, which one Shadow or an array of them converts into. They are drawn as CSS draws a box-shadow list — the first on top — and outer and inset shadows mix in one list. Most design systems spell an elevation as two shadows, a tight one that draws the edge and a soft one that lifts the card:

extern crate guido;
use guido::prelude::*;
const RAISED: Shadows = Shadows::new([
    Shadow::new((0.0, 1.0), 2.0, 0.0, Color::rgba(0.0, 0.0, 0.0, 0.3)),
    Shadow::new((0.0, 4.0), 8.0, 3.0, Color::rgba(0.0, 0.0, 0.0, 0.15)),
]);

fn main() {
container().shadow(RAISED);

// An array is a list too, inset shadows included.
container().shadow([
    Shadow::simple((0.0, 2.0), 4.0, Color::rgba(0.0, 0.0, 0.0, 0.2)),
    Shadow::new((0.0, 0.0), 6.0, 0.0, Color::rgba(1.0, 1.0, 1.0, 0.5)).inset(),
])
;
}

Shadows::new is const and refuses more than Shadows::MAX at compile time. A list animates shadow by shadow: the shorter of two is padded with shadows that draw nothing, so a second shadow arriving on hover fades in.

There is no elevation level

guido ships no elevation and no table behind one. That is deliberate. A single number can only reach one of the four degrees of freedom, and the table that expands it has to choose the other three on your behalf: always straight down, always black, never any spread.

What a design system wants from elevation is coordination — that every card in an application agrees about how high it sits. That is a set of constants, and it belongs to the application. Shadow::new, Shadow::simple and Shadow::none are all const, so a ladder costs nothing:

extern crate guido;
mod elevation {
    use guido::prelude::{Color, Shadow};

    const fn step(offset_y: f32, blur: f32, alpha: f32) -> Shadow {
        Shadow::new((0.0, offset_y), blur, 0.0, Color::rgba(0.0, 0.0, 0.0, alpha))
    }

    pub const FLAT: Shadow = Shadow::none();
    pub const LOW: Shadow = step(1.0, 3.0, 0.12);
    pub const RAISED: Shadow = step(3.0, 6.0, 0.19);
    pub const HIGH: Shadow = step(6.0, 10.0, 0.22);
}
fn main() {}

Then container().shadow(elevation::RAISED) reads the way .elevation(3.0) used to, and the three shadows below it are still reachable.

Shadows in state layers

A shadow is a paint property like any other, so a state layer overrides it:

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
const FLAT: Shadow = Shadow::none();
fn main() {
container()
    .shadow(LOW)
    .when_hovered(|s| s.shadow(RAISED))   // lift on hover
    .when_pressed(|s| s.shadow(FLAT))     // press it back down
;
}

Motion

The timing rides with the declaration, never with the override — the same rule every animatable property follows:

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
    .shadow(LOW.transition(Transition::new(200.0, TimingFunction::EaseOut)))
    .when_hovered(|s| s.shadow(RAISED))
;
}

All four fields interpolate, so a shadow can change colour as it grows, or slide from below a card to beside it. when_hovered(|s| s.shadow(RAISED.transition(..))) does not compile: a state layer supplies a value, and the declaration says how it moves.

Shadows and corners

The shadow follows the shape the container is drawn with, curvature included:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .corners(Corners::squircle(12.0))
    .shadow(Shadow::simple((0.0, 8.0), 16.0, Color::rgba(0.0, 0.0, 0.0, 0.22)))
;
}

A card, end to end

extern crate guido;
use guido::prelude::*;
mod elevation {
    use guido::prelude::{Color, Shadow};
    const fn step(offset_y: f32, blur: f32, alpha: f32) -> Shadow {
        Shadow::new((0.0, offset_y), blur, 0.0, Color::rgba(0.0, 0.0, 0.0, alpha))
    }
    pub const LOW: Shadow = step(1.0, 3.0, 0.12);
    pub const RAISED: Shadow = step(3.0, 6.0, 0.19);
    pub const HIGH: Shadow = step(6.0, 10.0, 0.22);
}
fn card(title: &str, body: &str) -> Container {
    container()
        .width(200.0)
        .padding(20.0)
        .background(Color::rgb(0.15, 0.15, 0.2).transition(150.0))
        .corners(12.0)
        .shadow(elevation::RAISED.transition(200.0))
        .when_hovered(|s| s.shadow(elevation::HIGH).lighter(0.05))
        .when_pressed(|s| s.shadow(elevation::LOW).darker(0.05))
        .layout(Flex::column().spacing(8.0))
        .children([
            container().child(text(title).font_size(18.0).color(Color::WHITE)),
            container().child(text(body).color(Color::rgb(0.7, 0.7, 0.75))),
        ])
}
fn main() {}

cargo run --example shadow_example shows a ladder beside the three things one number could never say: a shadow thrown sideways, a coloured one, and one with spread.

On dark backgrounds

A black shadow on a dark surface is nearly invisible. Two ways out, and they compose: lighten the surface as it rises, or give the shadow a colour.

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
fn main() {
container()
    .background(Color::rgb(0.12, 0.12, 0.16))
    .shadow(LOW)
    .when_hovered(|s| {
        s.lighter(0.03)
            .shadow(Shadow::simple((0.0, 8.0), 20.0, Color::rgba(0.35, 0.4, 0.9, 0.45)))
    })
;
}

Merged Shapes

A container can draw the backgrounds of its children as one shape. Where two of them come close, a concave neck grows between them; where they overlap, they are one outline with no seam. It is the shape a bubble makes as it slides out from under a pill and drops away.

extern crate guido;
use guido::prelude::*;
fn main() {
let bubble_x = create_signal(0.0f32);
let pill = Color::rgb(0.10, 0.10, 0.12);

container()
    .layout(Flex::row())
    .merge_children(14.0)
    .shadow(Shadow::new((0.0, 10.0), 26.0, 0.0, Color::BLACK.with_alpha(0.5)))
    .child(container().width(160.0).height(36.0).corners(18.0).background(pill))
    .child(
        container()
            .width(34.0)
            .height(34.0)
            .corners(17.0)
            .background(pill)
            .translate(move || (bubble_x.get(), 0.0)),
    )
;
}

The number is a distance in logical pixels. Children further apart than that do not touch each other’s outline at all, so one that moves away splits off by itself. Closer than that, their outlines lean towards each other, and once the gap is half the distance a neck joins them and thickens as they close in. 0.0 is a plain union: overlapping children merge with no seam, and nothing grows between children that do not overlap. The distance takes a signal like any other property.

What merges

Each direct child’s own box: its fill and its border, with its corners and wherever its transform puts it. What the children hold — text, icons, images, nested containers — is drawn over the merged shape as usual.

The children do not need the same colour. Fills blend across a neck, and so do borders: the border runs along the merged outline rather than around each child, its width and colour blending from one child’s to the other’s, and a child without a border fades the border out along the neck.

The merging container’s own border and shadow belong to the merged shape. The border is drawn along its outline in place of the children’s, and every outer shadow is cast by the whole of it, neck included. An inset shadow stays inside the container’s own box, and so does its background, if it has one, drawn underneath as usual. A child’s own shadows are drawn beneath the merged shape, and its inset ones over it, inside the child’s box.

What does not

  • A child whose fill is a gradient. It is drawn on its own, as it would be without merge_children, and a debug build says so once.
  • A child drawn as a layer: one with blur, or one that fades with something drawn over its background — a disabled tab with a label, faded by when_disabled, say. Its background is drawn with the rest of it, blurred or faded as one, and a debug build says so once. A child that fades with nothing over its background is no layer, and stays merged as it fades.
  • Children after the sixteenth with a background, for the same reason and with the same warning: every fragment of the merged shape measures every member, so the cost is kept bounded.
  • Anything below the direct children.

The neck draws but takes no input: each child answers clicks in its own box.

A container that both scrolls and merges merges its scrollbar track as well: the track is one of its children.

Text

The Text widget renders text content with support for reactive updates.

Basic Text

extern crate guido;
use guido::prelude::*;
fn main() {
text("Hello, World!")
;
}

Styling

A text declares how it looks:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
text("Hello").font_size(24.0).color(theme.text)
;
}

The methods — color, font_size, font_family, font_weight, bold, font_style, italic, font_features, mono, line_height, letter_spacing, text_stroke, text_shadow — belong to the two widgets that draw glyphs, Text and TextInput, and are written from one list so the two always offer the same thing.

color and font_size are declarations, so they say how they move as well as what they are:

extern crate guido;
use guido::prelude::*;
fn main() {
let busy = create_signal(false);
text("saving…").color((move || if busy.get() { Color::RED } else { Color::WHITE })
    .transition(200.0))
;
}

A state override supplies a value and never a timing — when_hovered(|s| s.color(..)) takes a colour alone, and a transition there is a compile error rather than something quietly ignored.

Repeating a style

A container declares nothing about the text inside it: it draws a box, and the widget that draws the glyphs is the one that says how they look. For “the same kind of label, many times”, write a function:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
let label = |s: &str| text(s).color(theme.weak).font_size(12.0);

container()
    .layout(Flex::row().spacing(8.0))
    .children([label("one"), label("two"), label("three")])
;
}

The style keeps a name, stays next to the widget that draws it, and costs no extra node.

Properties nothing declares fall back to white, 14 logical pixels, the registered default family, normal weight, upright, and the font’s own features.

A hover that reaches the glyphs

The pointer is over the box, and the colour belongs to the glyphs. Declare each where it happens, and let control() join them — the text resolves its own states from the nearest control above it:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
container()
    .padding(8.0)
    .control()
    .when_hovered(|s| s.lighter(0.1))
    .child(
        text("Label")
            .color(theme.weak)
            .when_hovered(|s| s.color(theme.strong)),
    )
;
}

Legibility over an image

Over a photograph no single text colour works, because the picture is light in some places and dark in others. Two declarations separate the glyphs from whatever is behind them:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    
    // A contour around the glyphs. CSS spells this `-webkit-text-stroke`;
    // CSS `outline` is the contour of a box, which is why the name differs.
    
    // As CSS `text-shadow`: offset x, offset y, blur, colour.
    
    .child(text("09:41").text_shadow(TextShadow::new(0.0, 2.0, 10.0, Color::rgba(0.0, 0.0, 0.0, 0.75))).text_stroke(TextStroke::new(1.5, Color::BLACK)).color(Color::WHITE))
;
}

The shadow is usually the more effective of the two: it darkens the whole neighbourhood the glyph sits in rather than only its edge. Both are drawn under the fill, so a stroke outlines the text instead of eating into it.

Both are approximated by re-drawing the glyphs at offsets, which costs fill rate but no extra rasterization — the glyph atlas is keyed on glyph, size and weight, and takes colour per draw. What decides whether the approximation shows is the spacing between copies: more than a couple of pixels apart and they stop blending, so the square features of a glyph — a colon’s dots, the stem of a 4 — read as separate copies rather than one halo. The shadow therefore fills a disc whose sample count grows with the radius (about a hundred copies at blur 10), and a very wide blur spreads a fixed budget thinner instead of costing more. A thick stroke is the case with a visible ceiling: its corners begin to scallop past a few pixels, where the honest fix is a dilate on an offscreen mask, not more taps.

Neither changes how much room the text takes, so adding a shadow never moves its neighbours.

cargo run --example text_decoration_example shows both against a control.

Frosted glass

The third way to sit a text on a picture is to make the letters a window onto it, blurred:

extern crate guido;
use guido::prelude::*;
fn main() {
text("09:41")
    .font_size(76.0)
    // The tint over the glass; without one the glyphs are the blur alone.
    .color(Color::rgba(1.0, 1.0, 1.0, 0.35))
    .backdrop_blur(16.0)
;
}

This is the same effect a container gets from backdrop_blur, with the shape of the letters in place of the shape of the box — CSS’s backdrop-filter together with background-clip: text. The renderer rasterizes the glyphs into a coverage mask, blurs the region they cover, and composites the result back through the mask; the text is then drawn over its own frost, which is why the colour reads as a tint.

It filters what this surface has already drawn — a wallpaper, a photo, the panel underneath. A container’s blur can also reach what the compositor puts behind the surface, through ext-background-effect-v1; that protocol takes a region, and regions are rectangles, so glyphs cannot be expressed in it.

Three things to know before reaching for it:

  • It is declared on the text. Every text property is declared on a container for the subtree below it; this one cannot, because each frosted text ends the render pass to filter the target. It is asked for one text at a time on purpose.
  • It is not legibility on its own. Frost softens the background where a stroke and a shadow darken it. A stroke composes with it — over frost it is drawn as a true contour, dilated from the same coverage mask and laid outside the letter, which is what the glass needs and what a thick stroke wanted anyway. A shadow does not: it is still copies of the glyphs under the fill, so it covers the letter’s own area as well as its edge, which is invisible under an opaque fill and an opaque letter over frost.
  • A transform carries it along. The coverage is cut in the text’s own space and read back through the transform, so a rotated or scaled text keeps a frost registered with its letters — which is what an animate_transform on a frosted label needs, and what it used to lose for the length of the animation. A transformed text is rasterized finer for it, so a very large one can reach the coverage size ceiling and lose the frost with a warning in the log; it is an effect for a label.
extern crate guido;
use guido::prelude::*;
fn main() {
text("09:41")
    .color(Color::rgba(1.0, 1.0, 1.0, 0.3))
    .backdrop_blur(16.0)
    // A contour, because this text is glass. Elsewhere the same declaration is
    // the cheap approximation, and under an opaque fill the two look alike.
    .text_stroke(TextStroke::new(2.0, Color::BLACK))
;
}

cargo run --example frosted_text puts all of it beside a control.

Styling

Font Size

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Large text").font_size(24.0));
container().child(text("Small text").font_size(12.0))
;
}

Color

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Colored text").color(Color::rgb(0.9, 0.3, 0.3)));
container().child(text("White text").color(Color::WHITE))
;
}

Font Family

Set the font family using predefined families or custom font names:

extern crate guido;
use guido::prelude::*;
fn main() {
// Predefined font families
container().child(text("Sans-serif text").font_family(FontFamily::SansSerif));
container().child(text("Serif text").font_family(FontFamily::Serif));
container().child(text("Monospace text").font_family(FontFamily::Monospace));

// Shorthand for monospace
container().child(text("Code example"));

// Custom font by name (if available on system)
container().child(text("Custom font").font_family(FontFamily::name("Inter")))
;
}

Available font families:

  • FontFamily::SansSerif - Default sans-serif font
  • FontFamily::Serif - Serif font
  • FontFamily::Monospace - Monospace/fixed-width font
  • FontFamily::Cursive - Cursive font
  • FontFamily::Fantasy - Fantasy/decorative font
  • FontFamily::name(&str) - Custom font by name, interned

Font Weight

Set the font weight using predefined constants or numeric values (100-900). A weight the font has no face for is drawn in the nearest one it has — a semibold in a family with only regular and bold comes out bold — never in a different font:

extern crate guido;
use guido::prelude::*;
fn main() {
// Using constants
container().child(text("Thin text").font_weight(FontWeight::THIN));
container().child(text("Light text").font_weight(FontWeight::LIGHT));
container().child(text("Normal text").font_weight(FontWeight::NORMAL));
container().child(text("Medium text").font_weight(FontWeight::MEDIUM));
container().child(text("Semi-bold text").font_weight(FontWeight::SEMI_BOLD));
container().child(text("Bold text").font_weight(FontWeight::BOLD));
container().child(text("Black text").font_weight(FontWeight::BLACK));

// Shorthand for bold
container().child(text("Bold text"));

// Custom numeric weight
container().child(text("Custom weight").font_weight(FontWeight(550)))
;
}

Available weight constants:

  • FontWeight::THIN (100)
  • FontWeight::EXTRA_LIGHT (200)
  • FontWeight::LIGHT (300)
  • FontWeight::NORMAL (400)
  • FontWeight::MEDIUM (500)
  • FontWeight::SEMI_BOLD (600)
  • FontWeight::BOLD (700)
  • FontWeight::EXTRA_BOLD (800)
  • FontWeight::BLACK (900)

Italic

font_style takes FontStyle::Normal, FontStyle::Italic or FontStyle::Oblique, as CSS font-style; italic() is the shorthand. A family with an italic face draws an italic in it, and each line is as tall as that face says. A family with none is drawn in its upright face, slanted by the shaper — a synthetic italic, as a browser draws one:

extern crate guido;
use guido::prelude::*;
fn main() {
let quoting = create_signal(true);
container().child(text("“Quoted”").italic());
container().child(
    text("Lyrics").font_style(move || {
        if quoting.get() { FontStyle::Italic } else { FontStyle::Normal }
    }),
)
;
}

OpenType Features

A font can draw more than its defaults: digits of one width, slashed zeros, small capitals, alternate letter forms. font_features asks for them by their OpenType tags, as CSS font-feature-settings does — enable turns a feature on, disable turns off one the font turns on by itself, and set picks an alternate by number:

extern crate guido;
use guido::prelude::*;
fn main() {
let seconds = create_signal(0u32);
// Tabular figures: every digit one width, so a clock does not shift as it ticks.
let tabular = FontFeatures::new().enable(b"tnum");
container().child(text(move || format!("0:{:02}", seconds.get())).font_features(tabular));

container().child(text("office").font_features(FontFeatures::new().disable(b"liga")));
container().child(text("0O").font_features(FontFeatures::new().enable(b"zero").set(b"ss01", 1)))
;
}

Most interface fonts draw their digits at different widths, which is why a counter, a percentage or a column of version numbers moves sideways as its value changes; tnum is the fix. A feature the font does not have does nothing.

A FontFeatures is a small id: every distinct set is stored once, for the life of the program, so a set is four bytes however many tags it holds. Writing a tag twice keeps the last value. The text is measured with its features, so a box that hugs it grows and shrinks with them, and a TextInput puts its caret between the glyphs they drew. A state that supplies features replaces the declared set rather than adding to it.

Line Height

Each line is as tall as the font says by default: its ascent, descent and line gap, scaled to the size. A bare number is a multiple of the font size, as in CSS; LineHeight::Absolute is logical pixels, which is how a design system writes a type scale such as 14/20:

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Loose notes").line_height(1.5));
container().child(text("Body").font_size(14.0).line_height(LineHeight::Absolute(20.0)))
;
}

A TextInput is one line tall, so a field and a label with the same style have the same height.

Centring on the Capitals

A text’s box is its line box, and the line box holds the face’s whole ascent and descent. Most faces leave more room below their capitals than above them, so a capitalised label centred in a button or a chip sits a little high — next to an icon centred by its own box, enough to read as misaligned.

text_box says which lines of the font the box ends on: the first edge above the first line, the second below the last. Trimmed to the capitals and the baseline, a line of capitals is exactly a capital tall, and centring the box centres what is drawn:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .height(28.0)
    .padding([0.0, 12.0])
    .corners(14.0)
    .background(Color::rgb(0.2, 0.2, 0.3))
    .layout(Flex::row().spacing(6.0).cross_alignment(CrossAlignment::Center))
    .child(container().width(14.0).height(14.0).background(Color::WHITE))
    .child(text("Night light").text_box(TextEdge::Cap, TextEdge::Alphabetic))
;
}

It is CSS’s text-box: trim-both cap alphabetic. The top edge is TextEdge::Text (no trim, the default), TextEdge::Cap or TextEdge::Ex (the top of the lowercase); the bottom is TextEdge::Text or TextEdge::Alphabetic. The cap height and x-height are the face’s own, from its OS/2 table, or the top of its H and its x when it does not declare them.

Only the box moves. The glyphs are drawn where they were, so accents and descenders reach past it, and in a text of several lines only the first line’s top and the last line’s bottom are trimmed. A row aligned with CrossAlignment::Baseline still lines it up on its baseline. Both edges take signals, like every other property.

Letter Spacing

Extra room after every character, in logical pixels — the unit a design token such as a tracking of 1.5 is written in, so it is the same distance at any font size. 0.0 is the default and the font’s own spacing; a negative value draws the letters tighter. Like every other style, it takes a signal as readily as a value, and a state can supply it:

extern crate guido;
use guido::prelude::*;
fn main() {
let wide = create_signal(false);
container().child(text("SECTION").font_size(11.0).letter_spacing(1.5));
container().child(text("Display").font_size(40.0).letter_spacing(-1.0));
container().child(
    text("Hover me")
        .letter_spacing(move || if wide.get() { 4.0 } else { 0.0 })
        .when_hovered(|s| s.letter_spacing(2.0)),
)
;
}

The text is measured with the spacing, so the box it takes grows and shrinks with it, and a text that wraps wraps where the spaced line no longer fits. A TextInput puts its caret, its selection and its clicks where the spaced letters are, and a PasswordInput spaces the dots it draws.

What the spacing is added to is decided by the shaper, cosmic-text: every glyph it puts out gets it, the last one on a line included. Most of the time a glyph is a character, but not always:

  • a ligature — Arabic lam and alef, drawn as one shape — takes one spacing for the two letters;
  • an accent the font can combine with its letter, such as e and a combining acute, becomes one glyph é and takes one; an accent it cannot combine is a glyph of its own, takes a second, and is drawn that far to the right of its letter;
  • joined Arabic letters are pulled apart, and the joins between them are not stretched to cover the gap.

A value that is not a number — a division by a zero somewhere upstream — is treated as no spacing at all.

Text Wrapping

By default, text wraps to fit the available width. Disable wrapping for single-line text:

extern crate guido;
use guido::prelude::*;
fn main() {
text("This text will not wrap").nowrap()
;
}

Text Alignment

align says where each line sits across the text’s box. The box is the text’s own: as wide as its widest line, so a wrapped label lines its lines up against each other, and as wide as the stretch when a layout stretches it.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(160.0)
    .child(text("A label long enough to wrap onto a second line").align(TextAlign::Center))
;
}
  • TextAlign::Start (the default) — where the text’s direction begins: the left for Latin, the right for Arabic or Hebrew
  • TextAlign::Center — each line centred
  • TextAlign::End — where the text’s direction ends
  • TextAlign::Justified — every line but a paragraph’s last stretched to the full width. A justified text that wraps is as wide as the width its container offered, since that is what its lines are stretched across, as a justified paragraph is in CSS; one that fits on its line hugs it

A text whose box fits its only line looks the same whichever alignment it has: there is no room for the line to move into. To centre a short label in a wider space, centre the text in its container, or stretch it across the container and centre its line.

A line wider than its box — an unwrapped text in a narrow container — starts at the start and runs off the end, whatever its alignment, as it does in CSS.

align takes a signal like the other text properties. A change moves the glyphs inside the box, and lays the text out again, since a justified text that wraps is wider than one aligned any other way.

Limiting Lines

A text that has to fit a fixed box — a window title in a bar, a track name in a player — can be held to a number of lines, with the cut marked:

extern crate guido;
use guido::prelude::*;
fn main() {
let title = "a window title far too long for the bar";
text(title).max_lines(1).overflow(TextOverflow::Ellipsis)
;
}

max_lines says how many lines the text may take, and the text is measured as those lines, not as everything it holds. overflow says what marks the cut:

  • TextOverflow::Clip (the default) — the lines past the limit are not drawn
  • TextOverflow::Ellipsis — the last line drawn ends in …
  • TextOverflow::EllipsisStart — the … opens the last line, which keeps the end of the text: a path whose file name is what matters
  • TextOverflow::EllipsisMiddle — the … stands in the middle, keeping both ends

A line break counts toward the limit like a wrapped line does. A text that fits is drawn exactly as it would be with no limit.

An unwrapped text is cut by its box already, so an ellipsis on a nowrap() text needs no limit — each line that runs past the box ends in …:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(120.0)
    .child(text("a long track name").nowrap().overflow(TextOverflow::Ellipsis))
;
}

Beside other children in a row, the text is offered what they leave it, so a title next to an icon ends in … at the icon rather than running past the row:

extern crate guido;
use guido::prelude::*;
fn main() {
let title = "a window title far too long for the bar";
container()
    .width(200.0)
    .layout(Flex::row().spacing(8.0))
    .child(container().width(16.0).height(16.0))
    .child(text(title).nowrap().overflow(TextOverflow::Ellipsis))
;
}

A row that does not fit takes the room from the children that can give it up — see Children That Do Not Fit. A text with an ellipsis can go down to the … alone, one that wraps to its widest word, and one that does neither cannot shrink at all.

Both are reactive like every other text property: max_lines takes an Option<u32>, so a signal can lift the limit with None.

Reactive Text

Text content can update based on signals:

extern crate guido;
use guido::prelude::*;
fn main() {
let message = create_signal("Hello".to_string());

text(move || message.get())
;
}

Formatted Reactive Text

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);

text(move || format!("Count: {}", count.get()))
;
}

Combining Styles

Chain style methods:

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Styled Text").font_family(FontFamily::Serif).font_size(18.0).color(Color::WHITE).nowrap())
;
}

Text in Containers

Text is typically placed inside containers for padding and backgrounds:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(12.0)
    .background(Color::rgb(0.2, 0.2, 0.3))
    .corners(4.0)
    .child(
        container().child(text("Button Label").font_size(14.0).color(Color::WHITE))
    )
;
}

Typography Patterns

Headings

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Page Title").font_size(24.0).color(Color::WHITE))
;
}

Body Text

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Regular content text").font_size(14.0).color(Color::rgb(0.8, 0.8, 0.85)))
;
}

Secondary Text

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("Subtitle or caption").font_size(12.0).color(Color::rgb(0.6, 0.6, 0.65)))
;
}

Code/Monospace Text

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("let x = 42;").font_size(13.0).color(Color::rgb(0.6, 0.9, 0.6)))
;
}

Labels

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(text("LABEL").font_size(11.0).color(Color::rgb(0.5, 0.5, 0.55)))
;
}

App-Level Default Font

Set a default font family for the entire application:

extern crate guido;
use guido::prelude::*;
fn view() -> Container { container() }
fn main() {
let config = SurfaceConfig::new();
App::new()
    .default_font_family(FontFamily::name("Inter"))
    .run(|app| {
        app.add_surface(config, || view());
    });
;
}

All text widgets will use this font family unless they explicitly override it.

Complete Example

extern crate guido;
use guido::prelude::*;
fn article_card(title: &str, author: &str, preview: &str) -> Container {
    container()
        .padding(16.0)
        .background(Color::rgb(0.12, 0.12, 0.16))
        .corners(8.0)
        .layout(Flex::column().spacing(8.0))
        .child(
            // Title - bold serif
            container().child(text(title).font_family(FontFamily::Serif).font_size(18.0).color(Color::WHITE))
        )
        .child(
            // Author - light weight
            container().child(text(format!("By {}", author)).font_weight(FontWeight::LIGHT).font_size(12.0).color(Color::rgb(0.5, 0.5, 0.6)))
        )
        .child(
            // Preview text
            container().child(text(preview).font_size(14.0).color(Color::rgb(0.7, 0.7, 0.75)))
        )
}
fn main() {}

API Reference

All properties accept static values, signals, or closures.

// not compiled: a signature listing — these declarations have no bodies.
text(content: impl IntoSignal<String, M>) -> Text

impl Text {
    pub fn font_size<M>(self, size: impl IntoSignal<f32, M>) -> Self;  // numbers work: .font_size(16) or .font_size(16.5)
    pub fn color<M>(self, color: impl IntoSignal<Color, M>) -> Self;
    pub fn font_family<M>(self, family: impl IntoSignal<FontFamily, M>) -> Self;
    pub fn font_weight<M>(self, weight: impl IntoSignal<FontWeight, M>) -> Self;
    pub fn bold(self) -> Self;      // Shorthand for FontWeight::BOLD
    pub fn font_style<M>(self, style: impl IntoSignal<FontStyle, M>) -> Self;
    pub fn italic(self) -> Self;    // Shorthand for FontStyle::Italic
    pub fn font_features<M>(self, features: impl IntoSignal<FontFeatures, M>) -> Self;
    pub fn mono(self) -> Self;      // Shorthand for FontFamily::Monospace
    pub fn letter_spacing<M>(self, spacing: impl IntoSignal<f32, M>) -> Self;  // logical pixels
    pub fn wrap<M>(self, wrap: impl IntoSignal<bool, M>) -> Self;
    pub fn nowrap(self) -> Self;    // Shorthand for wrap(false)
    pub fn align<M>(self, align: impl IntoSignal<TextAlign, M>) -> Self;
    pub fn max_lines<M>(self, lines: impl IntoSignal<Option<u32>, M>) -> Self;  // .max_lines(2)
    pub fn overflow<M>(self, overflow: impl IntoSignal<TextOverflow, M>) -> Self;
    pub fn text_box<M1, M2>(self, top: impl IntoSignal<TextEdge, M1>, bottom: impl IntoSignal<TextEdge, M2>) -> Self;
}

Text Input

The TextInput widget provides single-line text editing with support for selection, clipboard operations and undo/redo. Its sibling, password_input, is the same field for a password: masked, and holding the text where it cannot be copied, swapped or dumped.

Basic Usage

extern crate guido;
use guido::prelude::*;
fn main() {
let username = create_signal(String::new());

text_input(username)
;
}

TextInput uses two-way binding with signals:

  • When the user types, the signal is automatically updated
  • When the signal changes programmatically, the input reflects the new value

No manual synchronization is needed - just pass a Signal<String> and the binding works automatically.

Styling

Text Color

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container().child(text_input(value).color(Color::WHITE))
;
}

Cursor Color

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value).cursor_color(Color::rgb(0.4, 0.8, 1.0))
;
}

Selection Color

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value).selection_color(Color::rgba(0.4, 0.6, 1.0, 0.4))
;
}

Font Size

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container().child(text_input(value).font_size(16.0))
;
}

Font Family

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
// Predefined families
container().child(text_input(value).font_family(FontFamily::Monospace));

// Shorthand for monospace
container().child(text_input(value));

// Custom font
container().child(text_input(value).font_family(FontFamily::name("JetBrains Mono")))
;
}

Font Weight

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
// Using constants
container().child(text_input(value).font_weight(FontWeight::BOLD));

// Shorthand for bold
container().child(text_input(value))
;
}

Italic and OpenType Features

As on a text: italic(), font_style and font_features. A field of numbers with tabular figures keeps its caret between digits of one width:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container().child(text_input(value).font_features(FontFeatures::new().enable(b"tnum")))
;
}

Letter Spacing

Logical pixels after every character, as on a text. The caret, the selection and a click all land where the spaced letters are, and a password field spaces the dots it draws:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
let password = create_password();
container().child(text_input(value).letter_spacing(1.0));
container().child(password_input(password).letter_spacing(4.0))
;
}

Password Input

A password gets its own field, bound to a Password rather than a RwSignal<String>:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();

password_input(password)
    .placeholder("Password")
    .on_submit(move || {
        // Moved out, and the field is empty again. Hand it to PAM;
        // dropping it wipes it.
        let secret: Secret = password.take();
        let _ = secret;
    })
;
}

A String is the wrong home for a password: every edit and every get of one leaves a copy on the heap that nothing wipes, its pages can be swapped to disk, and a crash writes it into a core dump. So the field keeps its text in a Secret — pages of its own, locked in memory, left out of core dumps, and zeroed whenever they are given back — and edits it there, in place. It keeps no undo history, since every entry would be a copy; nothing can be copied or cut out of it; and Ctrl+Left/Right goes to the edge rather than revealing where the words end, as a double click selects all of it rather than a word. Pasting in still works, for password managers.

Password is a signal in everything but the copy. Reads subscribe, so state that follows the field is an ordinary closure, and the application reaches the text whenever it needs to — a Sign In button, a form’s Save, a clear after an idle timeout — by borrowing it:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
let sign_in = |_: &str| {};
container()
    .enabled(move || !password.is_empty())
    .on_click(move || password.with(|secret| sign_in(secret.expose())))
;
password.set(Secret::from(String::from("from the keyring"))); // pre-fill; the String is wiped
password.clear();                                             // wipe in place
}

Submitting is the application’s to spell, and Enter only says it happened. take moves the secret out and empties the field. To keep the dots on screen while the password is checked, hand over a duplicate instead — a second Secret in pages of its own, as protected as the first — and clear the field when the answer arrives. Enter and a submit button can then share one function:

extern crate guido;
use guido::prelude::*;
fn check(_: Secret) {}
fn main() {
let password = create_password();
let submit = move || check(password.with_untracked(Secret::duplicate));

container()
    .child(password_input(password).on_submit(submit))
    .child(container().on_click(submit).child(text("→")))
;
}

There is no get: whatever the application copies out of expose() is the application’s to wipe.

By default, characters are masked with •. Customize the mask character:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password).mask_char('*')
;
}

Showing the password

An eye button beside the field is a signal the field is told:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
let shown = create_signal(false);

container()
    .layout(Flex::row().spacing(8.0))
    .child(password_input(password).reveal(shown))
    .child(
        container()
            .on_click(move || shown.update(|s| *s = !*s))
            .child(text(move || if shown.get() { "Hide" } else { "Show" }.to_string())),
    )
;
}

Declared rather than rebuilt, so the caret, the selection and the focus stay where they were. Everything else stays as it was: nothing can be copied or cut out, and there is no undo.

The catch: a shown password is drawn like any other text, and drawing text copies it. Shaping makes temporary copies of the text and frees them without wiping them, and the measurement and render caches keep what they shaped. That is ordinary memory, outside what the field controls, so while the password is shown — and afterwards, until those caches let go of it — it can be found in the process’s memory the way any String can. The Secret itself is untouched, and the field’s own copy of what it drew is wiped when it changes. GTK 4’s peek icon makes the same trade. If the threat you are guarding against is a core dump or a memory read, leave reveal off.

Hardening the process

The field keeps the password out of places guido controls. Some copies are outside that, and a lock screen should know about them:

  • Each key press. smithay-client-toolkit, which guido reads the keyboard through, hands every key over as a one-character String and frees it unwiped. That leaves single characters, one per allocation — not the password, but pieces of it.
  • The clipboard. guido reads the clipboard only when something pastes, and wipes what it read. The password manager’s own copy is its business.
  • A revealed password, as above.

A lock screen can close the core-dump route to all of it with one call at startup, before the password is ever typed: a process that is not dumpable leaves no core dump for coredumpctl to read back, and cannot be attached to with ptrace by other processes of the same user.

// not compiled: uses the `libc` crate, which the book's samples do not link.
// SAFETY: a plain prctl with no pointers.
unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0) };

Do not reach for mlockall to keep the process out of swap. It counts against the locked-memory limit, which is usually a few megabytes, and a GPU-rendered application maps far more than that — with MCL_FUTURE, later allocations start failing. The Secret already locks the pages that hold the password, which is what swaylock does too; for the rest, swap is the system’s to encrypt.

Everything else here — placeholder, caret, pointer shape, colours, focus, readonly — is the same on both fields.

Initial Focus

A screen that exists to be typed into should not make the user click first — and on a surface with no pointer there may be nothing to click with:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
    .autofocus()
;
}

The input takes the keyboard at its first layout, unless a widget on its own surface already holds focus. That condition is what makes it safe: an input appearing later never pulls focus off the one being typed into beside it, and two autofocusing inputs do not fight (the first laid out wins).

When the focus is on another surface, the input waits for the keyboard. A popup opened from a focused field has its field focused once the keyboard reaches the popup, and the field below has the focus back when the popup closes. The same view built once per output — a lock screen with two monitors — ends up with the field on the monitor holding the keyboard focused, and one built for a monitor plugged in mid-lock takes nothing from the password being typed.

The offer is made once. A relayout does not ask again, so a resize or a scale change cannot drag focus back from wherever the user has since put it.

Losing Focus

The surface losing the keyboard does not take the focus off the field. Switching to another window, or opening a popup that grabs the keyboard, leaves the field focused with no caret, and when the keyboard comes back the caret does too and the next key is typed where it was going. A surface remembers the widget it had focused when the focus moved to another surface — the field inside that popup, say — and has it back when the keyboard returns, if it is still there.

A press that no widget claimed takes the keyboard off the field. Clicking the surface behind it, or a decorative panel, or empty space in a row: nothing there wanted the press, so there is nothing left for the focus to belong to, and the caret stops.

A press that something did claim leaves the focus alone. A container with an on_click, on_mouse_down or on_mouse_up claims the left presses that land on it, so a toolbar button that acts on the field it sits beside does not blur it, and neither does the field’s own scrollbar. The other two buttons are claimed by the handlers that name them, on_right_click and on_middle_click: a container with only an on_click does not claim a right press, and a right press it did not claim is a press on nothing like any other.

A press inside the focused field is always the field’s, whichever button it was — and it keeps the keyboard without consuming the press, so a row wrapping the field still gets its own click or context menu.

The box drawn around a field is the third case, and it is the one worth knowing: a container that declares when_focused and currently holds that focus keeps that focus against a press inside itself. Clicking its padding, its border, the space beside the caret, is clicking the field it draws.

The press still travels. The box keeps the keyboard and consumes nothing, so a clickable ancestor wrapping the field is clicked as it would be anywhere else.

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container()
    .padding(8.0)
    .border(1.0, Color::rgb(0.3, 0.3, 0.4))
    // Lights up for the focus, and keeps it: a click on the padding is a
    // click on the field. A ring outside the box, so the field does not move.
    .when_focused(|s| s.outline(2.0, Color::rgb(0.4, 0.8, 1.0), 2.0))
    .child(text_input(value))
;
}

The focus also leaves when the whole surface does — the compositor moving the keyboard elsewhere — and when the field is taken out of the tree.

Placeholder

What the field says while it is empty:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
    .placeholder("Password")
;
}

It is a label, not a value: never masked, so a password field shows the word rather than eight bullets, and it stands in place of the text rather than beside it — there is nothing to overlap, because it is only drawn when the value is empty.

Reactive, so a prompt that changes changes the empty field with it:

extern crate guido;
use guido::prelude::*;
fn main() {
let answer = create_signal(String::new());
let prompt = create_signal(String::from("Type here"));
text_input(answer).placeholder(move || prompt.get())
;
}

The colour is the field’s own text colour at reduced alpha — a placeholder is the same text, quieter. Declare placeholder_color on the field itself: only a field draws a placeholder, so only a field says what colour it is.

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
let value = create_signal(String::new());
container()
    .child(text_input(value).placeholder_color(theme.text_weak).color(theme.text).placeholder("Search"))
;
}

No Caret

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
    .no_caret()
;
}

Keeps the focus and everything it carries — click to position, drag to select, the keyboard — and draws no caret. For a field where the caret says nothing: a masked one, where every character looks the same and you are always at the end. swaylock draws none for that reason.

It is also the cheapest field there is. A blinking caret is the one thing a still screen redraws on its own, twice a second, for as long as it is focused; without one an idle surface asks the loop for nothing at all.

Pointer Shape

A field shows the I-beam under the pointer unless it says otherwise, and it takes the same cursor a container does — a value, a closure or a signal:

extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
    .no_caret()
    .cursor(CursorIcon::Hidden)
;
}

A masked field with no caret has nothing a click could place, so on a lock screen whose root hides the pointer the I-beam would promise editing that is not there. The field is the innermost widget under the pointer, so a shape declared around it never reaches it: this is the one place to say it.

Callbacks

On Change

Called whenever the text content changes:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value)
    .on_change(|new_text| {
        println!("Text changed: {}", new_text);
    })
;
}

On Submit

Called when the user presses Enter:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value)
    .on_submit(|text| {
        println!("Submitted: {}", text);
    })
;
}

Keyboard Shortcuts

The TextInput widget supports standard text editing shortcuts:

ShortcutAction
Ctrl+ASelect all
Ctrl+CCopy selection (not in a password field)
Ctrl+XCut selection (not in a password field)
Ctrl+VPaste
Ctrl+ZUndo (not in a password field)
Ctrl+Shift+Z or Ctrl+YRedo (not in a password field)
Left/RightMove cursor
Ctrl+Left/RightMove to the start or the end of a word (to the edge in a password field)
Shift+Left/RightExtend selection
Home/EndMove to start/end
BackspaceDelete before cursor
DeleteDelete after cursor

A character here is what is drawn as one: a letter and its combining accent, or an emoji made of several code points, such as a flag. The arrows step over it and both deletes take all of it. A password field steps one code point at a time, because its mask draws one bullet for each.

Styling with Container

TextInput handles text editing but not visual styling like backgrounds and borders. Wrap it in a Container for full styling:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container()
    .padding([8.0, 12.0])
    .background(Color::rgb(0.15, 0.15, 0.2))
    .border(1.0, Color::rgb(0.3, 0.3, 0.4))
    .corners(4.0)
    .child(
        container().child(text_input(value).font_size(14.0).color(Color::WHITE))
    )
;
}

With Focus State

Add visual feedback when the input is focused:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container()
    .padding([8.0, 12.0])
    .background(Color::rgb(0.15, 0.15, 0.2))
    .border(1.0, Color::rgb(0.3, 0.3, 0.4))
    .corners(4.0)
    .when_focused(|s| s.border(1.0, Color::rgb(0.4, 0.6, 1.0)))
    .child(
        container().child(text_input(value).color(Color::WHITE))
    )
;
}

Complete Example

A login form with username and password fields:

extern crate guido;
use guido::prelude::*;
fn login_form() -> Container {
    let username = create_signal(String::new());
    let password = create_password();

    container()
        .padding(24.0)
        .background(Color::rgb(0.1, 0.1, 0.15))
        .corners(12.0)
        .layout(Flex::column().spacing(16.0))
        .children([
            // Username field
            container()
                .layout(Flex::column().spacing(4.0))
                .children([
                    container().child(text("Username").font_size(12.0).color(Color::rgb(0.6, 0.6, 0.7))),
                    container()
                        .padding([8.0, 12.0])
                        .background(Color::rgb(0.15, 0.15, 0.2))
                        .border(1.0, Color::rgb(0.3, 0.3, 0.4))
                        .corners(4.0)
                        .when_focused(|s| s.border(1.0, Color::rgb(0.4, 0.6, 1.0)))
                        .child(
                            container().child(text_input(username).font_size(14.0).color(Color::WHITE))
                        ),
                ]),
            // Password field
            container()
                .layout(Flex::column().spacing(4.0))
                .children([
                    container().child(text("Password").font_size(12.0).color(Color::rgb(0.6, 0.6, 0.7))),
                    container()
                        .padding([8.0, 12.0])
                        .background(Color::rgb(0.15, 0.15, 0.2))
                        .border(1.0, Color::rgb(0.3, 0.3, 0.4))
                        .corners(4.0)
                        .when_focused(|s| s.border(1.0, Color::rgb(0.4, 0.6, 1.0)))
                        .child(
                            container().child(password_input(password).font_size(14.0).color(Color::WHITE))
                        ),
                ]),
            // Submit button
            container()
                .padding([10.0, 16.0])
                .background(Color::rgb(0.3, 0.5, 0.9))
                .corners(6.0)
                .when_hovered(|s| s.lighter(0.1))
                .when_pressed(|s| s.darker(0.1))
                .on_click(move || {
                    password.with(|secret| {
                        println!("Login: {} / {} characters", username.get(), secret.expose().chars().count());
                    });
                })
                .child(
                    container().child(text("Sign In").font_size(14.0).color(Color::WHITE))
                ),
        ])
}
fn main() {}

Features

  • Selection: Click and drag to select text, or use Shift+Arrow keys. A double click selects the word under the pointer, and dragging after it extends the selection a word at a time; a triple click selects everything. A word is what Unicode’s word segmentation (UAX #29) makes one, as in GTK, Qt and the browsers: a path or an address divides at its slashes and its @, while a full stop or an apostrophe between two letters, as in example.com or don’t, stays inside the word. A double click on spaces or punctuation selects only those. Ctrl+Left/Right stops at the same ends of words, stepping over the spaces and punctuation between them.
  • Clipboard: Full copy/cut/paste support via Ctrl+C/X/V. Another application’s copy is read only when you paste, off the UI thread, so a paste never blocks it: the text appears once that application answers. The field is one line, so a pasted line break is removed, as a browser’s text input removes it: a line copied from a terminal with its newline arrives as the line alone, and two lines arrive joined
  • Primary selection: selecting text with the mouse sets the primary selection (paste it elsewhere with middle click); middle-clicking the input pastes the primary selection at the click position
  • Undo/Redo: History with intelligent coalescing of rapid edits
  • Scrolling: Long text scrolls horizontally to keep cursor visible
  • Cursor Blinking: Standard blinking cursor when focused. It asks the loop to wake at its next toggle rather than animating, so a focused field costs two wakeups a second instead of a frame at the compositor’s rate; no_caret() drops it entirely and costs nothing
  • Key Repeat: Hold keys for continuous input, at the rate and delay the compositor reports — the same settings every other application on the session obeys

API Reference

// not compiled: a signature listing — these declarations have no bodies.
text_input(signal: RwSignal<String>) -> TextInput
password_input(password: Password) -> PasswordInput   // TextInput<Masked>

// Both fields
impl<C> TextInput<C> {
    pub fn autofocus(self) -> Self;
    pub fn caret<M>(self, caret: impl IntoSignal<bool, M>) -> Self;
    pub fn no_caret(self) -> Self;  // Shorthand for caret(false)
    pub fn cursor<M>(self, cursor: impl IntoSignal<CursorIcon, M>) -> Self;  // Text by default
    pub fn placeholder<M>(self, text: impl IntoSignal<String, M>) -> Self;
    pub fn readonly<M>(self, readonly: impl IntoSignal<bool, M>) -> Self;
}

impl TextInput {
    pub fn on_change<F: Fn(&str) + 'static>(self, callback: F) -> Self;
    pub fn on_submit<F: Fn(&str) + 'static>(self, callback: F) -> Self;
}

impl PasswordInput {
    pub fn mask_char<M>(self, c: impl IntoSignal<char, M>) -> Self;
    pub fn reveal<M>(self, reveal: impl IntoSignal<bool, M>) -> Self;  // copies while shown
    pub fn on_submit<F: Fn() + 'static>(self, callback: F) -> Self;
}

create_password() -> Password
impl Password {
    pub fn with<R>(&self, f: impl FnOnce(&Secret) -> R) -> R;  // tracked
    pub fn is_empty(&self) -> bool;                              // tracked
    pub fn set(&self, secret: Secret);
    pub fn take(&self) -> Secret;                                // empties the field
    pub fn clear(&self);
}

impl Secret {
    pub fn expose(&self) -> &str;
    pub fn is_empty(&self) -> bool;
    pub fn duplicate(&self) -> Secret;  // its own locked pages
}
impl From<String> for Secret   // wipes the String

Note: The on_change callback is optional and is called in addition to the automatic signal update. Use it for side effects like validation or logging, not for updating the signal (that happens automatically).

Images

Guido supports displaying both raster images (PNG, JPEG, GIF, WebP) and SVG vector graphics. Images are rendered as GPU textures and compose seamlessly with container transforms.

Basic Usage

The image() function creates an image widget from a file path:

extern crate guido;
fn main() {
use guido::prelude::*;

// Load a PNG image
container()
    .width(32.0)
    .height(32.0)
    .child(image("./icon.png"));

// Load an SVG (auto-detected by extension)
container()
    .width(100.0)
    .height(100.0)
    .child(image("./logo.svg"))
;
}

Image Sources

You can load images from different sources using ImageSource:

extern crate guido;
use guido::prelude::*;
fn main() {
let svg_string = String::new();
let image_bytes: &[u8] = &[];
let rgba_bytes: Vec<u8> = Vec::new();
// From file path (raster)
ImageSource::Path("./photo.jpg".into());

// From memory (raster)
ImageSource::Bytes(image_bytes.into());

// From file path (SVG)
ImageSource::SvgPath("./icon.svg".into());

// From memory (SVG)
ImageSource::SvgBytes(svg_string.as_bytes().into());

// Raw pre-decoded RGBA8 pixels (width * height * 4 bytes, row-major)
ImageSource::Rgba { width: 22, height: 22, pixels: rgba_bytes.into() }
;
}

When using a string path with image(), the file extension determines the type automatically: .svg files use SVG rendering, all others use raster decoding.

ImageSource::Rgba skips decoding entirely — use it for pixel data that never existed in an encoded format, such as tray icon pixmaps or album art received over D-Bus. Its pixels have straight alpha, as a decoder gives them, not premultiplied.

Loading in the Background

A raster Path or Bytes source is decoded on a background thread, not in the frame that first shows it. A large wallpaper takes a noticeable fraction of a second to decode, and a frame that waited for it would put nothing on screen until then. So the first frame goes out without the picture: the box is laid out at the image’s size, read from the file’s header, and nothing is drawn in it. When the decode finishes the image is drawn on the next frame, with nothing from your application needed to prompt it. Two images showing the same source share one decode.

An SVG is rasterized at the size it is shown. An icon-sized one is cheap enough to draw in the frame that shows it, so it appears at once. A large one — anything past 128 × 128 pixels once HiDPI and the 2× quality are counted — is rasterized on the same background thread and appears on a later frame the same way. When its box changes size it needs a new raster, and until that one is ready the previous one is drawn, stretched, so a large SVG in a resize animation never blinks out.

The image does not fade itself in. It tells you when it is ready, as a signal, and a fade is then an opacity with a transition on the container around it:

extern crate guido;
use guido::prelude::*;
fn main() {
let wallpaper = image("./wallpaper.png").content_fit(ContentFit::Cover);
let ready = wallpaper.ready();

container()
    .width(fill())
    .height(fill())
    .opacity((move || if ready.get() { 1.0 } else { 0.0 }).transition(200.0))
    .child(wallpaper)
;
}

ready() follows the source: when a reactive source changes, it is false until the new one is decoded. A source that failed to decode is never ready, and says why once in the log. An SVG is ready from the first frame that draws it: an icon-sized one is drawn, and usually ready, from the start, and a large one once its first raster lands. ImageSource::Rgba is ready from the start — the way to have a raster image in the very first frame, if you decoded it yourself.

Sizing

The box comes from the enclosing container, like every other size in guido; the image decides only how its pixels land inside it.

extern crate guido;
use guido::prelude::*;
fn main() {
// A 32x32 box
container().width(32.0).height(32.0).child(image("./icon.png"));

// Width fixed, height from the aspect ratio (the default fit is Contain)
container().width(200.0).child(image("./banner.png"));

// No box at all - the image reports its intrinsic size
image("./icon.png")
;
}

Content Fit Modes

The content_fit() method controls how the image maps into the box it is given:

ModeDescription
ContentFit::ContainFit within the box, preserving aspect ratio (default)
ContentFit::CoverFill the box, cropping, preserving aspect ratio
ContentFit::FillStretch to fill exactly, ignoring aspect ratio
ContentFit::NoneIntrinsic size, ignoring the box

Cover and Fill take all the room the container offers; they differ in how the pixels land. Contain takes only the largest rect of the image’s own aspect ratio that fits, so the empty strip is left to the parent’s alignment rather than painted.

Like every declared value, it takes a signal as readily as a value, so an image can change how it fills its box without being rebuilt:

extern crate guido;
use guido::prelude::*;
fn main() {
let cropped = create_signal(true);
image("./photo.jpg").content_fit(move || {
    if cropped.get() {
        ContentFit::Cover
    } else {
        ContentFit::Contain
    }
})
;
}
extern crate guido;
use guido::prelude::*;
fn main() {
let wallpaper = "./wallpaper.jpg";
// Cover a 200x150 box, cropping what does not fit
container()
    .width(200.0)
    .height(150.0)
    .child(image("./photo.jpg").content_fit(ContentFit::Cover));

// A wallpaper: cover the whole surface
container()
    .width(fill())
    .height(fill())
    .child(image(wallpaper).content_fit(ContentFit::Cover))
;
}

Transform Composition

Images inherit transforms from parent containers, just like text:

extern crate guido;
use guido::prelude::*;
fn main() {
// Rotated image
container()
    .rotate(15.0)
    .child(
        container()
            .width(32.0)
            .height(32.0)
            .child(image("./badge.svg"))
    );

// Scaled image
container()
    .scale(1.5)
    .child(
        container()
            .width(24.0)
            .height(24.0)
            .child(image("./icon.png"))
    );

// Combined transforms
container()
    .rotate(45.0)
    .scale(2.0)
    .child(image("./logo.svg"))
;
}

SVG Quality

SVGs are automatically rasterized at the appropriate scale for crisp rendering:

  • HiDPI displays: SVGs render at the display scale factor
  • Transforms: When scaled via container transforms, SVGs re-rasterize at the higher resolution
  • Quality: A 2x supersampling multiplier ensures smooth edges
  • Content fit: The raster is made at the size the fit draws it at — the box’s width and height for ContentFit::Fill, and for ContentFit::Cover only the part the box shows, at the scale that covers it — so a stretched or covering SVG is as sharp as a contained one, and a covering one costs no more than its box

This means SVGs stay crisp regardless of how they’re scaled or transformed.

In-Memory SVGs

For dynamically generated or embedded SVGs:

extern crate guido;
use guido::prelude::*;
fn main() {
let svg_data = r##"
    <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
        <circle cx="50" cy="50" r="40" fill="#4f46e5" />
    </svg>
"##;

container()
    .width(48.0)
    .height(48.0)
    .child(image(ImageSource::SvgBytes(svg_data.as_bytes().into())))
;
}

An Image carries no box of its own: the container it sits in is what gives it one, exactly as with a text. content_fit then decides how the picture uses that box.

Tinting

tint draws every pixel of an image in one colour, keeping its alpha. It is how a monochrome icon takes the colour of the text beside it, a hover, or a theme:

extern crate guido;
use guido::prelude::*;
fn main() {
let hovered = create_signal(false);
container()
    .width(16.0)
    .height(16.0)
    .on_hover(move |h| hovered.set(h))
    .child(image("./battery.svg").tint(move || {
        if hovered.get() { Color::WHITE } else { Color::rgb(0.6, 0.6, 0.6) }
    }))
;
}

The colour is applied while the image is drawn, from the texture it already has, so changing it costs a repaint rather than rasterizing the SVG again. The tint’s alpha fades the image as opacity would.

An icon inside a button rarely needs a signal of its own for this. An image takes state layers as a text does, and they follow the control it sits in, so the icon brightens and dims with its label:

extern crate guido;
use guido::prelude::*;
fn main() {
let weak = Color::rgb(0.6, 0.6, 0.6);
let dim = Color::rgb(0.3, 0.3, 0.3);
let busy = create_signal(false);
container()
    .layout(Flex::row().spacing(8.0))
    .enabled(move || !busy.get())
    .on_click(|| {})
    .child(
        container().width(16.0).height(16.0).child(
            image("./power.svg")
                .tint(weak.transition(150.0))
                .when_hovered(|s| s.tint(Color::WHITE))
                .when_disabled(|s| s.tint(dim)),
        ),
    )
    .child(
        text("Power off")
            .color(weak.transition(150.0))
            .when_hovered(|s| s.color(Color::WHITE))
            .when_disabled(|s| s.color(dim)),
    )
;
}

A transition declared on the tint eases the change between states; without one the tint switches.

Reactive Images

Image sources can be reactive, allowing dynamic image changes:

extern crate guido;
use guido::prelude::*;
fn main() {
let icon_source = create_signal(ImageSource::Path("./play.png".into()));

// The image updates when the signal changes
container()
    .width(32.0)
    .height(32.0)
    .child(image(icon_source));

// Change the image on click
container()
    .on_click(move || {
        icon_source.set(ImageSource::Path("./pause.png".into()));
    })
    .child(image(icon_source))
;
}

Supported Formats

Raster Formats

  • PNG
  • JPEG
  • GIF
  • WebP

A photo is shown the way its EXIF orientation says, as a browser shows it. A phone often stores a portrait photo’s pixels landscape and records the turn in the metadata; such a photo is laid out portrait and drawn upright. JPEG, PNG and WebP carry the orientation; a GIF has none and is shown as stored.

Vector Formats

  • SVG

Example

Here’s a complete example showing various image features:

extern crate guido;
use guido::prelude::*;

fn main() {
    App::new().run(|app| {
        let svg_icon = r##"
            <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                <circle cx="12" cy="12" r="10" fill="#3b82f6"/>
            </svg>
        "##;

        let view = container()
            .padding(16.0)
            .layout(Flex::row().spacing(16.0))
            .child(
                // PNG image
                container()
                    .width(48.0)
                    .height(48.0)
                    .child(image("./logo.png"))
            )
            .child(
                // SVG from memory
                container()
                    .width(32.0)
                    .height(32.0)
                    .child(image(ImageSource::SvgBytes(svg_icon.as_bytes().into())))
            )
            .child(
                // Rotated image
                container()
                    .rotate(15.0)
                    .child(
                        container()
                            .width(24.0)
                            .height(24.0)
                            .child(image("./icon.svg"))
                    )
            );

        app.add_surface(
            SurfaceConfig::new()
                .height(80)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            move || view,
        );
    });
}

Performance Notes

  • Raster files and bytes are decoded off the frame, once per source, and the decoded pixels are dropped once they are uploaded: after that the GPU texture is the image. If the texture is later evicted, the image is decoded again the next time it is drawn
  • Images are cached as GPU textures. Small ones — up to 256 pixels a side once rasterized — share atlas pages, so a screen full of icons is a few draw calls, not one per icon
  • The cache’s budget is 100 MB of images (about width × height × 4 bytes each) and it evicts the least recently drawn past that, never one drawn in the last second. An application with other needs sets its own budget with App::image_cache_budget. The atlas pages the small images share are 4 MB each and are not counted: a page is given back once eviction has emptied it, so the GPU memory held can run a few pages past the budget
  • SVGs are re-rasterized when their display size or scale changes — in the frame for an icon, off it for a large one, which draws the previous raster stretched until the new one lands
  • Texture uploads happen once per unique image/scale combination

Arcs and Paths

A container draws boxes. For a progress ring, a gauge, a sparkline or an outline that is not a rounded rect, there are two more things to draw: an arc(), which is a widget, and a path, which a widget of your own draws.

Arcs

arc() draws part of a ring centred in a square box — a battery level, a countdown, a volume gauge.

extern crate guido;
use guido::prelude::*;
fn main() {
let level = create_signal(64.0_f32);

arc()
    .size(18.0)                              // the box, in logical pixels
    .stroke(2.6)                             // the line's width
    .track(Color::WHITE.with_alpha(0.16))    // the whole sweep, underneath
    .color(Color::rgb(0.4, 0.85, 0.5))       // the covered part
    .caps(Cap::Round)
    .progress((move || level.get() / 100.0).transition(1000.0))
;
}

The line runs inside the box, its outer edge touching the box’s sides. It starts at start degrees, measured clockwise from twelve o’clock, and runs sweep degrees — a full turn unless you say otherwise. progress, from 0 to 1, is how much of that sweep is covered in color; the track, if you give one, is drawn under all of it.

extern crate guido;
use guido::prelude::*;
fn main() {
// A gauge open at the bottom: three quarters of a turn, from lower left.
arc()
    .size(48.0)
    .stroke(5.0)
    .start(-135.0)
    .sweep(270.0)
    .track(Color::rgba(1.0, 1.0, 1.0, 0.12))
    .caps(Cap::Round)
    .progress(0.6)
;
}

A negative sweep runs anticlockwise. Progress 0 draws the track alone, even with round caps — not a dot.

progress and color are animatable, like a container’s background: give either a .transition(..) and it eases to every new value. That costs nothing per frame beyond the frame itself, because an arc is drawn exactly, as one shape, rather than cut into triangles — a ring easing for a second is a number changing sixty times, never a new shape to build.

PropertyDefaultReactiveAnimatable
size24yes
stroke2yes
colorwhiteyesyes
tracknoneyes
capsCap::Buttyes
start0yes
sweep360yes
progress0yesyes

Paths

Anything else is a Path, drawn by a widget of your own — see Custom Widgets for the rest of what one is. A path is built a segment at a time, in the widget’s local coordinates:

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
fn main() {
let outline = Path::new()
    .move_to((0.0, 20.0))
    .line_to((10.0, 4.0))
    .quad_to((15.0, -2.0), (20.0, 4.0))
    .cubic_to((24.0, 10.0), (28.0, 18.0), (30.0, 20.0))
    .close();
let _ = outline;
}

PaintContext::draw_path fills it, strokes it, or both — the fill first, and the stroke centred on the outline over it:

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
struct Sparkline {
    samples: Signal<Vec<f32>>,
}

impl Widget for Sparkline {
    fn layout(&mut self, _ctx: &mut LayoutCtx, constraints: Constraints) -> Size {
        constraints.constrain(Size::new(80.0, 20.0))
    }

    fn paint(&self, ctx: &mut PaintContext) {
        // Read while painting, so a new sample repaints this widget alone.
        let samples = self.samples.get();
        let step = 80.0 / (samples.len().max(2) - 1) as f32;
        let line = samples.iter().enumerate().fold(Path::new(), |path, (i, v)| {
            let point = (i as f32 * step, 20.0 * (1.0 - v));
            if i == 0 { path.move_to(point) } else { path.line_to(point) }
        });
        let area = line.clone().line_to((80.0, 20.0)).line_to((0.0, 20.0)).close();

        let blue = Color::rgb(0.45, 0.8, 1.0);
        ctx.draw_path(&area, Fill::color(blue.with_alpha(0.2)), None);
        ctx.draw_path(&line, None, Stroke::new(1.6, blue).joins(Join::Round));
    }
}
fn main() {}

A Fill is a colour and a FillRule: NonZero by default, as in SVG and Canvas, or EvenOdd, under which a subpath inside another cuts a hole whichever way it winds. A Stroke is a width, a colour, a Cap for the ends of an open line — Butt, Round or Square — a Join where two segments meet — Miter, Round or Bevel — and a miter limit, 4 by default as in SVG.

The edges are anti-aliased, a pixel wide at any scale, and a translucent stroke is one tone from end to end: a join is not drawn twice.

Dashes

Stroke::dashes cuts the line into dashes: the lengths of a dash, a gap, a dash and so on, in logical pixels, and how far into that pattern the path starts.

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
fn main() {
let threshold = Stroke::new(1.0, Color::WHITE).dashes(&[6.0, 4.0], 0.0);
let dotted = Stroke::new(2.0, Color::WHITE)
    .caps(Cap::Round)
    .dashes(&[0.0, 5.0], 0.0);
let _ = (threshold, dotted);
}

Each dash is a line of its own, capped at both ends and joined at every corner it turns, so a dash of no length with round caps is a dot. The pattern starts again on each subpath, and on a closed one the dash that runs through the start is one dash. An odd number of lengths is repeated, as in SVG and Canvas; a pattern that adds up to nothing draws the line solid.

draw_arc takes a dashed Stroke too. A solid arc is one exact shape, cheap to animate; a dashed one is drawn as a path, so it is cut and its triangles kept like any other, and a sweep or an offset that moves every frame is new triangles every frame.

What a path costs

A path is cut into triangles the first time it is drawn at a scale, and the triangles are kept, keyed by the path’s segments and how it is filled or stroked. Drawing the same segments again — moved, turned, faded or in another colour — costs no new triangles. New segments do: a sparkline that shifts by a sample is a new path, which is fine once a second and worth knowing about at sixty frames a second. An animated sweep is what arc() is for.

The dash pattern and its offset are part of that key, the offset kept within one pass of the pattern. Dashes that march along a path — an offset that moves every frame — are new triangles every frame for that one path, until the offset comes back round to one it has drawn.

What there is not

No gradient fills, no boolean operations between paths, no text along a path, and no reading an SVG into a path — an SVG is an image.

Interactivity

This section covers how to make your UI respond to user input with visual feedback.

The State Layer API

Guido uses a declarative state layer system for interaction feedback. Instead of manually managing hover states with signals, you declare what should change:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_hovered(|s| s.lighter(0.1))      // Lighten on hover
    .when_pressed(|s| s.ripple())         // Ripple on press
;
}

The framework handles:

  • State tracking (hover, pressed)
  • Animations between states
  • Ripple effect rendering
  • Transform hit testing

In This Section

Why State Layers?

Before state layers, creating hover effects required manual signal management:

extern crate guido;
use guido::prelude::*;
fn main() {
// Old way (tedious)
let bg_color = create_signal(Color::rgb(0.2, 0.2, 0.3));
container()
    .background(bg_color)
    .on_hover(move |hovered| {
        if hovered {
            bg_color.set(Color::rgb(0.3, 0.3, 0.4));
        } else {
            bg_color.set(Color::rgb(0.2, 0.2, 0.3));
        }
    })
;
}

With state layers:

extern crate guido;
use guido::prelude::*;
fn main() {
// New way (clean)
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_hovered(|s| s.lighter(0.1))
;
}

Benefits:

  • Less boilerplate code
  • No manual signal management
  • Built-in animation support
  • Ripple effects included

State Layer API

The state layer system provides declarative style overrides based on widget interaction state.

Overview

State layers let containers define how they should look when:

  • Hovered - Mouse cursor is over the widget
  • Pressed - Mouse button is held down on the widget
  • Focused - The keyboard focus is inside the widget
  • Disabled - The widget, or something above it, was declared enabled(false) — see Disabling a Subtree

Changes are defined declaratively, and the framework handles state transitions, animations, and rendering.

Basic Usage

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .corners(8.0)
    .when_hovered(|s| s.lighter(0.1))      // Style when hovered
    .when_pressed(|s| s.ripple())         // Style when pressed
    .child(text("Click me"))
;
}

Available Overrides

State layers can override these properties:

Background

extern crate guido;
use guido::prelude::*;
fn main() {
container()
// Explicit color
.when_hovered(|s| s.background(Color::rgb(0.4, 0.6, 0.9)))

// Relative to base
.when_hovered(|s| s.lighter(0.1))   // 10% lighter
.when_pressed(|s| s.darker(0.1))  // 10% darker
;
}

On a container with a gradient, lighter, darker and background_alpha apply to both of its ends, and an explicit background replaces the gradient while the state is active.

background_alpha sets the alpha of the background and of nothing else: what the container draws inside it keeps its colours. To fade the whole control, use opacity.

Opacity

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .opacity(1.0.transition(150.0))
    .when_disabled(|s| s.opacity(0.4))
    .child(text("Power off"))
;
}

The container’s own opacity, replaced while the state holds: the background, the border and everything inside it fade together, by the declared opacity_mode, and the transition declared on opacity carries the change both ways.

Border

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_hovered(|s| s.border(1.0, Color::WHITE))   // both halves, always
;
}

A border takes room inside the box, so a layer that changes its width relays the container out and moves its content by the difference; a colour change moves nothing.

Outline

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.outline(2.0, Color::TRANSPARENT, 2.0)
.when_focused(|s| s.outline(2.0, Color::WHITE, 2.0))   // width, colour, offset
;
}

A ring outside the box that takes no room: a layer that shows, thickens or moves it only repaints. It is the focus or hover ring.

Transform

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.scale(0.98))
;
}

Corner Radius

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_hovered(|s| s.corners(12.0))
;
}

Shadow

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const HIGH: Shadow = Shadow::simple((0.0, 6.0), 10.0, Color::rgba(0.0, 0.0, 0.0, 0.22));
fn main() {
container()
.when_hovered(|s| s.shadow(HIGH))
.when_pressed(|s| s.shadow(LOW))
;
}

Ripple

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.ripple())
.when_pressed(|s| s.ripple_with_color(Color::rgba(1.0, 0.8, 0.0, 0.4)))
;
}

Combining Overrides

Chain multiple overrides in a single state:

extern crate guido;
use guido::prelude::*;
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
.when_hovered(|s| s
    .lighter(0.1)
    .border(2.0, Color::WHITE)
    .shadow(RAISED)
)

.when_pressed(|s| s
    .ripple()
    .darker(0.05)
    .scale(0.98)
)
;
}

With Animations

The motion belongs to the property, declared once beside its base value. A layer supplies a value for a property somebody else owns, and carries no timing of its own — so a hover eases under whatever the base declared:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
    .when_hovered(|s| s.lighter(0.15))
    .when_pressed(|s| s.darker(0.1))
;
}

Writing a timing on the override does not compile. It would be a declaration that is legal and does nothing:

// not compiled: the line is the mistake the paragraph above names.
.when_hovered(|s| s.background(HOT.transition(900.0)))   // error

Complete Example

extern crate guido;
use guido::prelude::*;
fn interactive_button(label: &str) -> Container {
    container()
        .padding(16.0)
        // Each property carries its own motion
        .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
        .corners(8.0)
        .border(1.0.transition(150.0), Color::rgb(0.4, 0.6, 0.9))
        .scale(Scale::NONE.transition(Transition::spring(SpringConfig::GENTLE)))

        // State layers
        .when_hovered(|s| s
            .lighter(0.1)
            .border(2.0, Color::rgb(0.5, 0.7, 1.0))
        )
        .when_pressed(|s| s
            .ripple()
            .darker(0.05)
            .scale(0.98)
        )

        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {}

How It Works

Internally, StateStyle holds all possible overrides:

// not compiled: a field listing of the real struct, whose halves
// (`BackgroundOverride`, `BorderOverride`, `OutlineOverride`) are named
// without being built.
pub struct StateStyle {
    pub background: Option<BackgroundOverride>,
    // Both halves or neither: half a border is no border.
    pub border: Option<BorderOverride>,
    // All three halves or none, for the same reason.
    pub outline: Option<OutlineOverride>,
    pub corners: Prop<Corners>,
    pub translate: Prop<Translate>,
    pub rotate: Prop<f32>,
    pub scale: Prop<Scale>,
    pub shadow: Option<Box<Prop<Shadows>>>,
    pub background_alpha: Prop<f32>,
    pub opacity: Prop<f32>,
    pub ripple: Option<RippleConfig>,
}

RippleConfig is in the prelude, because ripple_config takes one. BackgroundOverride, BorderOverride and OutlineOverride are not: nothing asks you to build any of them — background, lighter, darker, border and outline take colours and numbers and build them for you — so they stay in guido::widgets, for the rare caller that reads a StateStyle back.

When the container paints, it checks the current state and applies overrides accordingly, blending with animations when configured.

Hover & Pressed States

Define visual changes for different interaction states.

Every value inside a state layer is reactive, like the base property it covers. when_hovered(|s| s.background(theme.accent)) follows the theme; it does not take a snapshot of it when the widget is built.

Hover State

Applied when the mouse cursor is over the widget:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_hovered(|s| s.lighter(0.1))
;
}

Common Hover Patterns

Lighten background:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_hovered(|s| s.lighter(0.1))
;
}

Explicit color change:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_hovered(|s| s.background(Color::rgb(0.4, 0.6, 0.9)))
;
}

Border highlight:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.border(1.0, Color::rgb(0.3, 0.3, 0.4))
.when_hovered(|s| s.border(1.0, Color::rgb(0.5, 0.5, 0.6)))
;
}

The width stays: a border takes room inside the box, so a hover that widened it would move the content by the difference.

Ring:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.outline(2.0, Color::TRANSPARENT, 2.0)
.when_hovered(|s| s.outline(2.0, Color::rgb(0.5, 0.5, 0.6), 2.0))
;
}

An outline is drawn outside the box and takes no room, so showing it moves nothing.

Shadow lift:

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
.shadow(LOW)
.when_hovered(|s| s.shadow(RAISED))
;
}

Pressed State

Applied when the mouse button is held down:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_pressed(|s| s.darker(0.1))
;
}

Common Pressed Patterns

Darken background:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.darker(0.1))
;
}

Scale down (tactile feedback):

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.scale(0.98))
;
}

Shrink the shadow (press into the surface):

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
.shadow(RAISED)
.when_pressed(|s| s.shadow(LOW))
;
}

Ripple effect:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.ripple())
;
}

Combining Hover and Pressed

Most interactive elements use both states:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.ripple().darker(0.05))
;
}

Text Colour

A state layer can change the colour of the text below the container, not just the box around it:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
container()
    
    .control()   // the text below declares .when_hovered(|s| s.color(theme.text))
    .child(text("Label").color(theme.text_weak))
;
}

This works the same way the ordinary declaration does — the container publishes its text colour to descendants, and here what it publishes folds in the hover. A text that declares its own colour nearer down is unaffected, and a container with no state layer mentioning text creates nothing extra.

The base it returns to when the state ends is the colour that would have been inherited anyway, so a container can override on hover without restating what its ancestors already said:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
container()
                  // set once, further up
    .child(
        container()
            .control()   // the text below declares .when_hovered(|s| s.color(theme.text))
            .child(text("Label").color(theme.text_weak)),            // weak, then strong, then weak
    )
;
}

It cannot be animated. A container transitions background, border_color, border_width, corners, shadow, width, height, padding, translate, rotate and scale; text colour is not among them, so the override lands in one frame.

A transition declared on two levels — an animated value whose own base comes from an ancestor that is itself animating — retargets every frame, which gives a damped chase rather than a transition with its own curve. It converges either way; CSS instead starts the inner one once, towards the outer’s final value.

States Your App Owns

Hover, pressed and focus are noticed by the container. The rest — the last submit failed, this row is selected, this value is out of range — are conditions your app already holds, so there is nothing to propagate: pass the signal and it is read where the style is resolved.

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let password = create_password();
let theme = Theme::default();
let wrong_password = create_signal(false);

container()
    .border(1.0, theme.line)
    .state(wrong_password, |s| s.border(1.0, theme.error))
    .child(password_input(password))
;
}

Because it is just a signal, anything else that needs it reads the same one, independently — a label beside the field does not have to be told:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
let wrong_password = create_signal(false);
container()
    .state(wrong_password, |s| s.border(1.0, theme.error))
    .child(text("Wrong password"))
;
}

Whose Hover?

A label inside a button has to light up while the pointer is on the button’s padding, nowhere near the glyphs. And it has to stay dark while the pointer is over a different button in the same row. So neither “my own bounds” nor “any ancestor” is the answer.

The answer is the nearest enclosing control — the interaction unit the widget belongs to:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let save = move || {};
let theme = Theme::default();
container()
    .padding(12.0)
    .on_click(save)
    .child(text("Save").color(theme.weak).when_hovered(|s| s.color(theme.strong)))
;
}

on_click makes that container a control, because anything the pointer can act on is a unit by necessity. So do on_hover, on_scroll, scroll and any declared state layer. Write control() yourself where the boundary is real but nothing else announces it:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let password = create_password();
let theme = Theme::default();
container().control()
    .child(text("Password").when_focused(|s| s.color(theme.accent)))
    .child(password_input(password))
;
}

That is the case the old rule could not express at all: the focus is in a sibling, not below the label. Both belong to the same unit, so the label can ask about it.

Leaves get when_hovered, when_pressed, when_focused and state from the Stateful trait, and the closure hands back the same builder the widget itself uses.

Nesting scopes resolution, not state. A list row stays hovered while the pointer is over a button nested in it — the pointer is plainly still on the row. What the nested control changes is only who a descendant asks.

With nothing marked above it, a widget is its own unit and notices the pointer over its own bounds. It is never pressed: being pressed means being activated, and it has nothing to activate.

when_focused also keeps the focus. A press that no widget claimed takes the keyboard off whatever held it, and a press inside a container declaring when_focused while that focus is inside it is not one of those. A box that says it lights up for a field has said the box is part of the field, so clicking its padding does not blur what it draws.

It keeps the focus without consuming the press. A clickable row wrapping a decorated field still sees the click, still lights its pressed layer and still fires its on_click — keeping the keyboard and swallowing the event are two different things. See Losing Focus.

Which Layer Wins

Layers are resolved in reverse declaration order, one property at a time: the last one you wrote that is active and speaks about that property wins.

This is what lets a field say that its error outranks its focus ring — which matters on a password field, where the focus is held essentially all the time:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let password = create_password();
let theme = Theme::default();
let wrong_password = create_signal(false);
container()
    .border(1.0, theme.line)
    .outline(2.0, Color::TRANSPARENT, 2.0)
    .when_focused(|s| s.outline(2.0, theme.accent, 2.0))
    .state(wrong_password, |s| s.outline(2.0, theme.error, 2.0))   // written after,
    .child(password_input(password))                                    // so it wins
;
}

Swap the two lines and the focus ring wins instead. A layer that says nothing about a property is skipped rather than ending the search, so the pressed layer below only takes over the transform and leaves the hover’s background alone:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.scale(0.98))
;
}

Keep in mind that a layer replaces the base rather than ranking against it. A border that already carries a meaning does not belong in the base — put it in a layer with a condition, where the ordering above can speak about it.

Combining Multiple Overrides

Each state can override multiple properties:

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
.when_hovered(|s| s
    .lighter(0.1)
    .outline(2.0, Color::rgb(0.5, 0.7, 1.0), 2.0)
    .shadow(RAISED)
)

.when_pressed(|s| s
    .ripple()
    .darker(0.05)
    .scale(0.98)
    .shadow(LOW)
)
;
}

With Animations

Add transitions for smooth state changes:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
    .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)
    .scale(Scale::NONE.transition(Transition::spring(SpringConfig::GENTLE)))
    .when_hovered(|s| s.lighter(0.1).outline(2.0, Color::WHITE, 2.0))
    .when_pressed(|s| s.darker(0.1).scale(0.98))
;
}

Button Patterns

Simple Button

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(12.0)
    .background(Color::rgb(0.3, 0.5, 0.8))
    .corners(6.0)
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.ripple())
    .on_click(|| println!("Clicked!"))
    .child(container().child(text("Click me").color(Color::WHITE)))
;
}

Outlined Button

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(12.0)
    .background(Color::TRANSPARENT)
    .corners(6.0)
    .border(1.0, Color::rgb(0.5, 0.5, 0.6))
    .when_hovered(|s| s.background(Color::rgba(1.0, 1.0, 1.0, 0.1)))
    .when_pressed(|s| s.ripple())
    .child(container().child(text("Outlined").color(Color::WHITE)))
;
}

Card with Lift

// not compiled: `[...]` stands in for the children; this block is about
// the shadows above them.
extern crate guido;
use guido::prelude::*;
const FLAT: Shadow = Shadow::none();
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
    .padding(16.0)
    .background(Color::rgb(0.15, 0.15, 0.2))
    .corners(8.0)
    .shadow(LOW.transition(200.0))
    .when_hovered(|s| s.shadow(RAISED).lighter(0.03))
    .when_pressed(|s| s.shadow(FLAT))
    .children([...])
;
}

API Reference

StateStyle Builder

// not compiled: a signature listing — these declarations have no bodies.
impl StateStyleBuilder {
    // Background
    pub fn background(self, color: Color) -> Self;
    pub fn lighter(self, amount: f32) -> Self;
    pub fn darker(self, amount: f32) -> Self;
    pub fn background_alpha<M>(self, alpha: impl IntoSignal<f32, M>) -> Self;

    // The whole container, children included — its own `opacity`
    pub fn opacity<M>(self, opacity: impl IntoSignal<f32, M>) -> Self;

    // Border — both halves, always; half a border is no border
    pub fn border(self, width: f32, color: Color) -> Self;

    // Outline — width, colour and offset, always; it takes no room
    pub fn outline(self, width: f32, color: Color, offset: f32) -> Self;

    // Other
    pub fn corners<M>(self, corners: impl IntoSignal<Corners, M>) -> Self;
    // Every value is reactive here too, like the container's own
    pub fn translate<M>(self, translate: impl IntoSignal<Translate, M>) -> Self;
    pub fn rotate<M>(self, degrees: impl IntoSignal<f32, M>) -> Self;
    pub fn scale<M>(self, factor: impl IntoSignal<Scale, M>) -> Self;
    pub fn shadow<M>(self, shadow: impl IntoSignal<Shadows, M>) -> Self;

    // Ripple
    pub fn ripple(self) -> Self;
    pub fn ripple_with_color<M>(self, color: impl IntoSignal<Color, M>) -> Self;
}

Disabling a Subtree

A form that stops responding while a request is in flight is one declaration, not a check inside every handler:

extern crate guido;
use guido::prelude::*;
fn main() {
let busy = create_signal(false);
let submit = || {};
container()
    .enabled(move || !busy.get())
    .child(container().on_click(submit).child(text("Send")))
;
}

enabled(false) stops every event at that container. Nothing below it — a click, a key, a scroll, a nested button’s handler — is reached, and nothing below it has to know why.

It still stands in the way, which is the point: a click on a disabled button is a click the application swallowed, not one the desktop behind the surface receives. Letting it through is takes_input(false), a different question asked of the compositor rather than of the widget.

It propagates, and cannot be undone from below

A container declaring enabled(true) inside one declaring enabled(false) is disabled. There is no way for a descendant to re-enable itself, which is what lets a whole form be switched off in one place and is the rule Qt, GTK and SwiftUI all settled on.

The look is declared beside the behaviour

A disabled subtree is painted exactly as declared — guido does not pick a grey for you. when_disabled is how you pick one, and it reads the same declaration that stopped the events, so the two cannot come apart:

extern crate guido;
use guido::prelude::*;
fn main() {
let busy = create_signal(false);
container()
    .enabled(move || !busy.get())
    .child(
        text("Send")
            .color(Color::WHITE)
            .when_disabled(|s| s.color(Color::rgb(0.5, 0.5, 0.5))),
    )
;
}

To fade the whole control instead — fill, label and icon together — when_disabled(|s| s.opacity(0.4)) overrides its opacity.

The text asks its control — the nearest enclosing interaction unit, which declaring enabled makes the container into. That is why the label greys with the form around it without being handed the signal itself.

An icon greys the same way, with when_disabled(|s| s.tint(..)) on the image — see Images.

A condition you already hold reads the same at one call site: state(busy, |s| s.darker(0.2)). What it cannot do is promise that two widgets in two files mean the same thing by it. Disabled is the control’s answer, with its ancestors folded in, so it means one thing everywhere.

What a disabled subtree gives up, and what it keeps

It gives up the pointer and the keyboard. A disabled control is neither hovered nor pressed nor focused, so when_hovered, when_pressed and when_focused all switch off with it — no stale highlight, and no focus ring on something that is not taking keys. A press held when it was disabled is dropped without a click, so enabled again nothing in it comes back pressed. Qt, GTK, SwiftUI and <fieldset disabled> all behave this way.

It is still laid out and still painted. Disabling changes what an event does, not where anything sits — so it costs no layout pass.

Not the same as read-only

There is a second thing you may want, and it is not this one: a field that stops accepting edits but goes on saying the keyboard is aimed at it. A lock screen that has sent the password to PAM wants exactly that — the typed characters must go nowhere, but on a multi-monitor setup the when_focused ring is the only thing saying which screen you are typing at, and disabling would take it away.

That is readonly, and it is a different setter for a different job:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
let busy = create_signal(false);
text_input(value).readonly(busy).caret(move || !busy.get())
;
}

It refuses every edit — typing, backspace, delete, paste, cut, undo — and nothing else. The caret still moves, a selection can still be made and copied, Enter still reaches on_submit, and the focus stays put. The caret is not part of it: a field that should stop blinking says so with caret, driven by the same signal, as above.

Every toolkit keeps these two apart, and for this reason: readonly beside disabled on the web, setReadOnly beside setEnabled in Qt, readOnly beside enabled in Flutter. Reach for enabled when the thing is not available, and for readonly when it is yours but frozen.

Ripple Effects

Ripples provide Material Design-style touch feedback. They expand from the click point and create a visual acknowledgment of user interaction.

Basic Ripple

Add a default ripple to the pressed state:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3))
    .corners(8.0)
    .when_pressed(|s| s.ripple())
;
}

The default ripple uses a semi-transparent white overlay.

Colored Ripple

Customize the ripple color:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.ripple_with_color(Color::rgba(1.0, 0.8, 0.0, 0.4)))
;
}

Good ripple colors have transparency (alpha 0.2-0.5):

extern crate guido;
use guido::prelude::*;
fn main() {
// Subtle white
Color::rgba(1.0, 1.0, 1.0, 0.2);

// Yellow accent
Color::rgba(1.0, 0.8, 0.0, 0.4);

// Blue accent
Color::rgba(0.3, 0.5, 1.0, 0.3)
;
}

Ripple with Other Effects

Combine ripples with other pressed state changes:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s
    .ripple()
    .darker(0.05)
    .scale(0.98)
)
;
}

How Ripples Work

  1. Press - a disc appears at the click point, already at about a third of its final size, and starts spreading
  2. Release inside - the press happened, so the remaining expansion is completed rather than interrupted, while the disc fades out
  3. Release elsewhere, or drag off the button - nothing was activated, and the ripple says the same thing on_click does: the disc just fades, quickly

Two properties are worth stating outright, because they are what makes the effect read as an acknowledgement rather than a hesitation:

  • The radius never goes backwards. The exit is a fade, never a contraction.
  • A short press does not truncate the expansion. A click lasts 60-150ms against a growth measured in hundreds, so the release finishes the growth instead of abandoning it half-way.
  • No press is dimmer for being quicker. The disc always finishes appearing before it starts to leave, so a 20ms tap is as bright as a held one.

The disc’s centre also drifts toward the container’s own centre as it grows, so a press near a corner settles onto the button instead of staying lopsided. Its final size comes from the container, not from where you pressed — every ripple on a given button is the same size, and a full one covers it exactly.

The ripple:

  • Respects corner radius and container shape
  • Works correctly with transformed containers (rotated, scaled)
  • Renders in the overlay layer (on top of content)

Several at once

Each press is its own ripple, and they overlap:

extern crate guido;
use guido::prelude::*;
fn main() {
let increment = move || {};
container()
    .when_pressed(|s| s.ripple())
    .on_click(increment)
;
}

Clicking this repeatedly layers one disc over the next, because two clicks are two events — the second does not erase the first. Up to four are alive at a time; past that the oldest is dropped, which is the one furthest through its own fade.

Timing

PhaseDurationScaled by
Opacity rise on contact75msfade_speed
Growth while held1sexpand_speed
Growth remaining after release225msexpand_speed
Fade after a release375msfade_speed
Fade after an abandoned press75msfade_speed

The fade never starts before the rise has finished, and the growth after a release is never given longer than the fade it overlaps — so no combination of the two speeds can put the disc back where it started, half-grown and already invisible.

Both speeds are clamped to a sane range, so a zero or a negative one degrades instead of stalling the frame loop.

The colour is a Signal<Color>, as every other declared value is, so it is built through RippleConfig::with_color — which takes a value, a closure or a signal — rather than assigned as a bare Color. The two speeds stay plain numbers:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.when_pressed(|s| s.ripple_config(RippleConfig {
    expand_speed: 1.5,   // faster growth
    fade_speed: 1.0,
    ..RippleConfig::with_color(Color::rgba(1.0, 1.0, 1.0, 0.3))
}))
;
}

Ripples on Transformed Containers

Ripples work correctly even with transforms:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(16.0)
    .background(Color::rgb(0.4, 0.6, 0.4))
    .corners(8.0)
    // Fixed order: the offset is in the parent's frame, and the box turns
    // where it has been moved to.
    .translate((10.0, 15.0))
    .rotate(5.0)
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.ripple())
;
}

Click coordinates are properly transformed to local container space.

Ripples with Corner Curvature

Ripples respect different corner styles:

extern crate guido;
use guido::prelude::*;
fn main() {
// Squircle ripple
container()
    .corners(Corners::squircle(12.0))
    .when_pressed(|s| s.ripple());

// Beveled ripple
container()
    .corners(Corners::bevel(12.0))
    .when_pressed(|s| s.ripple())
;
}

Complete Example

extern crate guido;
use guido::prelude::*;
fn ripple_button(label: &str, color: Color) -> Container {
    container()
        .padding(16.0)
        .background(color)
        .corners(8.0)

        // Subtle hover, ripple on press
        .when_hovered(|s| s.lighter(0.1))
        .when_pressed(|s| s.ripple().scale(0.98))

        .on_click(|| println!("Clicked!"))
        .child(container().child(text(label).color(Color::WHITE)))
}

fn main() {
// Usage
ripple_button("Default Ripple", Color::rgb(0.3, 0.5, 0.8));
ripple_button("Action Button", Color::rgb(0.8, 0.3, 0.3));
}

Ripple Color Guidelines

BackgroundRipple Color
DarkColor::rgba(1.0, 1.0, 1.0, 0.2-0.3)
LightColor::rgba(0.0, 0.0, 0.0, 0.1-0.2)
ColoredLighter tint with 0.3-0.4 alpha

API Reference

extern crate guido;
use guido::prelude::*;
fn main() {
let a = 1.0f32;
let b = 0.5f32;
let g = 0.5f32;
let r = 0.5f32;
container()
// Default semi-transparent white ripple
.when_pressed(|s| s.ripple())

// Custom colored ripple
.when_pressed(|s| s.ripple_with_color(Color::rgba(r, g, b, a)))
;
}

Event Handling

Containers can respond to mouse events for user interaction.

Click Events

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_click(|| {
        println!("Clicked!");
    })
;
}

Click events fire when the mouse button is pressed and released within the container bounds.

Double Clicks

extern crate guido;
use guido::prelude::*;
fn main() {
let select = move || {};
let open = move || {};
container()
    .on_click(select)
    .on_double_click(open)
;
}

on_double_click fires on the release of the second click, as the web’s dblclick does. on_click still fires for both clicks, so the first click of a double click selects and the second opens. A click is the second of a pair when it is the same button, within 400 ms and 5 px of the first, with no drag between.

Every press carries its place in the run: Event::MouseDown’s count is 1 for a single click, 2 for the second of a double click, 3 for a triple, so a widget that wants a triple click reads it there. A finger always presses 1.

With Signal Updates

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);

container()
    .on_click(move || {
        count.update(|c| *c += 1);
    })
    .child(text(move || format!("Clicks: {}", count.get())))
;
}

Hover Events

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_hover(|hovered| {
        if hovered {
            println!("Mouse entered");
        } else {
            println!("Mouse left");
        }
    })
;
}

The callback receives a boolean indicating hover state.

When one pointer move leaves some containers and enters others, every on_hover(false) it causes is called before any on_hover(true). Leaves go from the innermost container outward, enters from the outermost inward, so the container the pointer left hears first and the one it reached hears last. A container holding both, which the pointer never left, hears nothing. The order is the same whichever way the pointer moved and whichever sibling is drawn on top, and it is the one browsers, Flutter, GTK and Qt give.

The hover state that when_hovered reads has already changed by the time any of these callbacks runs.

A container hidden or disabled while the pointer is on it ends the hover and the press of everything in it, itself included: on_hover(false) is called, and a press that was being held is dropped without a click, so nothing comes back hovered or pressed when it is shown or enabled again. That happens at the next pointer event to reach it, not at the instant it is hidden.

Hover with State Layer

For visual hover effects, use when_hovered instead:

// not compiled: `log` is the caller's crate, not one the book is
// compiled against.
extern crate guido;
use guido::prelude::*;
fn main() {
// Preferred for visual effects
container().when_hovered(|s| s.lighter(0.1));

// Use on_hover for side effects only
container().on_hover(|hovered| {
    log::info!("Hover changed: {}", hovered);
})
;
}

Other Buttons and Keys

extern crate guido;
use guido::prelude::*;
fn main() {
let close = move || {};
container()
    .on_click(|| println!("left"))
    .on_right_click(|| println!("right"))
    .on_middle_click(|| println!("middle"))
    .on_key_down(move |key, _mods, _repeat| {
        if key == Key::Escape {
            close();
            return EventResponse::Handled;
        }
        EventResponse::Ignored
    })
;
}

on_key_down fires while the surface has keyboard focus — a layer surface with KeyboardInteractivity set, or a popup holding a grab.

A key is a Key: a character, Key::Char, or one of the named keys — Enter, Tab, Escape, Backspace, Delete, the arrows, Home and End, Key::PageUp, Key::PageDown, and the function keys Key::F(1) to Key::F(12). The keypad’s Enter, and with NumLock off its arrows, Home, End, Page Up, Page Down and Delete, are the same keys as the main block’s. A text input takes neither Up, Down, Page Up, Page Down nor a function key, so they reach the listener around it — unless a scroller stands between them, which scrolls with the arrows, Page Up, Page Down, Home and End while it can move: see What Moves It.

It answers whether it took the key, as a widget’s event does. Handled stops the key there; Ignored lets it go on to the next container listening, and then to what the key does by default — Tab moving the focus, under Moving the Focus with Tab. A listener that answers Handled for every key keeps Tab from ever moving the focus out of it.

A widget with the focus — a text input you clicked into — hears a key first, and the containers around it after. on_key_down hears what nothing focused took, so Escape still closes a menu while its search field has the focus, and it hears it with nothing focused at all. Where two containers both declare it, the inner one hears the key first; where they stand side by side, the earlier one. The other hears it only when the first lets it through.

A held key arrives again at the compositor’s repeat rate, and each repeat is an ordinary key-down whose third argument is true. Text editing wants every one; something that should happen once per press returns early on a repeat:

extern crate guido;
use guido::prelude::*;
fn main() {
let activate = move || {};
container().on_key_down(move |key, _mods, repeat| {
    if key != Key::Enter {
        return EventResponse::Ignored;
    }
    if !repeat {
        activate();
    }
    EventResponse::Handled
})
;
}

A widget of your own reads the same flag as the repeat field of Event::KeyDown.

on_key_up hears a key being released, with the modifiers held as it went up, along the same route as on_key_down. That is where a button answers Space: the press shows it pressed, and the release activates it, so moving away before letting go abandons it.

extern crate guido;
use guido::prelude::*;
fn main() {
let activate = move || {};
let held = create_signal(false);
container()
    .on_key_down(move |key, _mods, _repeat| {
        if key != Key::Char(' ') {
            return EventResponse::Ignored;
        }
        held.set(true);
        EventResponse::Handled
    })
    .on_key_up(move |key, _mods| {
        if key == Key::Char(' ') && held.get_untracked() {
            held.set(false);
            activate();
            return EventResponse::Handled;
        }
        EventResponse::Ignored
    })
;
}

A release is not paired with its press. A text input that took a key’s press lets its release through, so a container around it or listening beside it can hear a key go up that it never heard go down — which is why the sample above remembers the press itself.

Moving the Focus with Tab

Tab moves the keyboard focus to the next text input on the surface, and Shift+Tab to the one before. A container joins them by declaring focusable(true):

extern crate guido;
use guido::prelude::*;
fn main() {
let open = move || {};
container()
    .focusable(true)
    .when_focused(|s| s.outline(2.0, Color::WHITE, 2.0))
    .on_key_down(move |key, _mods, _repeat| {
        if key == Key::Enter {
            open();
            return EventResponse::Handled;
        }
        EventResponse::Ignored
    })
    .child(text("Settings"))
;
}

Tab stops at it, a press inside it takes the focus — as tabindex="0" does on the web — and with the focus there its when_focused lights up and its on_key_down hears the keys first. The press is not consumed: a clickable row around it still gets its click. A press that something inside it took — a button in the card — stays that button’s, and the focus does not move. A container that is not focusable can still be given the focus by a widget ref; it is only not a place Tab stops.

Moving the focus is what a Tab does when nothing took it: the focused widget hears it first, then the containers around it, then the listeners, and only a Tab all of them let through moves the focus. A widget that wants Tab for itself — an editor inserting a tab character — answers Handled, and the focus stays where it is.

The order is reading order: top to bottom by where each stop was laid out, then left to right, as GTK orders it. It follows what is on screen, so a list reordered by key is walked in the order it is shown. At the last stop Tab goes round to the first, and Shift+Tab at the first to the last. With nothing focused, Tab takes the first and Shift+Tab the last.

A stop inside a hidden or disabled container is passed over, and so is one playing its exit. Tab stays on the surface holding the keyboard: in a popup, it goes round the popup’s own stops.

Tab with Ctrl, Alt or the logo key held does not move the focus; that is left to whatever shortcut it is.

Latched Modifiers

The Modifiers a key event carries describe that keystroke, which is what a shortcut needs. Caps lock is a different question: it is latched, so it stays on with nothing pressed, and something on screen may need to say so before anything has been typed — a password field warning that what goes in will be upper case.

That cannot be answered from a key event. The compositor sends the modifier update after the key that toggled it, so the press of caps lock reports the state it had before. Read the signal instead:

extern crate guido;
use guido::prelude::*;
fn main() {
container().child(move || {
    keyboard_modifiers()
        .get()
        .caps_lock
        .then(|| text("Caps lock is on"))
})
;
}

Everything is false until the compositor sends the first update, which it does when a surface takes keyboard focus.

Scroll Events

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_scroll(|dx, dy, source| {
        println!("Scroll: dx={}, dy={}", dx, dy);
    })
;
}

Parameters:

  • dx - Horizontal scroll amount
  • dy - Vertical scroll amount
  • source - Scroll source (wheel, touchpad)

Scroll with Signal

extern crate guido;
use guido::prelude::*;
fn main() {
let offset = create_signal(0.0f32);

container()
    .on_scroll(move |_dx, dy, _source| {
        offset.update(|o| *o += dy);
    })
    .child(text(move || format!("Offset: {:.0}", offset.get())))
;
}

Touch Input

Touchscreens work out of the box: the first finger down drives the same pointer pipeline as the mouse, so on_click, when_hovered, when_pressed (including ripples), and scroll all respond to touch. A tap is a click; lifting the finger clears hover state. No code changes are needed.

Cursor Shape

A container says which shape the pointer takes over it:

extern crate guido;
use guido::prelude::*;
fn main() {
let open = || {};
container()
    .cursor(CursorIcon::Pointer)
    .on_click(open)
;
}

After every pointer event the shape is resolved from where the pointer is: the innermost widget under it that declares one wins, and with none the pointer is the arrow. A text input declares the I-beam the same way, so a field inside a clickable row shows Text over itself and Pointer over the rest of the row, with nothing to reset when the pointer moves between them:

extern crate guido;
use guido::prelude::*;
fn main() {
let open = || {};
let query = create_signal(String::new());

container()
    .cursor(CursorIcon::Pointer)
    .on_click(open)
    .child(text_input(query))
;
}

Like any other property it takes a signal, and a change reaches the screen without the pointer having to move. A shape that is about a whole surface — busy while something loads — is a cursor on its root:

extern crate guido;
use guido::prelude::*;
fn main() {
let loading = create_signal(false);

container().cursor(move || {
    if loading.get() { CursorIcon::Wait } else { CursorIcon::Default }
})
;
}

CursorIcon::Hidden is a shape like the others: the pointer is still there and still reaches widgets, but nothing is drawn for it. A surface with no pointer — a lock screen, a kiosk — is Hidden on its root, and a widget inside can still claim a visible shape, because the innermost claim wins:

extern crate guido;
use guido::prelude::*;
fn main() {
let unlock = || {};
container()
    .cursor(CursorIcon::Hidden)
    .child(container().cursor(CursorIcon::Pointer).on_click(unlock))
;
}

A text input’s I-beam is the innermost claim over it, so it shows even there; a field that should not show it says so with its own cursor — see Pointer Shape.

A container declared with takes_input(false) claims no cursor: the compositor is not giving it the pointer, so whatever is beneath it answers.

Combining Events

A container can have multiple event handlers:

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let hovered = create_signal(false);

container()
    .on_click(move || count.update(|c| *c += 1))
    .on_hover(move |h| hovered.set(h))
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.ripple())
;
}

Event Propagation

Events flow through the widget tree from children to parents. A child receives events first; if it handles the event, the parent won’t receive it.

extern crate guido;
use guido::prelude::*;
fn main() {
// Inner container handles clicks, outer doesn't receive them
container()
    .on_click(|| println!("Outer - won't fire for inner clicks"))
    .child(
        container()
            .on_click(|| println!("Inner - handles click"))
            .child(text("Click me"))
    )
;
}

Hit Testing

Events only fire when the click is within the container’s bounds. Guido properly handles:

  • Corner radius - Clicks outside rounded corners don’t register
  • Transforms - Rotated/scaled containers have correct hit areas
  • Nested transforms - Parent transforms are accounted for

Complete Example

extern crate guido;
use guido::prelude::*;
fn interactive_counter() -> impl Widget {
    let count = create_signal(0);
    let scroll_offset = create_signal(0.0f32);

    container()
        .layout(Flex::column().spacing(12.0))
        .padding(16.0)
        .children([
            // Click counter
            container()
                .padding(12.0)
                .background(Color::rgb(0.3, 0.5, 0.8))
                .corners(8.0)
                .when_hovered(|s| s.lighter(0.1))
                .when_pressed(|s| s.ripple())
                .on_click(move || count.update(|c| *c += 1))
                .child(
                    container().child(text(move || format!("Clicked {} times", count.get())).color(Color::WHITE))
                ),

            // Scroll display
            container()
                .padding(12.0)
                .background(Color::rgb(0.2, 0.3, 0.2))
                .corners(8.0)
                .when_hovered(|s| s.lighter(0.05))
                .on_scroll(move |_dx, dy, _source| {
                    scroll_offset.update(|o| *o += dy);
                })
                .child(
                    container().child(text(move || format!("Scroll offset: {:.0}", scroll_offset.get())).color(Color::WHITE))
                ),
        ])
}
fn main() {}

API Reference

// not compiled: a signature listing — these declarations have no bodies.
impl Container {
    /// Handle click events
    pub fn on_click(self, handler: impl Fn() + 'static) -> Self;

    /// Handle the second click of a double click, on its release
    pub fn on_double_click(self, handler: impl Fn() + 'static) -> Self;

    /// Handle hover state changes
    pub fn on_hover(self, handler: impl Fn(bool) + 'static) -> Self;

    /// Handle a key press, or its release; each answers whether it took it
    pub fn on_key_down(
        self,
        handler: impl Fn(Key, Modifiers, bool) -> EventResponse + 'static
    ) -> Self;
    pub fn on_key_up(
        self,
        handler: impl Fn(Key, Modifiers) -> EventResponse + 'static
    ) -> Self;

    /// Make Tab stop here, and a press inside take the focus
    pub fn focusable(self, focusable: bool) -> Self;

    /// Handle scroll events
    pub fn on_scroll(
        self,
        handler: impl Fn(f32, f32, ScrollSource) + 'static
    ) -> Self;

    /// The pointer's shape over this container
    pub fn cursor<M>(self, cursor: impl IntoSignal<CursorIcon, M>) -> Self;
}

Animations

A value in guido can say how it moves. Declare a property with a motion and it eases to each new value instead of jumping there; declare it with a timeline and it plays a sequence whenever a trigger fires.

The motion rides with the value

There is no separate declaration naming a property and saying how it animates. The timing is part of what the property was set to, so it cannot name a property that was never set and cannot disagree with the property it decorates:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
    .when_hovered(|s| s.lighter(0.15))
;
}

When the hover state changes, the background animates over 200ms. The state layer supplies the value; the property carries the motion — see State Layers.

A bare number is a duration in milliseconds, eased out. Where the curve matters, name it:

extern crate guido;
use guido::prelude::*;
fn main() {
let c = Color::WHITE;
container().background(c.transition(Transition::new(200.0, TimingFunction::EaseInOut)))
;
}

.transition(..) and .timeline(..) hang off everything a property setter already accepts — a value, a signal, or a closure. The parentheses around a closure are Rust’s rule for calling a method on a closure literal, not a second API:

extern crate guido;
use guido::prelude::*;
fn main() {
let open = create_signal(false);
container()
    .width((move || if open.get() { 520.0 } else { 120.0 }).transition(200.0))
;
}

In This Section

Two Types of Animation

Duration-Based

Fixed duration with easing curve:

extern crate guido;
use guido::prelude::*;
fn main() {
Transition::new(200.0, TimingFunction::EaseOut)
;
}

Good for:

  • UI state changes (hover, pressed)
  • Color transitions
  • Border changes

Spring-Based

Physics simulation for natural motion:

extern crate guido;
use guido::prelude::*;
fn main() {
Transition::spring(SpringConfig::BOUNCY)
;
}

Good for:

  • Size changes
  • Position changes
  • Transform animations
  • Any motion that should feel physical

Quick Reference

extern crate guido;
use guido::prelude::*;
fn main() {
let c = Color::WHITE;
container()
    // Milliseconds, eased out
    .background(c.transition(200.0))
    // A curve by name
    .border(
        1.0.transition(Transition::new(150.0, TimingFunction::EaseInOut)),
        c.transition(150.0),
    )
    // Spring physics
    .scale(1.0.transition(SpringConfig::BOUNCY))
    .width(200.0.transition(Transition::spring(SpringConfig::GENTLE)))
;
}

Transitions

A transition eases a property to each new value over a fixed time, with an easing curve. It is declared on the value itself, with .transition(..).

Declaring one

extern crate guido;
use guido::prelude::*;
fn main() {
let value = Color::WHITE;
let timing = TimingFunction::EaseOut;
let duration_ms = 200.0;
let _: Animated<Color> =
value.transition(Transition::new(duration_ms, timing))
;
}
  • duration_ms - Animation duration in milliseconds
  • timing - Easing curve for the animation

A bare number is the short form: .transition(200.0) is 200ms eased out.

Examples

Background Animation

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
    .when_hovered(|s| s.lighter(0.15))
;
}

Border and Outline Animation

A border is declared as a pair and each half carries its own timing. Its width takes room inside the box, so a width that animates moves the content with it; a hover ring is an outline, which takes none, and whose three halves animate the same way:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(1.0, Color::rgb(0.3, 0.3, 0.4).transition(150.0))
    .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)
    .when_hovered(|s| s
        .border(1.0, Color::rgb(0.5, 0.5, 0.6))
        .outline(2.0, Color::rgb(0.5, 0.5, 0.6), 2.0))
;
}

Transform Animation

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scale(Scale::NONE.transition(Transition::new(300.0, TimingFunction::EaseOut)))
    .when_pressed(|s| s.scale(0.98))
;
}

The resting value is what the property is when no state layer overrides it — here Scale::NONE, the size the press shrinks away from.

Duration Guidelines

DurationUse Case
100-150msQuick feedback (button press)
150-200msState changes (hover)
200-300msContent changes (expand/collapse)
300-500msMajor transitions (page changes)

Combining with State Layers

A state layer supplies a value for a property somebody else declared, and carries no timing of its own. The property eases whatever supplies its value:

extern crate guido;
use guido::prelude::*;
const FLAT: Shadow = Shadow::none();
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3).transition(200.0))
    .shadow(LOW.transition(200.0))

    // State changes trigger the animations declared above
    .when_hovered(|s| s.lighter(0.1).shadow(RAISED))
    .when_pressed(|s| s.darker(0.05).shadow(FLAT))
;
}

Writing a timing on the override does not compile — it would be a declaration that is legal and does nothing:

// not compiled: the line is the mistake the paragraph above names.
.when_hovered(|s| s.background(HOT.transition(900.0)))   // error

Complete Example

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
const HIGH: Shadow = Shadow::simple((0.0, 6.0), 10.0, Color::rgba(0.0, 0.0, 0.0, 0.22));
fn animated_card() -> Container {
    container()
        .padding(20.0)
        .background(Color::rgb(0.15, 0.15, 0.2).transition(200.0))
        .corners(12.0)
        .border(1.0, Color::rgb(0.25, 0.25, 0.3))
        .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)
        .shadow(RAISED.transition(250.0))

        // State layers
        .when_hovered(|s| s
            .lighter(0.05)
            .outline(2.0, Color::rgb(0.4, 0.6, 0.9), 2.0)
            .shadow(HIGH)
        )
        .when_pressed(|s| s
            .darker(0.02)
            .shadow(LOW)
        )

        .child(container().child(text("Hover me!").color(Color::WHITE)))
}
fn main() {}

API Reference

/// Ease to each new value this holds
value.transition(transition: impl Into<TransitionConfig>) -> Animated<T>;

/// A bare number is milliseconds, eased out
Transition::new(duration_ms: f32, timing: TimingFunction) -> Transition;

/// Create a spring-based transition
Transition::spring(config: SpringConfig) -> Transition

Timing Functions

Timing functions (also called easing curves) control how animations progress over time.

Available Functions

Linear

Constant speed throughout:

extern crate guido;
use guido::prelude::*;
fn main() {
TimingFunction::Linear
;
}

Use for: Progress indicators, mechanical motion

EaseIn

Starts slow, accelerates:

extern crate guido;
use guido::prelude::*;
fn main() {
TimingFunction::EaseIn
;
}

Use for: Elements leaving the screen

EaseOut

Starts fast, decelerates:

extern crate guido;
use guido::prelude::*;
fn main() {
TimingFunction::EaseOut
;
}

Use for: Most UI animations - feels responsive and natural

EaseInOut

Slow start and end, fast middle:

extern crate guido;
use guido::prelude::*;
fn main() {
TimingFunction::EaseInOut
;
}

Use for: On-screen transitions, modal appearances

Visual Comparison

Linear:    ────────────────
EaseIn:    ___──────────
EaseOut:   ──────────___
EaseInOut: ___────────___

Recommendations

For State Changes (Hover, Press)

Use EaseOut - immediate response, smooth finish:

extern crate guido;
use guido::prelude::*;
fn main() {
let c = Color::WHITE;
container()
.background(c.transition(Transition::new(200.0, TimingFunction::EaseOut)))
;
}

For Expanding/Collapsing

Use EaseInOut - smooth start and stop:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.width(200.0.transition(Transition::new(300.0, TimingFunction::EaseInOut)))
;
}

For Enter Animations

Use EaseOut - quick appearance, smooth settle:

extern crate guido;
use guido::prelude::*;
fn main() {
Transition::new(250.0, TimingFunction::EaseOut)
;
}

For Exit Animations

Use EaseIn - quick exit, fades out:

extern crate guido;
use guido::prelude::*;
fn main() {
Transition::new(200.0, TimingFunction::EaseIn)
;
}

Examples

Button Hover

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.3, 0.5, 0.8).transition(Transition::new(200.0, TimingFunction::EaseOut)))
    .when_hovered(|s| s.lighter(0.1))
;
}

Card Expansion

extern crate guido;
use guido::prelude::*;
fn main() {
let expanded = create_signal(false);

container()
    .width(
        (move || if expanded.get() { 400.0 } else { 200.0 })
            .transition(Transition::new(300.0, TimingFunction::EaseInOut)),
    )
    .on_click(move || expanded.update(|e| *e = !*e))
;
}

Smooth Transform

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scale(Scale::NONE.transition(Transition::new(300.0, TimingFunction::EaseOut)))
    .when_pressed(|s| s.scale(0.98))
;
}

When to Use Springs Instead

For physical motion (bouncing, overshooting), use spring animations:

extern crate guido;
use guido::prelude::*;
fn main() {
let c = Color::WHITE;
container()
// Spring for bouncy physical motion
.scale(Scale::NONE.transition(Transition::spring(SpringConfig::BOUNCY)))

// Duration for smooth UI transitions
.background(c.transition(Transition::new(200.0, TimingFunction::EaseOut)))
;
}

See Spring Physics for more on spring animations.

API Reference

// not compiled: a listing of a type the crate owns — a copy of it declared
// here would compile without checking the original.
pub enum TimingFunction {
    Linear,
    EaseIn,
    EaseOut,
    EaseInOut,
}

Spring Physics

Spring animations use physics simulation for natural, dynamic motion. Unlike duration-based transitions, springs can overshoot and bounce.

Creating Spring Transitions

extern crate guido;
use guido::prelude::*;
fn main() {
Transition::spring(SpringConfig::BOUNCY)
;
}

Built-in Configurations

DEFAULT

Balanced spring - responsive without excessive bounce, settles in ~270ms:

extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::DEFAULT
;
}

SNAPPY

Quickest response with subtle overshoot, settles in ~185ms:

extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::SNAPPY
;
}

GENTLE

Slower and smooth - minimal overshoot, settles in ~325ms:

extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::GENTLE
;
}

BOUNCY

Energetic spring - visible bounce, settles in ~410ms:

extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::BOUNCY
;
}

When to Use Springs

Springs excel at:

  • Width/height animations - Expanding cards, accordions
  • Transform animations - Scale, rotation, translation
  • Physical feedback - Elements that should feel tangible
extern crate guido;
use guido::prelude::*;
fn main() {
// Width expansion with spring
let expanded = create_signal(false);

container()
    .width(
        (move || if expanded.get() { 600.0 } else { 50.0 })
            .transition(Transition::spring(SpringConfig::DEFAULT)),
    )
    .on_click(move || expanded.update(|e| *e = !*e))
;
}

Spring vs Duration

Use Duration For:

  • Color changes
  • Opacity changes
  • Border changes
  • Subtle state transitions

Use Springs For:

  • Size changes
  • Position changes
  • Scale transforms
  • Anything that should feel physical

Examples

Bouncy Scale

extern crate guido;
use guido::prelude::*;
fn main() {
let scale_factor = create_signal(1.0f32);
let is_scaled = create_signal(false);

container()
    .scale(scale_factor.transition(Transition::spring(SpringConfig::BOUNCY)))
    .on_click(move || {
        is_scaled.update(|s| *s = !*s);
        let target = if is_scaled.get() { 1.3 } else { 1.0 };
        scale_factor.set(target);
    })
;
}

Smooth Expansion

extern crate guido;
use guido::prelude::*;
fn main() {
let expanded = create_signal(false);

container()
    .width(
        (move || at_least(if expanded.get() { 400.0 } else { 200.0 }))
            .transition(Transition::spring(SpringConfig::GENTLE)),
    )
    .on_click(move || expanded.update(|e| *e = !*e))
;
}

Animated Rotation

extern crate guido;
use guido::prelude::*;
fn main() {
let rotation = create_signal(0.0f32);

container()
    .rotate(rotation.transition(Transition::spring(SpringConfig::DEFAULT)))
    .on_click(move || rotation.update(|r| *r += 90.0))
;
}

Combining Spring and Duration

Use different transition types for different properties:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    // Spring for physical properties
    .rotate(0.0.transition(Transition::spring(SpringConfig::BOUNCY)))
    .width(200.0.transition(Transition::spring(SpringConfig::GENTLE)))

    // Duration for color properties
    .background(Color::WHITE.transition(Transition::new(200.0, TimingFunction::EaseOut)))
    .border(1.0, Color::WHITE.transition(Transition::new(150.0, TimingFunction::EaseOut)))
;
}

Complete Example

extern crate guido;
use guido::prelude::*;
fn spring_button() -> Container {
    let pressed = create_signal(false);

    container()
        .padding(20.0)
        // Duration for color - smooth transition
        .background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
        .corners(12.0)

        // Spring for scale - bouncy feedback
        .scale(
            (move || if pressed.get() { 1.1 } else { 1.0 })
                .transition(Transition::spring(SpringConfig::BOUNCY)),
        )

        .when_hovered(|s| s.lighter(0.1))
        .on_click(move || {
            pressed.set(true);
            // In real app: trigger action and reset
        })

        .child(container().child(text("Spring!").font_size(18.0).color(Color::WHITE)))
}
fn main() {}

Interruption

A spring that is retargeted mid-flight keeps its momentum: the new segment starts with the speed the old one had, so a value sent back where it came from turns around rather than stopping first. That is what makes a spring feel physical when a hover reverses halfway, and it is also the cheapest wobble there is — send a value out and then back, and the overshoot on the way home is a shake nobody had to choreograph.

It has to be two different frames, though. A press and its release are two, so a button shakes itself:

extern crate guido;
use guido::prelude::*;
fn nudge(angle: RwSignal<f32>) -> Container {
    container()
        .rotate((move || angle.get()).transition(Transition::spring(SpringConfig::BOUNCY)))
        .on_mouse_down(move |_, _| angle.set(2.0))
        .on_mouse_up(move |_, _| angle.set(0.0))
        .child(text("shake me"))
}
fn main() {}

Two writes inside one frame would not do it: the loop reads the signal once, so it only ever sees the last of them and the spring is never sent anywhere.

Timed transitions have no momentum to keep: a retarget there starts from the current value at whatever the curve says, as it always has.

What it carries, and what it does not

The momentum that crosses into the new segment is the part of the old motion pointing along it. A translation interrupted by a pure scale change carries nothing, because the two have no direction in common — momentum stays in the channel it belonged to. And a spring that has already settled is at rest, so the next animation starts from a stop however small the residue was.

API Reference

// not compiled: a signature listing — these declarations have no bodies.
/// Spring configuration presets
pub struct SpringConfig {
    pub stiffness: f32,
    pub damping: f32,
    pub mass: f32,
}

impl SpringConfig {
    pub const DEFAULT: SpringConfig;
    pub const GENTLE: SpringConfig;
    pub const SNAPPY: SpringConfig;
    pub const BOUNCY: SpringConfig;
}

/// Create spring transition
Transition::spring(config: SpringConfig) -> Transition

Keyframes

Every other animation in guido moves towards something: a property gets a new value and a transition carries it there. That is the whole of what a state change looks like, and none of what a sequence looks like — a shake, a flash, a bounce, anything that has to pass through somewhere on its way and end where it started.

A timeline is the other shape. It has no target: it plays. Declare it with .timeline(..), on the value the property rests at between plays:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0);
container()
    .rotate(0.0.timeline(
        Keyframes::new(320.0)
            .at(0.0, 0.0)
            .at(0.15, 2.0)
            .at(0.40, -1.6)
            .at(0.65, 0.9)
            .at(1.0, 0.0)
            .played_by(rejections),
    ))
;
}

It reaches every animatable property, not only the transform components — a Keyframes<Color> on a background is a flash:

extern crate guido;
use guido::prelude::*;
fn main() {
let errors = create_signal(0);
let surface = Color::rgb(0.15, 0.15, 0.2);
container()
    .background(surface.timeline(
        Keyframes::new(240.0)
            .at(0.0, surface)
            .at(0.3, Color::rgb(0.8, 0.2, 0.2))
            .at(1.0, surface)
            .played_by(errors),
    ))
;
}

The resting value

The value .timeline(..) is called on is what the property is whenever nothing is playing. A shake returns to where it began, which makes it look redundant — but a sequence that ends somewhere else snaps back to it, and a sequence resting on a live signal has nowhere else to put its expression:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0);
let spin = create_signal(0u32);
let shake = || Keyframes::new(320.0).at(0.0, 0.0).at(0.5, 2.0).at(1.0, 0.0);
// A glyph that spins on one signal, and shakes on another.
container()
    .rotate((move || spin.get() as f32 * 90.0).timeline(shake().played_by(rejections)))
;
}

A timeline is for something that happens and is over. A change that should persist is a transition.

What plays it

A sequence plays as soon as the widget carrying it exists. That is what a wait looks like: a spinner should turn from the moment it is on screen, and there is nothing to ask it.

played_by(trigger) makes it wait instead, and then every change to that signal plays it once. A count rather than a flag, because two refusals in a row are two events and a signal that stays equal notifies nobody — the second wrong password has to shake as loudly as the first. SwiftUI’s keyframe animator takes its trigger the same way.

A sequence with a trigger plays nothing on the first frame: the container remembers what the signal held when it was built. Played again while it is still running, a sequence starts over from the top rather than continuing — half a shake is not what a second refusal means.

How long it runs

repeat(Repeat::Times(n)) plays the whole run n times and then rests, and never fewer than once — a computed n of zero is a single run. Repeat::Forever never stops:

extern crate guido;
use guido::prelude::*;
fn main() {
let busy = create_signal(true);
fn spin() -> Keyframes<f32> {
    Keyframes::new(1000.0).at(1.0, 360.0).repeat(Repeat::Forever)
}

fn spinner() -> Container {
    container().rotate(0.0.timeline(spin())).child(text("*"))
}

// On screen only while the work is outstanding.
container().child(move || busy.get().then(spinner))
;
}

There is no way to stop an endless sequence other than removing the widget that carries it, and that is the point. The widget being on screen is the animation running, so showing and hiding it is the only switch to keep in step — nothing to call off, and no way to leave a spinner turning after the work is done. Compose reaches the same place from the other direction: an infinite transition there runs for as long as the composable is in composition.

Starting late, or out of step

delay(ms) holds the first value for ms and then plays. It is paid once each time the sequence plays, not once per run: a repeat goes straight on into the next one, as animation-delay does in CSS.

phase(f) starts the first run f of the way through its cycle, which is what lets several elements share one looping definition and still move out of step — a row of equaliser bars, or loading dots:

extern crate guido;
use guido::prelude::*;
fn main() {
fn bounce(phase: f32) -> Keyframes<Scale> {
    Keyframes::new(900.0)
        .at_with(0.0, Scale::uniform(0.4), TimingFunction::EaseInOut)
        .at_with(0.5, Scale::uniform(1.0), TimingFunction::EaseInOut)
        .at(1.0, Scale::uniform(0.4))
        .phase(phase)
        .repeat(Repeat::Forever)
}

let bar = |phase| container().scale(Scale::NONE.timeline(bounce(phase)));
container()
    .layout(Flex::row().spacing(4.0))
    .children([bar(0.0), bar(0.33), bar(0.66)])
;
}

A phase is a negative animation-delay: the part of the cycle it skips is already behind it, so a sequence with a count ends that much sooner, on its last stop. It is read round the cycle — 1.25 is 0.25 — and with both, the delay holds the value the phased run begins from, so the end of the wait is not a jump.

Offsets and easing

An offset is a fraction of one run, 0.0 to 1.0, and duration is how long one run takes. The easing declared at a stop governs the segment that starts there, which is CSS’s rule for a timing function written inside a keyframe:

extern crate guido;
use guido::prelude::*;
fn main() {
Keyframes::new(360.0)
    .at_with(0.0, Translate::NONE, TimingFunction::EaseIn)
    .at_with(0.35, Translate::new(0.0, 14.0), TimingFunction::EaseOut)
    .at(1.0, Translate::NONE)
;
}

Before the first stop and after the last one the nearest stop holds — a timeline whose first stop is at 0.25 is not a timeline that starts undefined.

What it does to the declared value

A running timeline replaces it. For as long as the sequence plays, it is what the property is; when it ends, the property goes back to whatever is declared now — including a value that changed while it was playing. This is the rule CSS settled on, where an animation outranks a normal declaration for as long as it runs and hands the property back afterwards.

A timeline belongs to the one component it moves, so a card can hover and shake at the same time without the two meeting at all:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0u32);
fn shake() -> Keyframes<f32> {
    Keyframes::new(300.0).at(0.25, 6.0).at(0.75, -6.0).at(1.0, 0.0)
}
container()
    .when_hovered(|s| s.scale(1.03))
    .scale(Scale::NONE.transition(Transition::spring(SpringConfig::SNAPPY)))
    .rotate(0.0.timeline(shake().played_by(rejections)))
;
}

The hover is on scale and the shake on rotate, so both are drawn throughout: the card grows under the pointer while it is still shaking its head.

The replacing rule applies when a timeline and a declaration are on the same component:

extern crate guido;
use guido::prelude::*;
fn main() {
let rejections = create_signal(0u32);
fn shake() -> Keyframes<f32> {
    Keyframes::new(300.0).at(0.25, 6.0).at(0.75, -6.0).at(1.0, 0.0)
}
container()
    .when_hovered(|s| s.rotate(3.0))
    .rotate(0.0.timeline(shake().played_by(rejections)))
;
}

Here the hover tilt is still declared while the shake runs; it simply is not being drawn. When the sequence ends the property is handed back from wherever the sequence left it, rather than the card jumping to the tilted angle on the sequence’s last frame.

A property carries one motion: a value is declared either with a transition or with a timeline, and a second declaration of the same property replaces the whole thing — the value included. Where a card wants both a spring and a sequence, they go on two components, as in the example above.

What a segment cannot be eased with

A spring. A keyframe segment is a fixed slice of a fixed duration, which is exactly what a spring has not got — it settles when it settles. Passing one to at_with substitutes EaseInOut and says so in debug builds, rather than quietly playing the segment as a straight line.

extern crate guido;
use guido::prelude::*;
fn main() {
Keyframes::new(320.0)
    .at_with(0.0, 0.0, TimingFunction::EaseOut)
    .at(1.0, 2.0)
;
}

When not to use them

A timeline is a choreography, and most motion is not. If what you want is “go there, springily”, a spring already does it — and since a spring keeps its momentum when it is retargeted, out-and-back is a wobble without a timeline at all. Reach for keyframes when the shape of the motion is the point, not the destination.

cargo run --example keyframes_example plays both a shake and a nod from the same trigger, over a hover that stays declared throughout.

Animatable Properties

Every property on this page takes a motion the same way: .transition(..) or .timeline(..) on the value it is being set to.

Background

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3).transition(200.0))
    .when_hovered(|s| s.lighter(0.1))
;
}

Works with:

  • Solid colors
  • State layer overrides (lighter, darker, explicit)

Border

A border is declared as a pair, and each half carries its own timing. Its width takes room inside the box, so each frame of a width transition lays the container out and the content moves with the border:

extern crate guido;
use guido::prelude::*;
fn main() {
let thick = create_signal(false);
container()
    .border(
        (move || if thick.get() { 4.0 } else { 1.0 }).transition(150.0),
        Color::rgb(0.3, 0.3, 0.4),
    )
;
}

The mirror image, where only the colour moves:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(2.0, Color::rgb(0.3, 0.3, 0.4).transition(150.0))
    .when_hovered(|s| s.border(2.0, Color::rgb(0.5, 0.7, 1.0)))
;
}

Or both at once, on curves of their own — a width that springs while its colour eases is what one call could never express:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .border(
        2.0.transition(Transition::spring(SpringConfig::BOUNCY)),
        Color::rgb(0.3, 0.3, 0.4).transition(300.0),
    )
;
}

Transform

Translation, rotation and scale each animate on their own curve. There is no call that animates the three together, and declaring one says nothing about the other two:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scale(Scale::NONE.transition(Transition::new(300.0, TimingFunction::EaseOut)))
    .when_pressed(|s| s.scale(0.98))
;
}

Spring animations are especially good for transforms:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
.scale(Scale::NONE.transition(Transition::spring(SpringConfig::BOUNCY)))
;
}

Opacity

extern crate guido;
use guido::prelude::*;
fn main() {
let shown = create_signal(true);
container()
    .opacity((move || if shown.get() { 1.0 } else { 0.0 }).transition(200.0))
;
}

An opacity fades the container and everything in it, which is what an enter wants — a panel that fades in the one time it appears:

extern crate guido;
use guido::prelude::*;
fn main() {
container().opacity(1.0.transition(200.0).entering_from(0.0))
;
}

It is paint-only, like a transform: nothing around the container moves, and a container faded to nothing still takes clicks. See Opacity for what a fade does where children overlap.

A fade is cheap however much is inside it. A container whose children overlap is drawn once into a texture, and each frame of the fade lays that texture down again at the new opacity; nothing inside is painted or drawn again until it changes. One that moves by whole pixels keeps its texture too; one that moves to a new fraction of a pixel is drawn again there, so it stays as sharp as it would be without the fade. One that turns or scales keeps its texture and is resampled while it does.

Blur

extern crate guido;
use guido::prelude::*;
fn main() {
container().blur(0.0.transition(200.0).entering_from(8.0).exiting_to(8.0))
;
}

A view that arrives out of focus and sharpens — and leaves the way it came. Each frame blurs the texture the container was drawn into once, rather than drawing the container again. See Blur.

Width and Height

extern crate guido;
use guido::prelude::*;
fn main() {
let expanded = create_signal(false);

container()
    .width(
        (move || if expanded.get() { 400.0 } else { 200.0 })
            .transition(Transition::spring(SpringConfig::DEFAULT)),
    )
;
}

A size follows the content it holds as well as the length declared here, so the animation is over the resolved extent. What the box holds is laid out in the box as it is on each frame, as CSS’s interpolate-size has it, so a centred child stays centred while a box that sizes to its content grows or shrinks toward it. Content that is not natural() is laid out in that box too, and a label in it may wrap differently until the box arrives. While it does, the box keeps the size its animation gives it on both axes, even one with nowhere to go, and the content runs past it — cut off only under Overflow::Hidden — rather than the box jumping to fit a line it only wraps onto on the way.

The size a box heads for is where its content is heading, not where it is on this frame: a box around a child that animates its own width with the same transition grows and shrinks with it, frame for frame, while the child is drawn at its in-flight size. A box of this kind inside another heads, too, for where its content will be once the outer box arrives, rather than for the width the outer box is passing through, so the two run one transition each and neither jumps when it lands; a box that arrives before what it holds stays where it arrived while the rest catches up. These are the one pair that cannot carry a timeline: they declare a Length, which is not an animatable value in itself.

Position

What layout moves — a row a reorder sends elsewhere, a panel a sibling pushes down — jumps there unless it asks to slide:

extern crate guido;
use guido::prelude::*;
fn main() {
let rows = create_signal(vec![1u32, 2, 3]);

container().layout(Flex::column()).children(keyed(
    move || rows.get(),
    |id| *id,
    |id| {
        container()
            .animate_position(SpringConfig::SNAPPY)
            .child(text(format!("Row {id}")))
    },
))
;
}

The row is laid out in its new place at once, and drawn where it was, sliding over to it; nothing re-lays out while it does. It is measured inside its parent: scrolling slides nothing, and a parent that moves carries its children without sliding them. A move during a move starts from where the row is drawn, and a spring keeps its speed. The first layout places it without a slide — an entrance is entering_from’s.

A size that changes is the width and height’s own business, so something that moves and grows takes both: .animate_position(..) beside .width(w.transition(..)).

Corners and Padding

extern crate guido;
use guido::prelude::*;
fn main() {
let open = create_signal(false);
container()
    .corners((move || if open.get() { 30.0 } else { 6.0 }).transition(250.0))
    .padding(8.0.transition(200.0))
;
}

A corner transition that crosses zero curvature changes family in one frame: below zero a corner is concave and a different formula draws it. Within a family it is continuous.

Shadow

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
    .shadow(LOW.transition(200.0))
    .when_hovered(|s| s.shadow(RAISED))
;
}

All four fields interpolate — offset, blur, spread and colour — so a shadow can change colour as it grows, or slide from below a card to beside it. A list of shadows interpolates shadow by shadow, the shorter padded with shadows that draw nothing, so one arriving fades in. An inset shadow eases into another inset one; between an inset and an outer one there is nothing to draw halfway, and the list steps at the midpoint.

A timeline is written over Shadows, the type the property holds, so each stop converts with .into():

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container().shadow(Shadows::from(LOW).timeline(
    Keyframes::new(600.0)
        .at(0.0, LOW.into())
        .at(0.5, RAISED.into())
        .at(1.0, LOW.into()),
))
;
}

A shadow falls outside the box that casts it, so the container has to tell the layout how far its painting reaches — and the reach has to cover the deepest shadow it can ever cast, since a hover never re-runs layout.

That makes the two ways of lifting a card cost different amounts:

extern crate guido;
use guido::prelude::*;
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
let lifted = create_signal(false);
container()
// Constants: the deepest is RAISED whatever happens, so hovering moves only the
// paint and nothing is re-laid-out.
.shadow(LOW).when_hovered(|s| s.shadow(RAISED))

// A signal: the deepest genuinely changes when it is written, so every write
// re-runs the layout of this subtree.
.shadow(move || if lifted.get() { RAISED } else { LOW })
;
}

Both are correct, and the second is the one to reach for when the depth is driven by something other than pointer state. Prefer the state layer when it can say the same thing.

Text Colour and Size

Declared on the widget that draws the glyphs — a text or a text_input — because that is where text style is declared at all:

extern crate guido;
use guido::prelude::*;
fn main() {
let busy = create_signal(false);
text("saving…").color(
    (move || if busy.get() { Color::rgb(0.9, 0.6, 0.2) } else { Color::WHITE })
        .transition(200.0),
)
;
}

color and font_size are the two that move. A family and a weight snap to an installed face, and a stroke and a shadow are records with nothing to interpolate, so those four take values only.

Both take a timeline as readily as a transition — a label that flashes on an alert, a size that bounces once when a value changes:

extern crate guido;
use guido::prelude::*;
fn main() {
let alerts = create_signal(0u32);
text("disk full").color(Color::WHITE.timeline(
    Keyframes::new(600.0)
        .at(0.0, Color::WHITE)
        .at(0.5, Color::rgb(0.9, 0.2, 0.2))
        .at(1.0, Color::WHITE)
        .played_by(alerts),
))
;
}

An easing font_size re-measures the text on every frame it moves, which reflows whatever contains it — worth a shorter duration than a colour, and worth avoiding in a long list.

A state override supplies a value and never a timing, here as everywhere: when_hovered(|s| s.color(..)) takes a colour, and a transition on it is a compile error rather than something quietly ignored.

Arc Progress and Colour

An arc() moves its progress and its color the same way, and nothing else of it moves:

extern crate guido;
use guido::prelude::*;
fn main() {
let level = create_signal(40.0_f32);
arc()
    .size(24.0)
    .progress((move || level.get() / 100.0).transition(800.0))
    .color(
        (move || if level.get() < 20.0 { Color::rgb(0.9, 0.3, 0.3) } else { Color::WHITE })
            .transition(200.0),
    )
;
}

An easing progress redraws one shape a frame and builds nothing: the arc is drawn exactly rather than cut into triangles.

Multiple Animations

Each property carries its own, where it is declared:

extern crate guido;
use guido::prelude::*;
const FLAT: Shadow = Shadow::none();
const LOW: Shadow = Shadow::simple((0.0, 1.0), 3.0, Color::rgba(0.0, 0.0, 0.0, 0.12));
const RAISED: Shadow = Shadow::simple((0.0, 3.0), 6.0, Color::rgba(0.0, 0.0, 0.0, 0.19));
fn main() {
container()
    .background(Color::rgb(0.2, 0.2, 0.3).transition(200.0))
    .outline(2.0, Color::TRANSPARENT.transition(150.0), 2.0)
    .shadow(LOW.transition(250.0))
    .scale(Scale::NONE.transition(Transition::spring(SpringConfig::GENTLE)))

    .when_hovered(|s| s
        .lighter(0.1)
        .outline(2.0, Color::WHITE, 2.0)
        .shadow(RAISED)
    )
    .when_pressed(|s| s
        .scale(0.98)
        .shadow(FLAT)
    )
;
}

Complete Reference

PropertyDeclared onRecommended Transition
Backgroundbackground(..)Duration, EaseOut
Border widthborder(width, _)Duration, EaseOut
Border colourborder(_, colour)Duration, EaseOut
Outline width, colour, offsetoutline(width, colour, offset)Duration, EaseOut
Cornerscorners(..)Duration
Paddingpadding(..)Duration
Translatetranslate(..)Spring or Duration
Rotaterotate(..)Spring or Duration
Scalescale(..)Spring or Duration
Opacityopacity(..)Duration, EaseOut
Blurblur(..)Duration, EaseOut
Width, heightwidth(..), height(..)Spring
Position given by layoutanimate_position(..)Spring
Shadowshadow(..)Duration, EaseOut
Text colourcolor(..) on a text or text_inputDuration, EaseOut
Font sizefont_size(..) on a text or text_inputDuration
Arc progress, colourprogress(..), color(..) on an arcDuration, EaseOut

Best Practices

extern crate guido;
use guido::prelude::*;
fn main() {
container()
// Same duration for border width and color
.border(1.0.transition(150.0), Color::WHITE.transition(150.0))
;
}

Use Springs for Physical Motion

extern crate guido;
use guido::prelude::*;
fn main() {
container()
// Spring for size/position changes
.width(200.0.transition(Transition::spring(SpringConfig::DEFAULT)))
.scale(Scale::NONE.transition(Transition::spring(SpringConfig::BOUNCY)))

// Duration for visual changes
.background(Color::WHITE.transition(200.0))
;
}

Keep Animations Subtle

  • 150-300ms for most UI animations
  • Avoid overly bouncy springs in professional UIs
  • Let animations enhance, not distract

API Reference

// not compiled: a signature listing — these declarations have no bodies.
/// On everything a property setter accepts — a value, a signal, a closure.
pub trait Animate<T, M>: IntoSignal<T, M> + Sized {
    fn transition(self, transition: impl Into<TransitionConfig>) -> Animated<T>;
    fn timeline(self, keyframes: Keyframes<T>) -> Animated<T>
    where
        T: Animatable;
}

Transforms

Guido provides a complete 2D transform system for translating, rotating, and scaling widgets.

Transform Types

  • Translate - Move widgets by offset values
  • Rotate - Spin widgets around a pivot point
  • Scale - Resize widgets uniformly or non-uniformly

Quick Example

extern crate guido;
use guido::prelude::*;
fn main() {
// All three at once — they compose, in the order
// translate, then rotate, then scale.
container()
    .translate((20.0, 10.0))
    .rotate(45.0)
    .scale(1.5);

// Or one on its own.
container().translate((20.0, 10.0));  // move 20px right, 10px down
container().rotate(45.0);             // turn 45 degrees clockwise
container().scale(1.5)                // 150% size
;
}

In This Section

Key Features

  • Reactive - Transforms can use signals for dynamic updates
  • Animated - Smooth transitions with spring or duration-based animations
  • Hit Testing - Clicks correctly detect transformed bounds
  • Composable - Combine multiple transforms with proper ordering

Transform Basics

Learn the fundamental transform operations: translate, rotate, and scale.

Translation

Move a widget by offset values:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .translate((20.0, 10.0))  // Move 20px right, 10px down
;
}

Negative values move in the opposite direction:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .translate((-10.0, 0.0))  // Move 10px left
;
}

By Its Own Size

Translate::relative moves a widget by fractions of its own size — CSS’s translateX(-100%). -1.0 is one of its own widths:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .translate(Translate::relative(-1.0, 0.0))  // exactly its own width left
;
}

The fraction is resolved against the size the container is laid out at, every time it is painted, so it stays one width at any width — including one that changes, and including the first frame, before anything has been measured. Pixels and a fraction add, like CSS’s calc(8px - 100%):

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .translate(Translate { x: 8.0, ..Translate::relative(-1.0, 0.0) })
;
}

Reading the width back through a WidgetRef and translating by it is the other way to spell this, and the wrong one for a slide: the width is still zero when an entering_from plays, and a width change under a transition animates an offset that never changed. See animated transforms.

Rotation

Rotate a widget around its center (default):

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(45.0)  // Rotate 45 degrees clockwise
;
}

Degrees, always, and the number is kept as written. A full turn is 360.0 and not 0.0, two turns are 720.0, and nothing is normalised behind your back:

extern crate guido;
use guido::prelude::*;
fn main() {
container().rotate(360.0);   // one full turn — animates as one
container().rotate(-90.0)   // a quarter turn anticlockwise
;
}

That matters as soon as the angle is animated. See animated transforms.

Scale

Uniform Scale

Scale equally in both dimensions:

extern crate guido;
use guido::prelude::*;
fn main() {
container().scale(1.5);   // 150% size
container().scale(0.8)   // 80% size
;
}

Non-Uniform Scale

Scale differently on each axis:

extern crate guido;
use guido::prelude::*;
fn main() {
container().scale((2.0, 0.5))  // 200% width, 50% height
;
}

Combining Them

Declare as many as you need on the same container. They apply in one fixed order — translate, then rotate, then scale — whatever order you write them in:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(30.0)
    .translate((50.0, 0.0))
    .scale(1.2)
;
}

The order is fixed rather than taken from the call site so that two containers that declare the same three values are the same shape. It is the order CSS uses for its translate / rotate / scale properties, for the same reason.

Rotation and scale both happen about the pivot, which is the centre of the container unless you say otherwise — so rotating about a corner is .pivot(Pivot::TOP_LEFT), not a translate-rotate-translate sandwich.

Reactive Transforms

Transforms can use signals for dynamic updates:

extern crate guido;
use guido::prelude::*;
fn main() {
let rotation = create_signal(0.0f32);

container()
    .rotate(rotation)
    .on_click(move || rotation.update(|r| *r += 45.0))
;
}

When the signal changes, the container repaints. Nothing re-runs layout: all three are paint-only, so the space the container was given does not move and neither does anything around it.

Complete Example

extern crate guido;
use guido::prelude::*;
fn transform_demo() -> impl Widget {
    let rotation = create_signal(0.0f32);
    let scale_factor = create_signal(1.0f32);

    container()
        .layout(Flex::row().spacing(20.0))
        .children([
            // Static rotation
            container()
                .width(60.0)
                .height(60.0)
                .background(Color::rgb(0.8, 0.3, 0.3))
                .corners(8.0)
                .rotate(45.0)
                .child(container().child(text("45°").color(Color::WHITE))),

            // Click to rotate
            container()
                .width(60.0)
                .height(60.0)
                .background(Color::rgb(0.3, 0.6, 0.8))
                .corners(8.0)
                .rotate(rotation)
                .when_hovered(|s| s.lighter(0.1))
                .on_click(move || rotation.update(|r| *r += 45.0))
                .child(container().child(text("Click").color(Color::WHITE))),

            // Click to scale
            container()
                .width(60.0)
                .height(60.0)
                .background(Color::rgb(0.3, 0.8, 0.4))
                .corners(8.0)
                .scale(scale_factor)
                .when_hovered(|s| s.lighter(0.1))
                .on_click(move || {
                    let new = if scale_factor.get() > 1.0 { 1.0 } else { 1.3 };
                    scale_factor.set(new);
                })
                .child(container().child(text("Scale").color(Color::WHITE))),
        ])
}
fn main() {}

API Reference

Container Methods

All transform properties accept static values, signals, or closures. Integers also work (e.g., .rotate(45), .scale(2)).

// not compiled: a signature listing — these declarations have no bodies.
impl Container {
    pub fn translate<M>(self, t: impl IntoSignal<Translate, M>) -> Self;
    pub fn rotate<M>(self, degrees: impl IntoSignal<f32, M>) -> Self;
    pub fn scale<M>(self, factor: impl IntoSignal<Scale, M>) -> Self;
    pub fn pivot<M>(self, pivot: impl IntoSignal<Pivot, M>) -> Self;
}

The Value Types

A pair builds either one; a bare number builds a uniform Scale.

extern crate guido;
use guido::prelude::*;
fn main() {
Translate::NONE;                  // no displacement
Translate::new(20.0, 10.0);       // or just (20.0, 10.0)
Translate::relative(-1.0, 0.0);   // one of its own widths left

Scale::NONE;                      // unscaled — 1.0 on both axes
Scale::uniform(1.5);              // or just 1.5
Scale::new(2.0, 0.5)             // or just (2.0, 0.5)
;
}

There is no matrix in the application-facing API. The three components are what an application declares, and a matrix is what they compose into on the way to the renderer — a widget written outside the crate reaches it through guido::widget_prelude.

Pivots

By default, rotation and scale happen about the widget’s centre. A pivot moves that point.

One pivot serves both rotate and scale, as CSS gives one transform-origin to the whole group. translate ignores it: moving a box is the same movement wherever the pivot sits.

Setting a Pivot

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(45.0)
    .pivot(Pivot::TOP_LEFT)
;
}

Now the container rotates around its top-left corner instead of its center.

Built-in Pivots

PivotPosition
CENTER50%, 50% (default)
TOP_LEFT0%, 0%
TOP_RIGHT100%, 0%
BOTTOM_LEFT0%, 100%
BOTTOM_RIGHT100%, 100%
TOP50%, 0%
BOTTOM50%, 100%
LEFT0%, 50%
RIGHT100%, 50%

Visual Examples

Rotation from Different Origins

CENTER (default):        TOP_LEFT:
    ┌───┐                ┌───┐
    │ ↻ │                ↻
    └───┘

BOTTOM_RIGHT:
                         ┌───┐
                         │   │↻
                         └───┘

Examples

Rotate from Top-Left

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(80.0)
    .height(80.0)
    .background(Color::rgb(0.3, 0.5, 0.8))
    .rotate(30.0)
    .pivot(Pivot::TOP_LEFT)
;
}

Scale from Bottom-Right

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scale(1.5)
    .pivot(Pivot::BOTTOM_RIGHT)
;
}

Pivot from Top Edge

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(15.0)
    .pivot(Pivot::TOP)
;
}

Custom Origin

Specify exact percentages:

extern crate guido;
use guido::prelude::*;
fn main() {
// 25% from left, 75% from top
Pivot::percent(25.0, 75.0)
;
}

Values are percentages of the widget’s size:

  • 0.0 = left/top edge
  • 0.5 = center
  • 1.0 = right/bottom edge

Reactive Origins

Pivots can be reactive:

extern crate guido;
use guido::prelude::*;
fn main() {
let origin = create_signal(Pivot::CENTER);

container()
    .rotate(45.0)
    .pivot(origin)
    .on_click(move || {
        // Cycle through origins
        // `Pivot` carries f32 anchors, so its constants are not patterns —
        // compare, do not match.
        let current = origin.get();
        origin.set(if current == Pivot::CENTER {
            Pivot::TOP_LEFT
        } else if current == Pivot::TOP_LEFT {
            Pivot::BOTTOM_RIGHT
        } else {
            Pivot::CENTER
        });
    })
;
}

Complete Example

extern crate guido;
use guido::prelude::*;
fn origin_demo() -> impl Widget {
    container()
        .layout(Flex::row().spacing(40.0))
        .children([
            // Rotate from center (default)
            create_rotating_box(Pivot::CENTER, "Center"),

            // Rotate from top-left
            create_rotating_box(Pivot::TOP_LEFT, "Top-Left"),

            // Rotate from bottom-right
            create_rotating_box(Pivot::BOTTOM_RIGHT, "Bottom-Right"),
        ])
}

fn create_rotating_box(origin: Pivot, label: &'static str) -> Container {
    let rotation = create_signal(0.0f32);

    container()
        .layout(Flex::column().spacing(8.0))
        .children([
            container()
                .width(60.0)
                .height(60.0)
                .background(Color::rgb(0.3, 0.5, 0.8))
                .corners(8.0)
                .rotate(rotation.transition(Transition::new(300.0, TimingFunction::EaseOut)))
                .pivot(origin)
                .when_hovered(|s| s.lighter(0.1))
                .on_click(move || rotation.update(|r| *r += 45.0)),
            container().child(text(label).font_size(12.0).color(Color::WHITE)),
        ])
}
fn main() {}

API Reference

// not compiled: a signature listing — these declarations have no bodies.
impl Container {
    pub fn pivot(
        self,
        origin: impl IntoSignal<Pivot, M>
    ) -> Self;
}

impl Pivot {
    pub const CENTER: Pivot;
    pub const TOP_LEFT: Pivot;
    pub const TOP_RIGHT: Pivot;
    pub const BOTTOM_LEFT: Pivot;
    pub const BOTTOM_RIGHT: Pivot;
    pub const TOP: Pivot;
    pub const BOTTOM: Pivot;
    pub const LEFT: Pivot;
    pub const RIGHT: Pivot;

    pub fn percent(x_percent: f32, y_percent: f32) -> Pivot;  // 0-100
    pub fn px(x: f32, y: f32) -> Pivot;
}

Animated Transforms

Animate transform changes with smooth transitions.

Declaring one

The timing rides with the value: .transition(..) on whatever the component is being set to, so a component cannot be animated without being set.

extern crate guido;
use guido::prelude::*;
fn main() {
let rotation_signal = create_signal(0.0f32);
container()
    .rotate(rotation_signal.transition(Transition::new(300.0, TimingFunction::EaseOut)))
;
}

When rotation_signal changes, the transform animates smoothly.

Appearing mid-animation

transition says how a value moves. entering_from says where it starts the one time the widget appears — the CSS pair, where transition supplies the motion and @starting-style supplies the before-value.

extern crate guido;
use guido::prelude::*;
fn main() {
let open = create_signal(true);
let collapsed = Scale::new(1.0, 0.0);
container()
    .scale(
        (move || if open.get() { Scale::NONE } else { collapsed })
            .transition(Transition::spring(SpringConfig::SNAPPY))
            .entering_from(collapsed),
    )
    .pivot(Pivot::TOP)
;
}

A menu that scales open on its first layout, and closes by the same transition running the other way.

It is not transform-shaped: the verb hangs off the value, so it reaches every animatable property. A card can fade in.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgb(0.1, 0.1, 0.15).transition(200.0).entering_from(Color::TRANSPARENT))
;
}

The enter plays once, at the first layout, and is consumed there: a relayout, a resize or a state change is not an appearance and does not replay it. It needs a transition to travel with, so declaring one on a timeline is a panic rather than a value quietly ignored — a timeline already says where it starts.

Leaving

exiting_to is the other half: where a property goes when its widget is removed. A child of .child(move || ..), .children(move || ..) or keyed(..) that declares one is not torn down when it is removed. It stays where it stood, travels to its exit with the transition already named, and is disposed when it arrives.

extern crate guido;
use guido::prelude::*;
fn main() {
let step = create_signal(1.0f32);
let name = create_signal(String::from("default"));
const WIDTH: f32 = 120.0;
let slide = Transition::new(200.0, TimingFunction::EaseOut);
container().child(move || {
    container().child(text(name.get())).translate(
        Translate::NONE
            .transition(slide.clone())
            .entering_from(Translate::new(step.get_untracked() * WIDTH, 0.0))
            .exiting_to(move || Translate::new(-step.get() * WIDTH, 0.0)),
    )
})
;
}

A picker that slides the old name out one way as the new one slides in from the other. The exit is asked for when the child is removed, not when it was built, which is why it takes a closure: the direction is only known when the change that removes the child happens.

While it leaves, the child is inert. A click over it reaches whatever is under it, the focus it held is released, and a signal it read no longer relays out or repaints it — its item may already be gone. An effect created while building it is paused with it — a memo too, which keeps the value it had at removal: a write to what the effect read does not run it, and it is disposed with the child when the exit settles. In a keyed(..) list, a key that comes back while its row is leaving gets that row back: the exit is cancelled and it travels home from wherever it had got to. The row itself is not rebuilt; a .child(move || ..) inside it is re-run, as it would be for any change it read, and an effect that missed a write while the row was leaving runs once.

In a ZStack a leaving child no longer leads the stack’s size while anything staying does, so a shape that sizes to its content moves toward the view replacing it from the first frame.

Only the removed widget’s own properties are waited on. An exit declared on something inside it plays when that is the widget removed, and a container removed with no exit of its own takes a leaving child with it at once.

Duration-Based Animation

Standard easing curve transitions:

extern crate guido;
use guido::prelude::*;
fn main() {
let rotation = create_signal(0.0f32);
// Smooth ease-out rotation
container()
    .rotate(rotation.transition(Transition::new(300.0, TimingFunction::EaseOut)))
    .on_click(move || rotation.update(|r| *r += 45.0))
;
}

Spring-Based Animation

Physics simulation for bouncy, natural motion:

extern crate guido;
use guido::prelude::*;
fn main() {
let scale_signal = create_signal(1.0f32);
container()
    .scale(scale_signal.transition(Transition::spring(SpringConfig::BOUNCY)))
;
}

Spring presets:

  • SpringConfig::DEFAULT - Balanced
  • SpringConfig::GENTLE - Slower and smooth, minimal overshoot
  • SpringConfig::SNAPPY - Quickest response, subtle overshoot
  • SpringConfig::BOUNCY - Energetic with visible bounce

Examples

Click to Rotate

extern crate guido;
use guido::prelude::*;
fn main() {
let rotation = create_signal(0.0f32);

container()
    .width(80.0)
    .height(80.0)
    .background(Color::rgb(0.3, 0.6, 0.8))
    .corners(8.0)
    .rotate(rotation.transition(Transition::new(300.0, TimingFunction::EaseOut)))
    .when_hovered(|s| s.lighter(0.1))
    .when_pressed(|s| s.ripple())
    .on_click(move || rotation.update(|r| *r += 45.0))
;
}

Bouncy Scale Toggle

extern crate guido;
use guido::prelude::*;
fn main() {
let scale_factor = create_signal(1.0f32);
let is_scaled = create_signal(false);

container()
    .scale(scale_factor.transition(Transition::spring(SpringConfig::BOUNCY)))
    .on_click(move || {
        is_scaled.update(|s| *s = !*s);
        let target = if is_scaled.get() { 1.3 } else { 1.0 };
        scale_factor.set(target);
    })
;
}

Scale on Press (State Layer)

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .scale(Scale::NONE.transition(Transition::spring(SpringConfig::GENTLE)))
    .when_pressed(|s| s.scale(0.98))
;
}

Smooth Translation

extern crate guido;
use guido::prelude::*;
fn main() {
let offset_x = create_signal(0.0f32);

container()
    .translate(
        (move || (offset_x.get(), 0.0))
            .transition(Transition::new(400.0, TimingFunction::EaseInOut)),
    )
    .on_scroll(move |_, dy, _| {
        offset_x.update(|x| *x += dy * 10.0);
    })
;
}

Sliding by Its Own Width

A relative translate animates like a pixel one, and what animates is the fraction. So a panel can slide in from exactly its own width without anybody knowing the width — entering_from plays it on the first frame, before a pixel of it has been measured:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .translate(
        Translate::NONE
            .transition(Transition::new(250.0, TimingFunction::EaseOut))
            .entering_from(Translate::relative(-1.0, 0.0)),
    )
;
}

And a slide out is a signal holding where it should be:

extern crate guido;
use guido::prelude::*;
fn main() {
let open = create_signal(true);

container()
    .translate(
        (move || if open.get() { Translate::NONE } else { Translate::relative(-1.0, 0.0) })
            .transition(Transition::new(250.0, TimingFunction::EaseInOut)),
    )
;
}

If the width changes while it is out, it stays exactly one width out, at once: the declared offset did not change, so there is nothing to animate.

When to Use Each Type

Duration-Based

  • Rotation on click
  • State layer transforms
  • Predictable, controlled motion

Spring-Based

  • Scale effects that should feel physical
  • Bounce-back effects
  • Natural, dynamic interactions

Complete Example

extern crate guido;
use guido::prelude::*;
fn animated_transforms_demo() -> impl Widget {
    let rotation = create_signal(0.0f32);
    let scale = create_signal(1.0f32);
    let is_scaled = create_signal(false);

    container()
        .layout(Flex::row().spacing(20.0))
        .padding(20.0)
        .children([
            // Duration-based rotation
            container()
                .width(80.0)
                .height(80.0)
                .background(Color::rgb(0.3, 0.5, 0.8))
                .corners(8.0)
                .rotate(rotation.transition(Transition::new(300.0, TimingFunction::EaseOut)))
                .when_hovered(|s| s.lighter(0.1))
                .when_pressed(|s| s.ripple())
                .on_click(move || rotation.update(|r| *r += 45.0))
                .layout(Flex::column().center())
                .child(container().child(text("Rotate").font_size(12.0).color(Color::WHITE))),

            // Spring-based scale
            container()
                .width(80.0)
                .height(80.0)
                .background(Color::rgb(0.3, 0.8, 0.4))
                .corners(8.0)
                .scale(scale.transition(Transition::spring(SpringConfig::BOUNCY)))
                .when_hovered(|s| s.lighter(0.1))
                .when_pressed(|s| s.ripple())
                .on_click(move || {
                    is_scaled.update(|s| *s = !*s);
                    scale.set(if is_scaled.get() { 1.3 } else { 1.0 });
                })
                .layout(Flex::column().center())
                .child(container().child(text("Scale").font_size(12.0).color(Color::WHITE))),
        ])
}
fn main() {}

API Reference

// not compiled: a signature listing — these declarations have no bodies.
// The three components each take a value that may carry its own motion.
impl Container {
    pub fn translate<M>(self, t: impl IntoAnimated<Translate, M>) -> Self;
    pub fn rotate<M>(self, degrees: impl IntoAnimated<f32, M>) -> Self;
    pub fn scale<M>(self, factor: impl IntoAnimated<Scale, M>) -> Self;
}

// Where a property starts the one time its widget appears.
Animated::entering_from(self, from: T) -> Animated<T>

// Where it goes when its widget is removed, asked at removal.
Animated::exiting_to(self, to: impl IntoSignal<T, M>) -> Animated<T>

// Duration-based
Transition::new(duration_ms: f32, timing: TimingFunction) -> Transition

// Spring-based
Transition::spring(config: SpringConfig) -> Transition

Nested Transforms

Transforms compose through the widget hierarchy. A child inherits and builds upon its parent’s transform.

How Nesting Works

When a parent has a transform, children are affected:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(20.0)  // Parent rotated
    .child(
        container()
            .scale(0.8)  // Child scaled within rotated parent
            .child(text("Nested"))
    )
;
}

The child appears both rotated (from parent) and scaled (its own).

Transform Order

Parent transforms apply first, then child transforms:

World Space
    ↓
Parent Transform (rotate 20°)
    ↓
Child Space (already rotated)
    ↓
Child Transform (scale 0.8)
    ↓
Final Position

Example: Rotated Cards

extern crate guido;
use guido::prelude::*;
fn card(_label: &str) -> Container { container() }
fn main() {
container()
    .rotate(15.0)  // Tilt the whole group
    .layout(Flex::row().spacing(10.0))
    .children([
        // Each card inherits the rotation
        card("One"),
        card("Two"),
        card("Three"),
    ])
;
}

All cards appear tilted by 15°.

Example: Scaled Child with Rotation

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .width(100.0)
    .height(100.0)
    .background(Color::rgb(0.3, 0.3, 0.4))
    .rotate(30.0)
    .child(
        container()
            .width(50.0)
            .height(50.0)
            .background(Color::rgb(0.5, 0.7, 0.9))
            .scale(1.2)  // Child is 20% larger within rotated parent
    )
;
}

Hit Testing with Nested Transforms

Guido properly handles hit testing through nested transforms. A click on a nested, transformed element correctly detects the widget.

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(45.0)
    .child(
        container()
            .scale(0.8)
            .on_click(|| println!("Clicked!"))  // Works correctly
    )
;
}

Transform Independence

Each container has its own transform that doesn’t affect siblings:

// not compiled: `...` stands in for each child; this block is about the
// transforms beside them.
extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .layout(Flex::row().spacing(20.0))
    .children([
        // Each has independent transform
        container().rotate(15.0).child(...),
        container().rotate(-15.0).child(...),
        container().scale(0.9).child(...),
    ])
;
}

Complete Example

extern crate guido;
use guido::prelude::*;
fn nested_transforms_demo() -> impl Widget {
    container()
        .padding(40.0)
        .child(
            // Outer container with rotation
            container()
                .width(200.0)
                .height(200.0)
                .background(Color::rgb(0.2, 0.2, 0.3))
                .corners(16.0)
                .rotate(10.0)
                .layout(Flex::column().spacing(16.0).center())
                .children([
                    container().child(text("Parent (rotated 10°)").font_size(12.0).color(Color::WHITE)),

                    // Inner container with scale
                    container()
                        .width(120.0)
                        .height(80.0)
                        .background(Color::rgb(0.3, 0.5, 0.8))
                        .corners(8.0)
                        .scale(0.9)
                        .when_hovered(|s| s.lighter(0.1))
                        .when_pressed(|s| s.ripple())
                        .layout(Flex::column().center())
                        .child(
                            container().child(text("Child (scaled 0.9)").font_size(10.0).color(Color::WHITE))
                        ),
                ])
        )
}
fn main() {}

Caveats

Transform Origin Applies Locally

A child’s transform origin is relative to its own bounds, not the parent’s:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .rotate(45.0)  // Rotates around its own center
    .child(
        container()
            .rotate(30.0)
            .pivot(Pivot::TOP_LEFT)  // Relative to child's top-left
    )
;
}

Performance

Deep nesting with many transforms is fine for typical UIs. The transform matrices multiply efficiently.

Tips

  1. Keep it simple - Deep transform nesting can be hard to reason about
  2. Use for grouping - Apply a transform to a parent to affect all children
  3. Independent animations - Each level can have its own animated transform
// not compiled: `...` stands in for each child; this block is about the
// animations beside them.
extern crate guido;
use guido::prelude::*;
fn main() {
// Group animation
container()
    .rotate(group_rotation.transition(Transition::new(300.0, TimingFunction::EaseOut)))
    .children([
        // Individual children can have their own animations
        container()
            .scale(child_scale.transition(Transition::spring(SpringConfig::BOUNCY)))
            .child(...),
    ])
;
}

Advanced Topics

This section covers advanced patterns for building complex Guido applications.

In This Section

When You Need These

These topics become relevant when building:

  • Reusable UI libraries - Components with props and callbacks
  • Data-driven UIs - Lists that update from external sources
  • Real-time applications - System monitors, clocks, live data
  • Desktop widgets - Status bars, panels, overlays
  • Something the primitives cannot draw or arrange - a custom widget or layout

Creating Components

The #[component] macro creates reusable widgets from functions. Function parameters become props, and the function body becomes the render method.

Basic Component

extern crate guido;
use guido::prelude::*;

#[component]
pub fn button(label: String) -> impl Widget {
    container()
        .padding(12.0)
        .background(Color::rgb(0.3, 0.5, 0.8))
        .corners(6.0)
        .when_hovered(|s| s.lighter(0.1))
        .when_pressed(|s| s.ripple())
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {}

Use the component with the auto-generated builder:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(label: String) -> impl Widget {
    container()
        .padding(12.0)
        .background(Color::rgb(0.3, 0.5, 0.8))
        .corners(6.0)
        .when_hovered(|s| s.lighter(0.1))
        .when_pressed(|s| s.ripple())
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {
button().label("Click me")
;
}

The macro generates a Button struct (PascalCase) and a button() constructor function from the function name.

Props

All function parameters are props. Use #[prop(...)] attributes for special behavior.

Standard Props

Parameters without attributes become standard props with Default::default():

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(label: String) -> impl Widget {
    container().child(text(label))
}
fn main() {}
extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(label: String) -> impl Widget {
    container().child(text(label))
}
fn main() {
button().label("Required")
;
}

Props with Defaults

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(
    label: String,
    #[prop(default = Color::rgb(0.3, 0.3, 0.4))]
    background: Color,
    #[prop(default = Padding::all(8.0))]
    padding: Padding,
) -> impl Widget {
    container()
        .padding(padding)
        .background(background)
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {}

Optional — uses default if not specified:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(
    label: String,
    #[prop(default = Color::rgb(0.3, 0.3, 0.4))]
    background: Color,
    #[prop(default = Padding::all(8.0))]
    padding: Padding,
) -> impl Widget {
    container()
        .padding(padding)
        .background(background)
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {
button().label("Uses defaults");
button().label("Custom").background(Color::RED).padding(16.0)
;
}

Callback Props

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(
    label: String,
    #[prop(callback)] on_click: (),
) -> impl Widget {
    container()
        .on_click(on_click)
        .child(text(label))
}
fn main() {}

Provide closures for events:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(
    label: String,
    #[prop(callback)] on_click: (),
) -> impl Widget {
    container()
        .on_click(on_click)
        .child(text(label))
}
fn main() {
button()
    .label("Click me")
    .on_click(|| println!("Clicked!"))
;
}

Inside the body a callback prop is an Option<Callback<..>>. A Callback is a Copy handle to the closure, so it goes into as many closures as needed without being cloned, and is called with run:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn stepper(#[prop(callback)] on_change: fn(i32)) -> impl Widget {
    container()
        .layout(Flex::row().spacing(4))
        // The same handle used twice — no clone in sight
        .child(container().on_click(move || {
            if let Some(cb) = on_change { cb.run(-1) }
        }))
        .child(container().on_click(move || {
            if let Some(cb) = on_change { cb.run(1) }
        }))
}
fn main() {}

Accessing Props

In the function body, each prop is a read-only Signal<T> (which is Copy). Pass the signal directly to widget methods — this preserves reactivity so props update automatically when the caller provides reactive values (both RwSignal<T> and Signal<T> work as prop values via IntoSignal):

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(
    label: String,
    #[prop(default = Padding::all(8.0))] padding: Padding,
    #[prop(default = Color::rgb(0.3, 0.3, 0.4))] background: Color,
    #[prop(callback)] on_click: (),
) -> impl Widget {
    container()
        .padding(padding)                  // Pass Signal<Padding> directly (Copy, keeps reactivity)
        .background(background)            // Pass Signal<Color> directly (Copy, keeps reactivity)
        .on_click(on_click)         // Copy handle, no clone
        .child(text(label))
}
fn main() {}

The Body Runs Once

A component’s body executes one time, when the widget is first built, inside its own ownership scope — that is what lets the signals and effects it creates die with the component. Reactivity comes from the closures it leaves behind, not from re-running the body:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn status_chip(label: String, active: bool) -> impl Widget {
    container()
        // Reactive: the closure re-runs when `active` changes
        .background(move || if active.get() { Color::GREEN } else { Color::GRAY })
        // Reactive: the signal is passed through untouched
        .child(text(label))
}
fn main() {}

Reading a prop outside a closure takes a snapshot of it, and the component will never update:

extern crate guido;
use guido::prelude::*;
fn plain_row() -> Container { container() }
fn selected_row() -> Container { container() }
fn main() {
let active = create_signal(false);
let _snapshot =
// Wrong: the branch is decided once, for good
if active.get() { selected_row() } else { plain_row() }
;

// Right: the choice itself lives in a closure
container().child(move || {
    if active.get() { selected_row().into_any() } else { plain_row().into_any() }
})
;
}

Debug builds warn at the exact line when a prop is read this way — unless the caller passed a plain value, which cannot change anyway. If the snapshot is deliberate, get_untracked() says so and silences the warning.

Components with Children

extern crate guido;
use guido::prelude::*;
#[component]
pub fn card(
    title: String,
    #[prop(children)] children: (),
) -> impl Widget {
    container()
        .padding(16.0)
        .background(Color::rgb(0.18, 0.18, 0.22))
        .corners(8.0)
        .layout(Flex::column().spacing(8.0))
        .child(container().child(text(title).font_size(18.0).color(Color::WHITE)))
        .children(children)
}
fn main() {}

children is the same method the caller uses, and the rows land where the call is written: the title above stays above them, and anything the component adds afterwards comes after. A component that never calls it drops what it was handed.

Use with child/children methods:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn card(
    title: String,
    #[prop(children)] children: (),
) -> impl Widget {
    container()
        .padding(16.0)
        .background(Color::rgb(0.18, 0.18, 0.22))
        .corners(8.0)
        .layout(Flex::column().spacing(8.0))
        .child(container().child(text(title).font_size(18.0).color(Color::WHITE)))
        .children(children)
}
fn main() {
card()
    .title("My Card")
    .child(text("First child"))
    .child(text("Second child"))
;
}

Slot Props

Slots let a component accept named widget positions — useful for layout components like headers, sidebars, or multi-region containers:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn center_box(
    #[prop(slot)] left: (),
    #[prop(slot)] center: (),
    #[prop(slot)] right: (),
) -> impl Widget {
    container()
        .layout(Flex::row())
        .children(vec![
            left,
            center,
            right,
        ].into_iter().flatten())
}
fn main() {}

Use with the auto-generated builder methods:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn center_box(
    #[prop(slot)] left: (),
    #[prop(slot)] center: (),
    #[prop(slot)] right: (),
) -> impl Widget {
    container()
        .layout(Flex::row())
        .children(vec![left, center, right].into_iter().flatten())
}
fn main() {
center_box()
    .left(text("Left"))
    .center(text("Center"))
    .right(text("Right"))
;
}

Each slot accepts any impl Widget + 'static. Inside the function body, use the parameter name directly — it’s an Option<Box<dyn Widget>> that was automatically consumed from the slot.

Reactive Props

Props accept signals and closures:

extern crate guido;
use guido::prelude::*;
#[component]
pub fn button(
    label: String,
    #[prop(default = Color::rgb(0.3, 0.3, 0.4))] background: Color,
) -> impl Widget {
    container().background(background).child(text(label))
}
fn main() {
let count = create_signal(0);

button()
    .label(move || format!("Count: {}", count.get()))
    .background(move || {
        if count.get() > 5 {
            Color::rgb(0.3, 0.8, 0.3)
        } else {
            Color::rgb(0.3, 0.5, 0.8)
        }
    })
;
}

Complete Example

extern crate guido;
use guido::prelude::*;

#[component]
pub fn button(
    label: String,
    #[prop(default = Color::rgb(0.3, 0.3, 0.4))] background: Color,
    #[prop(default = Padding::all(8.0))] padding: Padding,
    #[prop(callback)] on_click: (),
) -> impl Widget {
    container()
        .padding(padding)
        .background(background)
        .corners(6.0)
        .when_hovered(|s| s.lighter(0.1))
        .when_pressed(|s| s.ripple())
        .on_click(on_click)
        .child(container().child(text(label).color(Color::WHITE)))
}

#[component]
pub fn card(
    title: String,
    #[prop(default = Color::rgb(0.18, 0.18, 0.22))] background: Color,
    #[prop(children)] children: (),
) -> impl Widget {
    container()
        .padding(16.0)
        .background(background)
        .corners(8.0)
        .layout(Flex::column().spacing(8.0))
        .child(container().child(text(title).font_size(18.0).color(Color::WHITE)))
        .children(children)
}

fn main() {
    App::new().run(|app| {
        let count = create_signal(0);

        let view = container()
            .padding(16.0)
            .layout(Flex::column().spacing(12.0))
            .child(
                card()
                    .title("Counter")
                    .child(container().child(text(move || format!("Count: {}", count.get())).color(Color::WHITE)))
                    .child(
                        container()
                            .layout(Flex::row().spacing(8.0))
                            .child(button().label("Increment").on_click(move || count.update(|c| *c += 1)))
                            .child(button().label("Reset").on_click(move || count.set(0)))
                    )
            );

        app.add_surface(
            SurfaceConfig::new()
                .width(400)
                .height(200)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            move || view,
        );
    });
}

When to Use Components

Components are ideal for:

  • Repeated patterns - Buttons, cards, list items
  • Configurable widgets - Same structure, different props
  • Encapsulated state - Self-contained logic
  • Team collaboration - Clear interfaces and contracts

For one-off layouts, regular functions returning impl Widget may be simpler.

Custom Widgets and Layouts

Guido ships few widgets on purpose. Container is the box — padding, colours, corners, borders, events — and Text, Image and TextInput are the things that draw content. A checkbox, a toggle, a slider are compositions of those, written as functions or as #[component]s in your own app.

Occasionally that is not enough: you need to draw something the primitives cannot express, or arrange children in a way Flex and ZStack do not. Both are extension points, and both are reachable from outside the crate.

The two preludes

guido::prelude is the application’s vocabulary. Writing a widget or a layout needs a second one:

extern crate guido;
fn main() {
use guido::prelude::*;
use guido::widget_prelude::*;
;
}

widget_prelude adds what the Widget and Layout signatures name — Tree, WidgetId, Constraints, PaintContext, RenderNode, LayoutHints, ChildPaintOptions — and Transform, which is what a widget positions what it draws with. An application never names Transform: it declares translate, rotate and scale, and those compose into one.

It also carries with_signal_tracking and JobType. Neither is needed to write a widget any more — the framework scopes both passes — but they are what a widget reaches for to claim a narrower attribution than its own pass: one dynamic-children segment rather than the whole of it.

A widget

Widget has two required methods. Everything else — event, advance_animations, reconcile_children, layout_hints, register_children, refresh_paint_bounds, and the three a removed widget is asked about its exit, begin_exit, is_exiting and cancel_exit — has a default.

A row that cannot fit its children shrinks the ones that can be narrower, and it learns how narrow from what each recorded during its layout. A widget that records nothing is never shrunk. One that can give room up — it ellipsizes, or scales what it draws — says how far with ctx.set_minimum(size) in its layout, on both axes.

event is only called with what was routed to your widget: a pointer event when the widget can be under its point or the pointer still owes it one, a key when it holds the focus or is on the way to what does. It is not offered every event, so there is no need to check whether one was meant for it — and no way to hear the keyboard from a widget nothing has focused, other than a container’s on_key_down and on_key_up. Event Routing says where each kind goes.

One of those defaults is worth knowing about if your widget draws outside the box it was given. A parent narrows its children to the visible region before painting them, and it does that by their laid-out bounds — so a widget that paints elsewhere, because it transforms itself or casts something past its edges, has to say how far. That is refresh_paint_bounds, which reports it with Tree::set_own_paint_reach: a number for the same distance on every side, or an Outsets for a reach that has a direction, like a shadow cast downward. The framework calls it the same way it calls the other two — inside your widget’s own Paint scope — so read your signals plainly here too, and a write to one repaints your widget without reflowing anything. It runs after every layout and from the paint job, because either can be the last pass to have run.

Read them untracked and you get the bug this closed: a widget culled before it ever painted has read nothing, so nothing can tell it to come back, and a transform that would carry it into view reaches no one.

Which pass you publish from follows what your reach depends on. If it moves with something layout already tracks — a size, a font — publish it from layout, which is what the built-in text widgets do for their stroke and shadow. If it moves with a paint-only property such as a transform, publish it here.

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
struct Bar {
    extent: Signal<f32>,
    measured: f32,
}

impl Widget for Bar {
    fn layout(&mut self, _ctx: &mut LayoutCtx, _constraints: Constraints) -> Size {
        let extent = self.extent.get();
        self.measured = extent;
        Size::new(extent, extent)
    }

    fn paint(&self, ctx: &mut PaintContext) {
        // Local coordinates: the parent has already placed this node.
        ctx.draw_rounded_rect(
            Rect::new(0.0, 0.0, self.measured, self.measured),
            Color::RED,
            0.0,
        );
    }
}
fn main() {}

Three things are worth spelling out.

Position lives in the Tree, not on the widget. layout measures and reports; the parent decides where the result goes. Paint therefore happens in local coordinates, with (0, 0) at the widget’s own origin.

A layout measures; it does not decide whether it runs. The framework calls it through LayoutCtx::layout_child, and that call holds the check that skips a widget with the same constraints and nothing dirty, the scope that makes the widget’s reads belong to it, the pass it is in, and the cache the size goes into. So there is nothing to remember: read the signals plainly and return a size.

A paint draws; it does not scope itself either. The framework calls it through PaintContext::paint_child, and that call holds the cull test that skips a widget nobody can see, the cache a clean one is served from, and the same scope layout gets — so a signal read while drawing belongs to this widget and repaints this widget. ctx.id() is which widget it is and ctx.tree() is the tree it is in.

The scale is the frame’s. Everything a paint draws is in logical pixels, and the renderer scales it to the surface. A widget that builds a raster of its own — a chart drawn into a buffer, a generated texture — wants one source pixel per device pixel, and ctx.scale_factor() says how many those are: the scale of the frame being painted, fractional where the compositor says so, 1 until it has said anything. Read it while painting and nothing else is needed: a scale change repaints the whole surface, and each surface answers with its own. Round rather than truncate, as the surface’s own buffer does, and keep the raster between paints if building it is not cheap — the sample below rebuilds it every time for brevity.

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
use std::sync::Arc;
struct Noise;

impl Widget for Noise {
    fn layout(&mut self, _ctx: &mut LayoutCtx, constraints: Constraints) -> Size {
        constraints.constrain(Size::new(100.0, 20.0))
    }

    fn paint(&self, ctx: &mut PaintContext) {
        let scale = ctx.scale_factor();
        let (width, height) = ((100.0 * scale).round() as u32, (20.0 * scale).round() as u32);
        let pixels: Vec<u8> = (0..width * height * 4).map(|i| (i % 251) as u8).collect();
        ctx.draw_image(
            ImageSource::Rgba { width, height, pixels: Arc::from(pixels) },
            Rect::new(0.0, 0.0, 100.0, 20.0),
            ContentFit::Fill,
            None,
        );
    }
}
fn main() {}

This is newer than it looks: until #390 a paint really did have to open its own scope, and a widget that did not subscribed to nothing — it drew once and then stayed as it was. The advice here used to say that the read fell through to the parent container. It did not.

Which pass is running is ctx.measuring(). True while a natural size is being measured, before a surface exists, which is when an animated value reads as where it is going rather than where it is. A widget that animates nothing never has to ask.

A widget that draws a shape no box describes — a chart, a custom outline — builds a Path and hands it to ctx.draw_path; Arcs & Paths has the vocabulary.

A layout

Layout has one method: given the children and the constraints, place them and report the size the parent should use.

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
struct Stagger {
    step: f32,
}

impl Layout for Stagger {
    fn layout(
        &mut self,
        ctx: &mut LayoutCtx,
        children: &[WidgetId],
        constraints: Constraints,
        origin: (f32, f32),
    ) -> Size {
        let mut size = Size::zero();
        for (i, &child_id) in children.iter().enumerate() {
            let child = ctx.layout_child(child_id, constraints).unwrap_or_default();

            let x = origin.0 + i as f32 * self.step;
            let y = origin.1 + i as f32 * self.step;
            ctx.tree().set_origin(child_id, x, y);

            size.width = size.width.max(x + child.width - origin.0);
            size.height = size.height.max(y + child.height - origin.1);
        }
        constraints.constrain(size)
    }
}
fn main() {}

It plugs into any container:

extern crate guido;
use guido::prelude::*;
use guido::widget_prelude::*;
fn card(_label: &str) -> Container { container() }
struct Stagger {
    step: f32,
}
impl Layout for Stagger {
    fn layout(
        &mut self,
        ctx: &mut LayoutCtx,
        children: &[WidgetId],
        constraints: Constraints,
        origin: (f32, f32),
    ) -> Size {
        let mut size = Size::zero();
        for (i, &child_id) in children.iter().enumerate() {
            let child = ctx.layout_child(child_id, constraints).unwrap_or_default();
            let x = origin.0 + i as f32 * self.step;
            let y = origin.1 + i as f32 * self.step;
            ctx.tree().set_origin(child_id, x, y);
            size.width = size.width.max(x + child.width - origin.0);
            size.height = size.height.max(y + child.height - origin.1);
        }
        constraints.constrain(size)
    }
}
fn main() {
container()
    .layout(Stagger { step: 8.0 })
    .child(card("one"))
    .child(card("two"))
;
}

The absence of a built-in grid is not a gap — this is where a grid goes.

If your layout reads signals of its own (a reactive spacing, an alignment that follows a setting), read them straight from layout: the container runs it inside its own tracking scope, so those reads are attributed correctly without you opening one.

ctx.tree_ref().with_widget(child_id, |w| w.layout_hints()) reports whether a child wants to fill an axis, which is what lets a layout give the remaining space to the children that asked for it, whether it is hidden, which takes no space at all, and whether it is leaving, playing its exit, which a ZStack does not let lead its size. ZStack in the guido source is the short example to read.

A layout can record the least its children can be arranged in the same way, with ctx.set_minimum, reading each child’s with ctx.tree_ref().minimum(child_id). That is what lets the container holding it give room up in a crowded row. One that records nothing keeps its container at the size it was laid out — the stagger above never shrinks — and ZStack’s, which records the largest of its children’s, is the short one to copy.

Checking your work

tests/external_widget.rs in the guido repository is a widget written against the public API only, with a test that lays it out and paints it. It is the shortest complete example of everything on this page.

Dynamic Children

Learn the different ways to add children to containers, from static to fully reactive with automatic resource cleanup.

The Model

Children come in four forms — two static, two reactive, plus a keyed variant for lists:

extern crate guido;
use guido::prelude::*;
fn build_menu(_entries: Vec<Entry>) -> Container { container() }
fn menu_widget() -> Container { container() }
fn build_rows(_items: Vec<Item>) -> Vec<Container> { Vec::new() }
#[derive(Clone)]
struct Entry { id: u32 }
#[derive(Clone, PartialEq)]
struct Item { id: u32, label: String }
fn row(_item: Item) -> Container { container() }
fn main() {
let entries = create_signal(vec![Entry { id: 1 }]);
let items = create_signal(vec![Item { id: 1, label: String::from("one") }]);
container()
    .child(text("Hello"))                                  // static single
    .child(move || build_menu(entries.get()))              // reactive single
    .children([text("A"), text("B")])                      // static list
    .children(move || build_rows(items.get()))             // reactive list
    .children(keyed(move || items.get(), |i| i.id, row))   // reactive keyed list
;
}

The rule is the one from SolidJS and Leptos: a value is static, a closure is reactive. A reactive closure re-runs when a signal it reads changes, and its result replaces the previous widget — always. There is no key comparison and no silently discarded rebuild.

Tracking is per segment: each closure re-runs only when its own signals change, never because a sibling changed.

Reactive Single Child

extern crate guido;
use guido::prelude::*;
fn build_menu(_entries: Vec<Entry>) -> Container { container() }
fn menu_widget() -> Container { container() }
#[derive(Clone)]
struct Entry;
fn main() {
let menu_entries = create_signal(Vec::<Entry>::new());
#[derive(Clone)]
struct Window;
struct State { active_window: RwSignal<Option<Window>> }
fn title_widget(_w: Window) -> Container { container() }
let state = State { active_window: create_signal(None) };
// Content rebuilt when the data changes
container().child(move || build_menu(menu_entries.get()));

// Presence: Option shows/hides — None removes the child
container().child(move || {
    state.active_window.get().map(|w| title_widget(w))
})
;
}

Because widget properties are themselves lazy closures (a text(move || ...) tracks its signal at paint time, not at construction), the reads tracked by a child closure are naturally the structural ones: the closure re-runs exactly when the shape of the content must change, while property-level updates keep flowing through the widgets without any rebuild.

Controlling granularity with memos

A closure re-runs whenever a tracked signal changes value. If your content depends on a reduction of the data (a count, a filtered subset), read a memo instead of the raw signal — memos notify only when the computed value actually changes:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq)]
struct Item { id: u32, label: String }
fn main() {
let items = create_signal(vec![Item { id: 1, label: String::from("one") }]);
let count = create_memo(move || items.with(|l| l.len()));

// Rebuilt only when the count changes, not on every list edit
container().child(move || text(format!("{} items", count.get())))
;
}

Reactive Lists

The unkeyed closure form replaces all rows when it re-runs:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq)]
struct Item { id: u32, label: String }
fn row_widget(_item: &Item) -> Container { container() }
fn main() {
let items = create_signal(vec![Item { id: 1, label: String::from("one") }]);
container().children(move || {
    items.with(|list| {
        list.iter().map(|item| row_widget(item)).collect::<Vec<_>>()
    })
})
;
}

Rows have no identity across runs: inner state (hover, animations, local signals) does not survive a re-run. That is fine for simple rows; for rows that carry state, use keyed():

extern crate guido;
use guido::prelude::*;
fn item_widget(_item: Item) -> Container { container() }
#[derive(Clone, PartialEq)]
struct Item { id: u32, label: String }
fn main() {
let items = create_signal(vec![Item { id: 1, label: String::from("one") }]);
container().children(keyed(
    move || items.get(),   // tracked; items: Clone + PartialEq
    |item| item.id,        // stable identity (survives reorders)
    |item| item_widget(item),
))
;
}

Per item, identity (key) and content (PartialEq) are diffed separately:

  • new key → the builder runs
  • known key, item unchanged → the existing widget is kept, including across reorders (state and animations persist)
  • known key, item changed → only that row is rebuilt
  • key gone → the widget is dropped with owner cleanup

Keys must be unique and stable — never use the index:

extern crate guido;
use guido::prelude::*;
#[derive(Clone)]
struct Row { id: u32, name: String }
impl PartialEq for Row { fn eq(&self, other: &Self) -> bool { self.id == other.id } }
fn build(_row: Row) -> Container { container() }
fn build_at(_row: (usize, Row)) -> Container { container() }
fn main() {
let data = create_signal(Vec::<Row>::new());
keyed(move || data.get(), |item| item.id, build);          // Good: stable identity
keyed(move || data.get(), |item| item.name.clone(), build); // Good: any Hash + Eq key
keyed(move || data.get().into_iter().enumerate(), |(index, _)| *index, build_at)  // Bad: reorder loses state
;
}

The key is anything Hash + Eq + Clone, and rows are indexed by the key itself rather than by a hash of it, so two distinct keys are never reconciled as one.

The item type chooses the update granularity: fields included in the item trigger a row rebuild when they change; fields left out can be read via signals inside the row for in-place updates.

Automatic Ownership & Cleanup

Reactive closures and keyed builders run inside an owner scope: signals and effects created there are automatically owned and cleaned up when the child is removed or rebuilt.

// not compiled: `log` is the caller's crate, not one the book is
// compiled against.
fn item_widget(item: Item) -> impl Widget {
    // This signal is OWNED by this row
    let clicks = create_signal(0);

    // Register cleanup for non-reactive resources
    on_cleanup(move || {
        log::info!("Item {} removed", item.id);
    });

    container()
        .padding(8.0)
        .when_hovered(|s| s.lighter(0.1))
        .on_click(move || clicks.update(|c| *c += 1))
        .child(text(move || format!("{} (clicks: {})", item.name, clicks.get())))
}

container().children(keyed(move || items.get(), |i| i.id, item_widget))

When a child is removed or rebuilt:

  1. The widget is dropped
  2. on_cleanup callbacks run
  3. Effects are disposed
  4. Signals are disposed

State that must survive a rebuild belongs in signals created outside the closure.

Complete Example

extern crate guido;
use guido::prelude::*;
fn item_widget(item: Item) -> impl Widget {
    container().child(text(move || item.name.clone()))
}
#[derive(Clone, PartialEq)]
struct Item {
    id: u64,
    name: String,
}

fn dynamic_list_demo() -> impl Widget {
    let items = create_signal(vec![
        Item { id: 1, name: "First".into() },
        Item { id: 2, name: "Second".into() },
        Item { id: 3, name: "Third".into() },
    ]);
    let next_id = create_signal(4u64);

    container()
        .padding(16.0)
        .layout(Flex::column().spacing(12.0))
        .child(
            container()
                .layout(Flex::row().spacing(8.0))
                .children([
                    button("Add", move || {
                        let id = next_id.get();
                        next_id.set(id + 1);
                        items.update(|list| {
                            list.push(Item { id, name: format!("Item {}", id) });
                        });
                    }),
                    button("Remove Last", move || {
                        items.update(|list| { list.pop(); });
                    }),
                    button("Reverse", move || {
                        items.update(|list| { list.reverse(); });
                    }),
                ])
        )
        .child(
            container()
                .layout(Flex::column().spacing(4.0))
                .children(keyed(move || items.get(), |i| i.id, item_widget))
        )
}

fn button(label: &str, on_click: impl Fn() + 'static) -> Container {
    container()
        .padding(8.0)
        .background(Color::rgb(0.3, 0.3, 0.4))
        .corners(4.0)
        .when_hovered(|s| s.lighter(0.1))
        .when_pressed(|s| s.ripple())
        .on_click(on_click)
        .child(container().child(text(label).color(Color::WHITE)))
}
fn main() {}

Mixing Static and Dynamic

Combine static and dynamic children freely, in any order:

extern crate guido;
use guido::prelude::*;
fn warning_banner() -> Container { container() }
fn item_view(_item: Item) -> Container { container() }
#[derive(Clone, PartialEq)]
struct Item { id: u32, label: String }
fn main() {
let items = create_signal(vec![Item { id: 1, label: String::from("one") }]);
let warning = create_signal(false);
container()
    .layout(Flex::column().spacing(8.0))
    // Static header
    .child(container().child(text("Items:").font_size(18.0).color(Color::WHITE)))
    // Reactive middle
    .child(move || warning.get().then(|| warning_banner()))
    // Reactive keyed list
    .children(keyed(move || items.get(), |i| i.id, item_view))
    // Static footer
    .child(container().child(text("End of list").color(Color::rgb(0.6, 0.6, 0.7))))
;
}

API Reference

// not compiled: a signature listing — these declarations have no bodies.
impl Container {
    // A widget value, or a reactive closure returning a widget / Option<widget>
    pub fn child<M>(self, child: impl IntoChild<M>) -> Self;

    // An iterator of widgets, a reactive closure returning one,
    // keyed(data, key, build), or the ChildrenSource a component was handed
    pub fn children<M>(self, children: impl IntoChildren<M>) -> Self;
}

// Reactive keyed children list: tracked data, key-based identity,
// untracked per-item builder with content diffing.
pub fn keyed<T, I, K, W>(
    data: impl Fn() -> I + 'static,
    key: impl Fn(&T) -> K + 'static,
    build: impl Fn(T) -> W + 'static,
) -> KeyedChildren<T, I, K, W>
where
    T: Clone + PartialEq + 'static,
    I: IntoIterator<Item = T>,
    K: Hash + Eq + Clone + 'static,
    W: Widget + 'static;

// Cleanup registration (use inside reactive closures and builders)
pub fn on_cleanup(f: impl FnOnce() + 'static);

Background Tasks

Guido signals (RwSignal<T> and Signal<T>) live on the main thread and are !Send — they cannot be captured directly in background tasks. To update signals from a background task, call .writer() on an RwSignal<T> to obtain a WriteSignal<T>, which is Send. Writes through a WriteSignal are queued and applied on the main thread during the next frame.

A wait whose callback only needs the UI — a delay, a debounce, a clock tick — does not need any of this: set_timeout and set_interval run their callback on the UI thread, where signals can be read as well as written.

create_task and create_service spawn async background work that is automatically cleaned up when the component unmounts — create_task for work that only pushes into signals, create_service when the UI also sends it commands. Both run as tokio tasks: if your main already runs inside a tokio runtime (e.g. #[tokio::main]), that runtime is used; otherwise guido lazily starts a small background runtime of its own, so a plain fn main() works too.

Basic Pattern: A Task That Only Pushes

For services that only push data to signals (no commands from UI):

// not compiled: the task body is written against tokio and the reader's
// own crates, which the book is not compiled against.
extern crate guido;
use guido::prelude::*;
fn main() {
use std::time::Duration;

let time = create_signal(String::new());
let time_w = time.writer(); // Get a Send-able write handle

// A task has no command channel, so no command type and no receiver
create_task(move |ctx| async move {
    while ctx.is_running() {
        time_w.set(chrono::Local::now().format("%H:%M:%S").to_string());
        tokio::time::sleep(Duration::from_secs(1)).await;
    }
});

// The task automatically stops when the component unmounts
;
}

Bidirectional Service

For services that also receive commands from the UI, use tokio::select! for efficient async multiplexing:

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
enum Cmd {
    Refresh,
    SetInterval(u64),
}

let data = create_signal(String::new());
let data_w = data.writer(); // Get a Send-able write handle

let service = create_service(move |mut rx, ctx| async move {
    let mut interval = Duration::from_secs(1);

    loop {
        tokio::select! {
            Some(cmd) = rx.recv() => {
                match cmd {
                    Cmd::Refresh => {
                        data_w.set(fetch_data());
                    }
                    Cmd::SetInterval(secs) => {
                        interval = Duration::from_secs(secs);
                    }
                }
            }
            _ = tokio::time::sleep(interval) => {
                if !ctx.is_running() { break; }
                data_w.set(fetch_data());
            }
        }
    }
});

// Send commands from UI callbacks
container()
    .on_click(move || service.send(Cmd::Refresh))
    .child(text("Refresh"))

Complete Example: System Monitor

// not compiled: the task body is written against tokio and the reader's
// own crates, which the book is not compiled against.
extern crate guido;
use guido::prelude::*;
use std::time::Duration;

fn main() {
    App::new().run(|app| {
        // Signals for system data
        let cpu_usage = create_signal(0.0f32);
        let memory_usage = create_signal(0.0f32);
        let time = create_signal(String::new());

        // Get Send-able write handles for the background task
        let cpu_w = cpu_usage.writer();
        let mem_w = memory_usage.writer();
        let time_w = time.writer();

        // Background monitoring service
        create_task(move |ctx| async move {
            while ctx.is_running() {
                // Simulate system monitoring
                cpu_w.set(rand::random::<f32>() * 100.0);
                mem_w.set(rand::random::<f32>() * 100.0);
                time_w.set(chrono::Local::now().format("%H:%M:%S").to_string());

                tokio::time::sleep(Duration::from_secs(1)).await;
            }
        });

        // Build UI
        let view = container()
            .layout(Flex::column().spacing(8.0))
            .padding(16.0)
            .children([
                container().child(text(move || format!("CPU: {:.1}%", cpu_usage.get())).color(Color::WHITE)),
                container().child(text(move || format!("Memory: {:.1}%", memory_usage.get())).color(Color::WHITE)),
                container().child(text(move || format!("Time: {}", time.get())).color(Color::WHITE)),
            ]);

        app.add_surface(
            SurfaceConfig::new()
                .width(200)
                .height(100)
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            move || view,
        );
    });
}

Multiple Services

You can create multiple independent services:

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
let weather = create_signal(String::new());
let news = create_signal(String::new());

let weather_w = weather.writer();
let news_w = news.writer();

// Weather service
create_task(move |ctx| async move {
    while ctx.is_running() {
        weather_w.set(fetch_weather());
        tokio::time::sleep(Duration::from_secs(300)).await; // Every 5 minutes
    }
});

// News service
create_task(move |ctx| async move {
    while ctx.is_running() {
        news_w.set(fetch_news());
        tokio::time::sleep(Duration::from_secs(60)).await; // Every minute
    }
});
;
}

Error Handling

Handle errors from background services:

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
enum DataState {
    Loading,
    Success(String),
    Error(String),
}

let status = create_signal(DataState::Loading);
let status_w = status.writer();

create_task(move |ctx| async move {
    while ctx.is_running() {
        match fetch_data() {
            Ok(data) => status_w.set(DataState::Success(data)),
            Err(e) => status_w.set(DataState::Error(e.to_string())),
        }
        tokio::time::sleep(Duration::from_secs(1)).await;
    }
});

// In UI
text(move || match status.get() {
    DataState::Loading => "Loading...".to_string(),
    DataState::Success(s) => s,
    DataState::Error(e) => format!("Error: {}", e),
})

Timer Example

Simple clock using a service:

// not compiled: the task body is written against tokio and the reader's
// own crates, which the book is not compiled against.
extern crate guido;
use guido::prelude::*;
fn main() {
let time = create_signal(String::new());
let time_w = time.writer();

create_task(move |ctx| async move {
    while ctx.is_running() {
        let now = chrono::Local::now();
        time_w.set(now.format("%H:%M:%S").to_string());
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
});

let view = container()
    .padding(20.0)
    .child(container().child(text(move || time.get()).font_size(48.0).color(Color::WHITE)));
;
}

Reading UI State From a Task: watch()

writer() carries values from a task to the UI. The other direction — a task that needs to follow something the UI decides — is watch(), which returns a tokio::sync::watch::Receiver<T>: Send, always holding the current value, and awaitable.

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
let menu_open = create_memo(move || active_menu.get() == Some(Menu::SystemInfo));
let mut open_rx = menu_open.watch();

create_task(move |ctx| async move {
    while ctx.is_running() {
        // Current value, no polling
        let scope = if *open_rx.borrow_and_update() { Scope::All } else { Scope::Bar };
        sample(scope);

        // Sleep, but wake early if the UI changes its mind
        tokio::select! {
            _ = tokio::time::sleep(interval) => {}
            changed = open_rx.changed() => {
                if changed.is_err() {
                    return;   // the scope that owns the watcher is gone
                }
            }
        }
    }
});
;
}

Watching a Memo is usually what you want: it only notifies when the derived value actually changes, so the task wakes on real transitions rather than on every keystroke behind them.

This replaces the Arc<AtomicBool> mirrored by an effect that apps otherwise write by hand — and unlike the atomic, the task can wait on the change instead of polling for it.

Best Practices

Use tokio::select! for Responsive Shutdown

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
// Good: select! wakes on either event
loop {
    tokio::select! {
        Some(cmd) = rx.recv() => {
            handle_command(cmd);
        }
        _ = tokio::time::sleep(Duration::from_secs(1)) => {
            if !ctx.is_running() { break; }
            // periodic work
        }
    }
}

// Also fine for simple loops
while ctx.is_running() {
    // do work
    tokio::time::sleep(Duration::from_millis(50)).await;
}
;
}

Batch Signal Updates

If multiple signals update together, update them in sequence:

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
let cpu_w = cpu.writer();
let memory_w = memory.writer();
let disk_w = disk.writer();

create_task(move |ctx| async move {
    while ctx.is_running() {
        let data = fetch_all_data();

        // All updates happen before next render
        cpu_w.set(data.cpu);
        memory_w.set(data.memory);
        disk_w.set(data.disk);

        tokio::time::sleep(Duration::from_secs(1)).await;
    }
});
;
}

The Service Handle Is Copy

Service<Cmd> is a handle into the reactive arena, so it goes into as many callbacks as you like without a clone:

// not compiled: `create_service(...)` is elided — the service itself is the
// subject of the block above.
extern crate guido;
use guido::prelude::*;
fn main() {
let service = create_service(...);

container()
    .child(
        container()
            .on_click(move || service.send(Cmd::Action1))
            .child(text("Action 1"))
    )
    .child(
        container()
            .on_click(move || service.send(Cmd::Action2))
            .child(text("Action 2"))
    )
;
}

To send commands from inside another background task, take the Send half with service.sender() — the handle itself is main-thread only, like signals.

Widget Ref

A WidgetRef is a handle from application code to one widget in the tree. It answers two questions the composed view cannot answer on its own: where that widget ended up, and where the keyboard should go.

Creating a WidgetRef

extern crate guido;
fn main() {
use guido::prelude::*;

let module_ref = create_widget_ref();
;
}

This creates a WidgetRef with an internal Signal<Rect> initialized to Rect::default() (all zeros). The signal is updated automatically on every frame that may have moved the widget.

Attaching to a Container

Attach the ref to a container using the .widget_ref() builder method:

extern crate guido;
use guido::prelude::*;
fn main() {
let module_ref = create_widget_ref();
let module = container()
    .widget_ref(module_ref)
    .padding(8.0)
    .background(Color::rgb(0.2, 0.2, 0.3))
    .child(text("System Info"));
;
}

Reading Bounds Reactively

Read the bounds via .rect(), which returns a Signal<Rect>:

extern crate guido;
use guido::prelude::*;
fn main() {
let module_ref = create_widget_ref();
let bounds_text = text(move || {
    let r = module_ref.rect().get();
    format!("x={:.0} y={:.0} w={:.0} h={:.0}", r.x, r.y, r.width, r.height)
});
;
}

The Rect is where the widget is drawn, relative to the surface origin:

  • x, y — its top-left corner
  • width, height — its size

That is the box it was laid out in, moved by the scroll offset of every scroll area it is in and by its transforms and its ancestors’. A row in a scrolled list reports the row you see, and a scaled widget its scaled size; a rotated one reports the axis-aligned box around it. A shadow is not part of it. The signal changes when any of those move — after a scroll or an animation frame as well as after layout. A pressed or hovered state that scales the widget moves it too, so a popup anchored to a button that shrinks while pressed follows it.

Do not move a widget by its own rect, or by the rect of something it carries: each frame’s rect would move it again, and it would never settle.

The Box Layout Gave It

.layout_rect() is the same box with every transform taken out — the widget’s own translate, scale and rotate, its ancestors’, and their animations. It still moves with the scroll of every scroll area the widget is in. A transform animating across frames changes rect() on each of them and never writes layout_rect(); a layout that moves or resizes the widget writes it once.

That is the one to read when a widget’s transform is driven from its own geometry — a hand-written animation from where an element was to where layout now puts it — because rect() there would read the transform back:

extern crate guido;
use guido::prelude::*;
fn main() {
let card_ref = create_widget_ref();
let target = move || card_ref.layout_rect().get();
let _ = target;
}

Positioning a Popup

A common use case is positioning a popup centered under a clickable module:

extern crate guido;
use guido::prelude::*;
const BAR_HEIGHT: f32 = 32.0;
const POPUP_WIDTH: f32 = 200.0;
const SCREEN_WIDTH: f32 = 1920.0;
fn popup_content() -> Container { container() }
fn main() {
let show_popup = create_signal(false);
let module_ref = create_widget_ref();

// The module in the status bar
let module = container()
    .widget_ref(module_ref)
    .on_click(move || show_popup.set(true))
    .child(text("Menu"));

// The popup, centered under the module
let popup = container()
    .translate(move || {
        let r = module_ref.rect().get();
        let midpoint = r.x + r.width / 2.0;
        let x = (midpoint - POPUP_WIDTH / 2.0).clamp(8.0, SCREEN_WIDTH - POPUP_WIDTH - 8.0);
        // Both halves of the pair are the same numeric type — `BAR_HEIGHT`
        // here is an `f32`, as `x` is.
        (x, BAR_HEIGHT)
    })
    .child(popup_content());
;
}

Moving the Keyboard

Attach the ref to the widget that takes focus — a text_input, or a container that declares focusable(true) — and ask:

extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
let field = create_widget_ref();

container()
    .layout(Flex::column().spacing(8.0))
    .child(text_input(value).widget_ref(field))
    .child(
        container()
            .on_click(move || field.focus())
            .child(text("Edit")),
    )
;
}

focus() lands on the next frame, not inside the call. Focus is resolved against the tree — that is where a widget’s ancestors are, and when_focused needs them — and the composing code has no tree. Every toolkit arrives at the same shape: Compose’s FocusRequester must be called from an effect, iced’s text_input::focus(id) returns a Task for the runtime to run, and Flutter tells you to request from a post-frame callback because during initState the widget is not mounted.

Because of that, asking early is allowed: a request that names a widget which has not been laid out yet waits for it rather than being dropped, so focus() in a startup effect does what it looks like. Two requests in one frame are two answers to the same question, and the later one wins.

A request does not wait forever, though, and the difference matters: one whose widget has since left the tree is dropped, and so is one whose ref belonged to a scope that is gone — .focus() from inside a popup that closed before the frame came round. Both are waiting for something that already happened, and a request left parked would fire at whatever took that ref next.

The rest of the verb:

extern crate guido;
use guido::prelude::*;
fn main() {
let field = create_widget_ref();
field.blur();          // give the keyboard back, if this widget has it
field.is_focused();    // reactive when read in a tracked scope
field.widget();        // Some(WidgetId) once laid out; None before, and None
                       // again once the widget leaves or the ref's scope dies
;
}

For a field that should simply be ready when the screen appears, there is no need for a handle at all — use autofocus().

When Not to Use It

A WidgetRef rect is written after layout, on the frame’s way to paint, so a property derived from one lags a frame — and reads zero on the first frame, before any layout has run. That is fine for positioning a popup, and wrong for sizing. Two common cases have direct layout support instead:

  • A bar proportional to a value (slider fill, gauge, progress): use fraction(), resolved against the real constraints during layout.
  • A widget sized to a sibling (background, badge, decoration): stack them with ZStack and give the follower fill().

Edge Cases

  • Before first layout: The signal returns Rect::default() (all zeros)
  • Widget removal: The registry entry is automatically cleaned up
  • Cross-surface reads: Works naturally since all surfaces share the main thread. Surface B may read a one-frame-old value if it renders before surface A

Wayland Layer Shell

Guido uses the Wayland layer shell protocol for positioning widgets on the desktop. This enables status bars, panels, overlays, and multi-surface applications.

Surface Configuration

Each surface is configured using SurfaceConfig:

extern crate guido;
use guido::prelude::*;
fn view() -> Container { container() }
fn main() {
App::new().run(|app| {
    let _surface_id = app.add_surface(
        SurfaceConfig::new()
            .width(1920)
            .height(32)
            .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
            .layer(Layer::Top)
            .keyboard_interactivity(KeyboardInteractivity::OnDemand)
            .namespace("my-status-bar")
            .background_color(Color::rgb(0.1, 0.1, 0.15)),
        || view(),
    );
});
;
}

Note: run() takes a setup closure where you add surfaces. add_surface() returns a SurfaceId that can be used to get a SurfaceHandle for dynamic property modification; the surface’s own tree reads its handle with expect_context::<SurfaceHandle>() instead (see A Surface’s Own Handle).

Layers

Control where your surface appears in the stacking order:

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new().layer(Layer::Top)
;
}
LayerDescription
BackgroundBelow all windows
BottomAbove background, below windows
TopAbove windows (default)
OverlayAbove everything

Use Cases

  • Background: Desktop widgets, wallpaper effects
  • Bottom: Dock bars (below windows but above background)
  • Top: Status bars, panels (above windows)
  • Overlay: Notifications, lock screens

Keyboard Interactivity

Control how the surface receives keyboard focus:

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new().keyboard_interactivity(KeyboardInteractivity::OnDemand)
;
}
ModeDescription
NoneSurface never receives keyboard focus
OnDemandSurface receives focus when clicked (default)
ExclusiveSurface grabs keyboard focus exclusively

Use Cases

  • None: Status bars that only respond to mouse
  • OnDemand: Panels with text input fields
  • Exclusive: Lock screens, app launchers, modal dialogs

Anchoring

Control which screen edges the surface attaches to:

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new().anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
;
}
AnchorEffect
TOPAttach to top edge
BOTTOMAttach to bottom edge
LEFTAttach to left edge
RIGHTAttach to right edge

Common Patterns

Top status bar (full width):

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new()
    .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
    .height(32)
;
}

Bottom dock (full width):

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new()
    .anchor(Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT)
    .height(48)
;
}

Left sidebar (full height):

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new()
    .anchor(Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT)
    .width(64)
;
}

Corner widget (top-right):

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new()
    .anchor(Anchor::TOP | Anchor::RIGHT)
    .width(200)
    .height(100)
;
}

Centered floating (no anchors):

extern crate guido;
use guido::prelude::*;
fn main() {
// No anchor = centered on screen
SurfaceConfig::new()
    .width(400)
    .height(300)
;
}

Size Behavior

Size depends on anchoring:

  • Anchored dimension: Expands to fill (e.g., width when LEFT+RIGHT anchored)
  • Unanchored dimension: Uses specified size
  • No anchors: Uses exact size, centered on screen
extern crate guido;
use guido::prelude::*;
fn main() {
// Width fills screen, height is 32px
SurfaceConfig::new()
    .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
    .height(32);

// Both dimensions specified, widget is 200x100
SurfaceConfig::new()
    .anchor(Anchor::TOP | Anchor::RIGHT)
    .width(200)
    .height(100)
;
}

Namespace

Identify your surface to the compositor:

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new().namespace("my-app-name")
;
}

Some compositors use this for:

  • Workspace rules
  • Blur effects
  • Per-app settings

Exclusive Zones

Reserve screen space (windows won’t overlap):

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new()
    .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
    .height(32)
    .exclusive_zone(32)  // Reserve 32px at top
;
}

Without exclusive zone, windows can cover the surface.

The compositor adds a margin on the anchored edge on top of the zone (wlroots compositors such as sway, and smithay ones such as niri, both do), so .margin([8, 0, 0, 0]) with .exclusive_zone(32) keeps windows 40px from the top. ExclusiveZone::Auto reserves the surface’s own size plus the margin facing away from the edge: a gap below a top bar is .margin([0, 0, 8, 0]).

Multi-Surface Applications

Guido supports creating multiple surfaces within a single application. All surfaces share the same reactive state, allowing for coordinated updates.

Multiple Static Surfaces

Define multiple surfaces at startup:

extern crate guido;
use guido::prelude::*;
fn main() {
    App::new().run(|app| {
        // Shared reactive state
        let count = create_signal(0);

        // Top status bar
        app.add_surface(
            SurfaceConfig::new()
                .height(32)
                .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
                .layer(Layer::Top)
                .namespace("status-bar")
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            move || {
                container()
                    .height(fill())
                    .layout(
                        Flex::row()
                            .main_alignment(MainAlignment::SpaceBetween)
                            .cross_alignment(CrossAlignment::Center)
                    )
                    .padding([0.0, 16.0])
                    .child(text("Status Bar"))
                    .child(text(move || format!("Count: {}", count.get())))
            },
        );

        // Bottom dock
        app.add_surface(
            SurfaceConfig::new()
                .height(48)
                .anchor(Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT)
                .layer(Layer::Top)
                .namespace("dock")
                .background_color(Color::rgb(0.15, 0.15, 0.2)),
            move || {
                container()
                    .height(fill())
                    .layout(Flex::row().spacing(16.0).center())
                    .child(
                        container()
                            .padding([8.0, 16.0])
                            .background(Color::rgb(0.3, 0.3, 0.4))
                            .corners(8.0)
                            .when_hovered(|s| s.lighter(0.1))
                            .on_click(move || count.update(|c| *c += 1))
                            .child(container().child(text("+").color(Color::WHITE)))
                    )
            },
        );
    });
}

Key Points

  • Shared State: All surfaces share the same reactive signals
  • Independent Widget Trees: Each surface has its own widget tree
  • Fill Layout: Use height(fill()) to make containers expand to fill the surface

Dynamic Surfaces

Create and destroy surfaces at runtime using spawn_surface():

extern crate guido;
use guido::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    App::new().run(|app| {
        let popup_handle: Rc<RefCell<Option<SurfaceHandle>>> = Rc::new(RefCell::new(None));
        let popup_clone = popup_handle.clone();

        app.add_surface(
            SurfaceConfig::new()
                .height(32)
                .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT),
            move || {
                container()
                    .child(
                        container()
                            .padding(8.0)
                            .when_hovered(|s| s.lighter(0.1))
                            .on_click({
                                let popup_handle = popup_clone.clone();
                                move || {
                                    let mut handle = popup_handle.borrow_mut();
                                    if let Some(h) = handle.take() {
                                        // Close existing popup
                                        h.close();
                                    } else {
                                        // Create new popup
                                        let new_handle = spawn_surface(
                                            SurfaceConfig::new()
                                                .width(200)
                                                .height(300)
                                                .anchor(Anchor::TOP | Anchor::RIGHT)
                                                .layer(Layer::Overlay)
                                                .keyboard_interactivity(KeyboardInteractivity::Exclusive),
                                            || {
                                                container()
                                                    .padding(16.0)
                                                    .child(text("Popup Content"))
                                            }
                                        );
                                        *handle = Some(new_handle);
                                    }
                                }
                            })
                            .child(text("Toggle Popup"))
                    )
            },
        );
    });
}

SurfaceHandle API

The SurfaceHandle allows controlling a surface after creation:

// not compiled: a signature listing — these declarations have no bodies.
impl SurfaceHandle {
    /// Close and destroy the surface. Input already queued for it is
    /// not delivered once this is called. On a lock cover it does nothing
    /// but log a warning: `unlock_session()` is what takes covers away
    pub fn close(&self);

    /// Get the surface ID
    pub fn id(&self) -> SurfaceId;

    /// Change the layer (Background, Bottom, Top, Overlay)
    pub fn set_layer(&self, layer: Layer);

    /// Change keyboard interactivity mode
    pub fn set_keyboard_interactivity(&self, mode: KeyboardInteractivity);

    /// Change anchor edges
    pub fn set_anchor(&self, anchor: Anchor);

    /// Change surface size — same vocabulary as `SurfaceConfig::width`,
    /// so an axis can be handed back to `content()`
    pub fn set_size(&self, width: impl Into<SurfaceExtent>, height: impl Into<SurfaceExtent>);

    /// Change the screen-space reservation policy
    pub fn set_exclusive_zone(&self, zone: impl Into<ExclusiveZone>);

    /// Change margins — CSS shorthands, as `padding` takes
    pub fn set_margin(&self, margin: impl Into<Margin>);
}

A Surface’s Own Handle

Every surface’s scope declares its own SurfaceHandle, so a tree can reach the surface it is in without being handed its id. Read it with expect_context in the factory body, where the scope is current, and capture it into the handlers that use it — a handler opens no scope of its own (see Context):

extern crate guido;
use guido::prelude::*;
fn main() {
App::new().run(|app| {
    app.add_surface(
        SurfaceConfig::new()
            .width(240)
            .height(32)
            .anchor(Anchor::TOP | Anchor::RIGHT),
        || {
            let surface = expect_context::<SurfaceHandle>();
            let open = create_signal(false);
            container()
                .on_click(move || {
                    open.update(|o| *o = !*o);
                    surface.set_size(240, if open.get() { 400 } else { 32 });
                })
                .child(text("Toggle"))
        },
    );
});
;
}

The same holds for a surface from spawn_surface(), a popup from spawn_popup() and a lock cover from lock_session(). A popup’s handle is its own: a tree inside a popup reads the popup’s id, not its parent’s. A factory that needs the parent’s id — to hang a popup from it — reads the parent’s handle where the parent’s tree is built and captures it.

Getting a Handle for Existing Surfaces

Use surface_handle() to get a handle for any surface by its ID, from outside it:

extern crate guido;
use guido::prelude::*;
fn main() {
let config = SurfaceConfig::new();
App::new().run(|app| {
    // Store the ID when adding the surface
    let status_bar_id = app.add_surface(config, move || {
        container().child(text("Status bar"))
    });

    // Get a handle and modify properties dynamically, from anywhere
    let handle = surface_handle(status_bar_id);
    handle.set_layer(Layer::Overlay);
    handle.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
});
;
}

Multiple Outputs (Monitors)

Connected outputs are exposed as a reactive list. Reading it inside a tracked closure (an effect, a dynamic child, a reactive property) re-runs the closure when monitors are plugged in, unplugged, or reconfigured:

extern crate guido;
fn main() {
use guido::prelude::*;

create_effect(move || {
    for info in outputs().get() {
        println!(
            "{:?}: {:?} {} ({}x{:?})",
            info.id, info.name, info.model, info.scale_factor, info.logical_size
        );
    }
});
;
}

Each OutputInfo carries a stable OutputId plus the connector name ("DP-1", "eDP-1", …), description, make/model, integer scale factor, and logical size/position when the compositor reports them. Ids are never reused: a monitor that is unplugged and reconnected gets a fresh id.

Display Scale

Scaling is automatic and there is nothing to configure. Widgets are laid out in logical pixels and the surface is drawn at whatever scale its output is set to — 1.5 and 1.25 included, where the compositor offers wp_fractional_scale_v1 and wp_viewporter. Where it does not, the surface falls back to the whole-number scale the compositor asks for.

OutputInfo::scale_factor is not that number. It is the integer wl_output scale, which is what a compositor reports for a monitor and is rounded up on a fractionally-scaled one — a 1.5 output reports 2. It is there to describe the monitor, not to size anything.

Pinning a Surface to an Output

By default the compositor picks the output a surface appears on. Pass an OutputId to pin it:

extern crate guido;
use guido::prelude::*;
fn bar_widget() -> Container { container() }
fn main() {
for info in outputs().get() {
spawn_surface(
    SurfaceConfig::new()
        .height(32)
        .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
        .output(info.id),
    move || bar_widget(),
);
}
}

The output cannot be changed after creation — a layer surface is bound to its output for its lifetime. If the output disconnects before the surface is created, the compositor chooses one instead (a warning is logged).

One Bar per Monitor

An app can start with zero surfaces and spawn one per output from an effect — the classic multi-monitor status bar. See examples/multi_output.rs for the full version:

extern crate guido;
use guido::prelude::*;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
fn bar_widget() -> Container { container() }
fn main() {
App::new().run(|_app| {
    let bars: Rc<RefCell<HashMap<OutputId, SurfaceHandle>>> =
        Rc::new(RefCell::new(HashMap::new()));

    create_effect(move || {
        let current = outputs().get();
        let mut bars = bars.borrow_mut();

        // Drop bars for disconnected outputs
        bars.retain(|id, handle| {
            let alive = current.iter().any(|o| o.id == *id);
            if !alive {
                handle.close();
            }
            alive
        });

        // Spawn a bar on every new output
        for info in current {
            if !bars.contains_key(&info.id) {
                let handle = spawn_surface(
                    SurfaceConfig::new().height(32).output(info.id),
                    move || bar_widget(),
                );
                bars.insert(info.id, handle);
            }
        }
    });
});
;
}

When the compositor closes the surfaces of an unplugged monitor, the bars keep working on the remaining outputs. When the last monitor goes, its bar is the last surface, and closing the last surface ends the app — unless it was built with App::quit_on_last_surface(false), in which case it idles until a monitor comes back and the effect spawns a bar for it.

Tracking Which Output a Surface Is On

surface_output(surface_id) reports the output a surface is currently shown on (None until the compositor maps it). It is a tracked read — reactive inside any tracked closure:

extern crate guido;
use guido::prelude::*;
fn main() {
let my_surface_id = SurfaceId::next();
text(move || match surface_output(my_surface_id) {
    Some(out) => format!("shown on output {}", out.raw()),
    None => "not mapped yet".to_string(),
})
;
}

For a surface spanning multiple outputs, this reports the one entered most recently.

Input Regions

By default the whole surface accepts pointer and touch input. A container can say otherwise, and what it says goes to the compositor: everything outside the region lets clicks reach the windows below.

The surface declares the baseline and a container declares the exception. An overlay that lets everything past, with one pill that does not:

extern crate guido;
use guido::prelude::*;
fn main() {
SurfaceConfig::new()
    .background_color(Color::TRANSPARENT)
    .click_through();

container()
    .takes_input(true)
    .corners(20.0)
    .on_click(|| {})
;
}

And the other way round, a bar that takes input with a notch the desktop gets:

extern crate guido;
use guido::prelude::*;
fn main() {
container().takes_input(false).width(200.0)
;
}

The region is read off the frame that was drawn, so it follows the shape on screen: its corners, its transform, its clip. A container that stops painting — hidden, culled, scrolled out of view — takes its region with it, and a frame that changes nothing publishes nothing. examples/input_region.rs is the first of the two, in full.

The transform half of that is newer than the sentence. A turned container’s region was the axis-aligned box around it, which claims more than it drew: at 45° an 80×80 container takes clicks over 56% more area than it covers, and every one of those clicks lands on a panel that is not there rather than reaching the window below. The region is cut from the placed shape now, and so is whatever clips it — so a card filling a rounded scroller takes clicks in the scroller’s rounded shape rather than in the square around it.

What a container declares is its own shape. Children are covered because they are drawn inside it, not because the declaration reaches them. A child drawn outside its parent — overflow is Visible by default, so a transform or a size larger than the parent does it — is outside the region as well, and on a click-through surface a handler on that child never runs:

extern crate guido;
use guido::prelude::*;
fn main() {
let do_thing = || {};
container()
    .takes_input(true)
    .width(100.0)
    .height(30.0)
    // 40px below the parent, so outside the region: those clicks go to the
    // desktop, not to this handler.
    .child(container().translate((0.0, 40.0)).on_click(do_thing))
;
}

Declare takes_input on the container whose shape you mean, and a nested declaration of the opposite kind is simply a smaller area declared later — an island inside a hole works with no rule of its own.

takes_input(false) is not the same as enabled(false): a disabled container refuses input and still blocks what is behind the surface, which is what a disabled button should do.

For a region that belongs to no widget, the surface still takes rectangles directly:

extern crate guido;
use guido::prelude::*;
fn main() {
let id = SurfaceId::next();
let rect = Rect::new(16.0, 20.0, 200.0, 40.0);
SurfaceConfig::new().input_region([Rect::new(16.0, 20.0, 200.0, 40.0)]);
surface_handle(id).set_input_region(Some(vec![rect]));
surface_handle(id).set_input_region(None);
;
}

Note: keyboard focus is unaffected — use keyboard_interactivity for that. Rectangles are rounded outward to whole pixels.

Backdrop Blur

backdrop_blur blurs whatever is behind a container. There are two things behind it, on opposite sides of the Wayland surface, and both are filtered:

  • what this surface already drew — a wallpaper, a photo, the panel under a card;
  • what the compositor composites below the surface, through ext-background-effect-v1, wherever the surface is translucent.
extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .background(Color::rgba(0.12, 0.12, 0.18, 0.55))
    .corners(16.0)
    .backdrop_blur(32.0)
    .child(text("Frosted glass"))
;
}

Filtering both is not a compromise between mechanisms. Blur is a linear operator, so over a region of uniform alpha a:

blur(a·ours + (1−a)·theirs) = a·blur(ours) + (1−a)·blur(theirs)

Blurring each layer separately and compositing gives exactly the same result — which is why neither is chosen over the other. A translucent panel is neither “ours” nor “theirs” at any pixel, so a per-box choice could only be right for some of them.

Restrict it when only one side should soften:

extern crate guido;
use guido::prelude::*;
fn main() {
// Desktop behind the panel softens; the panel's own islands stay crisp.
container().backdrop_blur(
    BackdropBlur::new(24.0).sources(BackdropSources::COMPOSITOR),
)
;
}

What each side costs

The compositor side is a region handed over once per change: the region follows the container automatically as layout moves, resizes and (animated) corner radii change. Rounded corners are tessellated into small rectangles because wl_region has no notion of curves, and the protocol carries no radius — the compositor picks its own, so radius does not apply there. Check availability with compositor_effects().

Which is why a blur restricted to the compositor asks for its region on its sources alone, and a radius of zero there is a request rather than a refusal:

extern crate guido;
use guido::prelude::*;
fn main() {
// Blur nothing of our own; let the compositor blur the desktop behind us.
container().backdrop_blur(
    BackdropBlur::new(0.0).sources(BackdropSources::COMPOSITOR),
)
;
}

Everywhere the surface is a source, a radius of zero still means no blur — that is how a blur is switched off by the same signal that switches it on. To switch off a compositor-only one, empty its sources instead.

The surface side is guido’s own: the frame is drawn into an offscreen target, each region is downsampled, blurred with a separable gaussian and composited back masked to the container’s rounded shape. Frames with no backdrop blur skip all of it and draw straight to the swapchain. A text can ask for the same thing with the shape of its glyphs as the mask — see Frosted glass — and that one is surface-side only, since a wl_region cannot hold a letter.

Two skips keep that honest, and both are unobservable — they drop work that could not have changed a pixel, so they may switch on and off between frames without anything showing:

  • a region with nothing drawn beneath it is not filtered at all;
  • surfaces that never asked for a compositor region are left untouched, so compositor-side rules (e.g. blur by namespace) keep working.

The seam

Where alpha varies within the blur radius — at the edges of opaque content inside the box — the two blurs do not bleed into each other: the desktop does not smear into the surface’s own content or the other way round. That cannot be fixed from guido. set_blur_region is fire-and-forget: it takes a region and returns nothing, so the compositor’s pixels are never ours to filter across.

See examples/blur_example.rs and examples/draw_order_example.rs.

Popups (Menus, Dropdowns)

spawn_popup creates an xdg popup anchored to a surface: the compositor positions it relative to an anchor rectangle, keeps it on screen (flipping/sliding at screen edges), and — with .grab() — dismisses it when the user clicks outside. Real menu semantics, no fullscreen overlay:

extern crate guido;
use guido::prelude::*;
fn menu_widget() -> Container { container() }
fn main() {
let bar_id = SurfaceId::next();
let button_ref = create_widget_ref();
let popup = spawn_popup(
    bar_id,
    PopupConfig::new(250)                      // width; height sizes to content
        .anchor_rect(button_ref.rect().get())  // parent surface coords
        .anchor(PopupAnchor::Bottom)           // attach point on the rect
        .gravity(PopupGravity::Bottom)         // growth direction
        .grab(),                               // dismiss on outside click
    move || menu_widget(),
);
;
}

Dismissal is reactive — reset your open/closed state when the compositor closes the popup:

extern crate guido;
use guido::prelude::*;
fn main() {
let menu_open = create_signal(false);
let popup = spawn_popup(SurfaceId::next(), PopupConfig::new(250), container);
create_effect(move || {
    if popup.dismissed() {
        menu_open.set(false);
    }
});
;
}

popup.close() closes it programmatically, and from that call on neither it nor a popup hanging from it is handed any more input — a second click already queued on the item that closed the menu picks nothing. Popups render their own widget tree and share the app’s reactive state like any surface; anchoring a popup to another popup creates a nested popup (submenus). A handle outlives its popup: reading dismissed() is safe after the popup is gone, including from a submenu whose parent closed with it.

Note: for a bottom bar use anchor(Top) + gravity(Top) so the menu opens upward — or just rely on the compositor’s flip adjustment.

See examples/popup_example.rs.

Session Lock (Lock Screens)

lock_session asks the compositor to lock the session (ext-session-lock-v1) and, without waiting for the answer, creates one lock surface per output using your widget factory, as the protocol asks. Once the compositor grants the lock it blanks every output, shows the lock surfaces, and routes all input to them, so a password_input works out of the box:

extern crate guido;
use guido::prelude::*;
fn verify_password(_attempt: &str) -> bool { true }
fn main() {
lock_session(|output: OutputInfo| {
    let attempt = create_password();
    container()
        .width(fill())
        .height(fill())
        .background(Color::rgb(0.07, 0.07, 0.1))
        .layout(Flex::column().main_alignment(MainAlignment::Center))
        .child(text(format!("Locked — {:?}", output.name)))
        .child(password_input(attempt).on_submit(move || {
            if verify_password(attempt.take().expose()) {
                unlock_session();
            }
        }))
});
;
}

The lifecycle is reactive:

text(move || format!("{:?}", lock_state().get()))
// Unlocked → Locking → Locked, back to Unlocked on unlock/denial

Details worth knowing:

  • Outputs plugged in while locking or locked get a lock surface automatically (the factory is called again); the compositor blanks any output without one.
  • If the compositor refuses the lock (no protocol support, or another lock client is active), lock_state returns to Unlocked and the lock surfaces already made are dropped.
  • A lock surface lasts as long as the lock. close() on its SurfaceHandle does nothing but log a warning — a dismiss button, or a component written for ordinary surfaces, cannot leave an output uncovered — and unlock_session is the way out.
  • Unlike ordinary surfaces, taking lock surfaces away never exits the app: a lock daemon whose only surfaces are lock surfaces keeps running after unlocking, idle until the next lock_session call.
  • Layer-shell properties (anchor, margins, exclusive zone…) don’t apply to lock surfaces; their size always comes from the compositor.

See examples/simple_lock.rs — note that running it really locks your session (it has a 30-second auto-unlock safety net; the password is guido).

Complete Examples

Status Bar

extern crate guido;
use guido::prelude::*;
fn center_section() -> Container { container() }
fn left_section() -> Container { container() }
fn right_section() -> Container { container() }
fn main() {
    App::new().run(|app| {
        app.add_surface(
            SurfaceConfig::new()
                .height(32)
                .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
                .layer(Layer::Top)
                .exclusive_zone(32u32)
                .namespace("status-bar")
                .background_color(Color::rgb(0.1, 0.1, 0.15)),
            || {
                container()
                    .height(fill())
                    .layout(
                        Flex::row()
                            .main_alignment(MainAlignment::SpaceBetween)
                            .cross_alignment(CrossAlignment::Center)
                    )
                    .children([
                        left_section(),
                        center_section(),
                        right_section(),
                    ])
            },
        );
    });
}

Dock

extern crate guido;
use guido::prelude::*;
fn dock_icon(_name: &str) -> Container { container() }
fn main() {
    App::new().run(|app| {
        app.add_surface(
            SurfaceConfig::new()
                .height(64)
                .anchor(Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT)
                .layer(Layer::Top)
                .exclusive_zone(64u32)
                .namespace("dock")
                .background_color(Color::rgba(0.1, 0.1, 0.15, 0.9)),
            || {
                container()
                    .height(fill())
                    .layout(Flex::row().spacing(8.0).center())
                    .children([
                        dock_icon("terminal"),
                        dock_icon("browser"),
                        dock_icon("files"),
                    ])
            },
        );
    });
}

Floating Overlay with Keyboard Focus

extern crate guido;
use guido::prelude::*;
fn main() {
    App::new().run(|app| {
        app.add_surface(
            SurfaceConfig::new()
                .width(300)
                .height(100)
                .anchor(Anchor::TOP | Anchor::RIGHT)
                .layer(Layer::Overlay)
                .keyboard_interactivity(KeyboardInteractivity::Exclusive)
                .namespace("notification")
                .background_color(Color::TRANSPARENT),
            || {
                container()
                    .padding(20.0)
                    .background(Color::rgb(0.15, 0.15, 0.2))
                    .corners(12.0)
                    .child(container().child(text("Notification").color(Color::WHITE)))
            },
        );
    });
}

API Reference

SurfaceConfig

// not compiled: a signature listing — these declarations have no bodies.
impl SurfaceConfig {
    pub fn new() -> Self;
    pub fn width(self, width: impl Into<SurfaceExtent>) -> Self;
    pub fn height(self, height: impl Into<SurfaceExtent>) -> Self;
    pub fn anchor(self, anchor: Anchor) -> Self;
    pub fn layer(self, layer: Layer) -> Self;
    pub fn keyboard_interactivity(self, mode: KeyboardInteractivity) -> Self;
    pub fn exclusive_zone(self, zone: impl Into<ExclusiveZone>) -> Self;
    pub fn namespace(self, namespace: impl Into<String>) -> Self;
    pub fn background_color(self, color: Color) -> Self;
    pub fn margin(self, margin: impl Into<Margin>) -> Self;
    pub fn output(self, output: OutputId) -> Self;
    pub fn input_region(self, rects: impl Into<Vec<Rect>>) -> Self;
    pub fn click_through(self) -> Self;
}

Outputs

// not compiled: a signature listing — these declarations have no bodies.
/// Reactive list of connected outputs, sorted by id
pub fn outputs() -> Signal<Vec<OutputInfo>>;

/// The output a surface is currently shown on (tracked read)
pub fn surface_output(id: SurfaceId) -> Option<OutputId>;

pub struct OutputInfo {
    pub id: OutputId,
    pub name: Option<String>,        // connector, e.g. "DP-1"
    pub description: Option<String>,
    pub make: String,
    pub model: String,
    pub scale_factor: i32,
    pub logical_size: Option<(i32, i32)>,
    pub logical_position: Option<(i32, i32)>,
}

App

// not compiled: a signature listing — these declarations have no bodies.
impl App {
    pub fn new() -> Self;
    pub fn run(self, setup: impl FnOnce(&mut Self)) -> ExitReason;
    pub fn add_surface<W, F>(&mut self, config: SurfaceConfig, widget_fn: F) -> SurfaceId
    where
        W: Widget + 'static,
        F: FnOnce() -> W + 'static;
}

Dynamic Surface Creation

// not compiled: a signature listing — these declarations have no bodies.
/// Spawn a new surface at runtime
pub fn spawn_surface<W, F>(config: SurfaceConfig, widget_fn: F) -> SurfaceHandle
where
    W: Widget + 'static,
    F: FnOnce() -> W + Send + 'static;

/// Get a handle for an existing surface by ID
pub fn surface_handle(id: SurfaceId) -> SurfaceHandle;

SurfaceHandle

// not compiled: a signature listing — these declarations have no bodies.
impl SurfaceHandle {
    pub fn id(&self) -> SurfaceId;
    pub fn close(&self);
    pub fn set_layer(&self, layer: Layer);
    pub fn set_keyboard_interactivity(&self, mode: KeyboardInteractivity);
    pub fn set_anchor(&self, anchor: Anchor);
    pub fn set_size(&self, width: impl Into<SurfaceExtent>, height: impl Into<SurfaceExtent>);
    pub fn set_exclusive_zone(&self, zone: impl Into<ExclusiveZone>);
    pub fn set_margin(&self, margin: impl Into<Margin>);
    pub fn set_input_region(&self, rects: Option<Vec<Rect>>);
}

App Lifecycle

Guido applications can programmatically quit or restart. App::run() returns an ExitReason so the caller knows why the loop exited: Quit, Restart, or Error(PlatformError) when the platform layer fails (no Wayland session, a compositor without layer-shell support such as GNOME, a lost connection, or no GPU to draw a surface with) — these conditions are reported instead of panicking.

Quitting

Call quit_app() to request a clean shutdown:

extern crate guido;
fn main() {
use guido::prelude::*;

container()
    .padding([8.0, 16.0])
    .background(Color::rgb(0.3, 0.3, 0.4))
    .when_hovered(|s| s.lighter(0.1))
    .on_click(|| quit_app())
    .child(text("Quit"))
;
}

The current App::run() loop exits and returns ExitReason::Quit.

Outliving the Last Surface

Closing the last surface also ends the loop with ExitReason::Quit — whether the app closed it, the compositor did, or its monitor was unplugged. A resident program that only sometimes has something to show — a polkit agent, a notification daemon, an on-screen display — turns that off with quit_on_last_surface(false):

extern crate guido;
use guido::prelude::*;

fn main() {
    App::new().quit_on_last_surface(false).run(|_app| {
        let asked = create_signal(false);
        let mut dialog: Option<SurfaceHandle> = None;
        create_effect(move || {
            if asked.get() {
                dialog = Some(spawn_surface(SurfaceConfig::new(), || text("Authenticate")));
            } else if let Some(open) = dialog.take() {
                open.close();
            }
        });
        // Something outside the UI — a D-Bus request, say — writes `asked`.
    });
}

With it off, the app idles when its last surface closes, exactly as one that has not spawned a surface yet does, and ends through quit_app() or a lost connection.

An app holds a GPU device only while it has something to draw. The device is made when the first surface needs one, and let go 30 seconds after the last one closes — soon enough that a resident program between windows costs no GPU memory, late enough that a dialog closed and reopened straight away does not wait for a new one.

Restarting

Call restart_app() to request a restart. The loop exits and returns ExitReason::Restart, letting the caller re-create the app:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_click(|| restart_app())
    .child(text("Restart"))
;
}

Restart Loop

Use a loop in main() to support restart:

extern crate guido;
fn build_ui() -> Container { container() }
use guido::prelude::*;

fn main() {
    loop {
        let reason = App::new().run(|app| {
            app.add_surface(
                SurfaceConfig::new()
                    .height(32)
                    .anchor(Anchor::TOP | Anchor::LEFT | Anchor::RIGHT)
                    .layer(Layer::Top)
                    .namespace("my-bar")
                    .background_color(Color::rgb(0.1, 0.1, 0.15)),
                || build_ui(),
            );
        });

        match reason {
            ExitReason::Quit => break,
            ExitReason::Restart => continue,
            ExitReason::Error(e) => {
                eprintln!("guido could not run: {e}");
                break;
            }
        }
    }
}

This is useful for reloading configuration, switching themes, or resetting application state.

Calling from Background Tasks

Both quit_app() and restart_app() are Send — they work from any thread, including background services:

// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
create_task(move |ctx| async move {
    loop {
        tokio::select! {
            _ = watch_config_file() => {
                // Config changed — trigger restart
                restart_app();
                break;
            }
            _ = tokio::time::sleep(Duration::from_millis(50)) => {
                if !ctx.is_running() { break; }
            }
        }
    }
});
;
}

API Reference

ExitReason

// not compiled: a listing of a type the crate owns — a copy of it declared
// here would compile without checking the original.
pub enum ExitReason {
    /// Normal exit: `quit_app()`, or the last surface closing.
    Quit,
    /// Restart requested. The caller should re-create `App` and run again.
    Restart,
    /// The platform layer failed: no Wayland session, a compositor without
    /// layer shell, a lost connection, or no GPU to draw a surface with.
    Error(PlatformError),
}

Functions

// not compiled: a signature listing — these declarations have no bodies.
/// Request a clean application quit.
/// App::run() will return ExitReason::Quit.
pub fn quit_app();

/// Request a clean application restart.
/// App::run() will return ExitReason::Restart.
/// Call from any thread — uses an atomic + ping to wake the event loop.
pub fn restart_app();

App::run

// not compiled: a signature listing — these declarations have no bodies.
impl App {
    /// Run the application. Returns the reason the loop exited.
    pub fn run(self, setup: impl FnOnce(&mut Self)) -> ExitReason;
}

Context

Context shares state across widgets without passing it through every level of the tree: a value is declared for a scope, and every widget built below that scope can read it back by type.

When to Use Context

Use context for cross-cutting concerns that many widgets need:

  • Application configuration
  • Theme or styling data
  • Service handles (loggers, API clients)
  • User preferences

For state that only a few nearby widgets share, passing signals directly is simpler and preferred.

Scopes

A scope is a lifetime, and guido opens one wherever it invokes a closure of yours: the App::run setup, each surface, each popup, and each dynamic children factory. A value declared with provide_context belongs to the scope that is current when the call runs, and a read walks from the reader’s scope upward and takes the nearest declaration.

Three things follow, and they are the whole model:

  • An inner declaration shadows an outer one for its own scope only. A popup that declares its own RwSignal<Theme> reads that one rather than the application’s, and leaves the application’s alone for everything else.
  • A declaration dies with its scope. When the popup closes, what it declared goes with it; nothing is left behind for a later read to find.
  • Declaring the same type twice in one scope panics. Two of a type is what shadowing is for, and shadowing needs two scopes — a second declaration in one scope would replace the first with nothing to say it had.

Guido declares one value of its own in each surface’s scope — a popup’s and a lock cover’s included: that surface’s SurfaceHandle, so expect_context::<SurfaceHandle>() in a factory body is the surface the tree is being built for (see A Surface’s Own Handle).

The scopes are one level deep. A surface, a popup and a surface spawned at runtime are all children of the root — the loop opens each one’s scope from the root, not from the surface it belongs to — so a popup reads what the application declared and not what the surface that opened it did, and two surfaces are siblings that cannot see each other’s declarations.

A plain widget factory is an ordinary function call, not a scope: a provide_context written inside one declares its value for whatever scope called it — the whole surface — and not for the subtree the factory is building. A closure guido invokes is a scope, and that is the difference: the factory you hand to .child(…) for dynamic children is called by the library, which opens a scope around it, so a declaration made there belongs to that row. That scope hangs from the one that was current where .child(…) or .children(…) was written — the surface’s, or the row’s around it — and not from whatever is current when the library gets round to running the factory, which is later, while the frame is laid out. So a row reads what its surface declared, and a row inside a row reads what either of them did.

Where a Read Resolves

In the body of the factory that builds the widgets. That is where the scope the value was declared for is the one that is current, so it is where guido knows who is asking.

A property closure resolves where it was written, whenever it is read. The closure you hand a builder becomes a derived signal, and the scope that was current when the builder ran travels with it — so a property declared in a row of a dynamic list reads that row’s declarations on every phase of every frame, and not the row’s while laying out and the application’s while painting.

An effect and a memo are the same story on a different clock. Their bodies run once where they were created and again whenever a dependency changes — and that second run happens at a flush, which belongs to the application rather than to whatever declared the value. They carry their scope too, so a memo of a row’s declaration is that row’s on its first computation and on every one after it.

An event handler and a spawned task are what is left: they open no scope and carry none, so a read inside one resolves against whatever is current, which in a running application is the root — App::run enters the root scope before your setup closure and never leaves it. The application’s declarations are readable from a handler; a surface’s are not.

Reading the value once in the factory body and capturing it works everywhere, and is still the clearest thing to write:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq, Default)]
struct Theme { bg_color: Color, title: String }
fn main() {
fn themed_box() -> Container {
    let theme = expect_context::<RwSignal<Theme>>();   // here: a scope is current

    container()
        .background(move || theme.get().bg_color)      // or here: the closure keeps this scope
        .child(text(move || theme.get().title.clone()))
}
}

Providing Context

Call provide_context in your App::run() setup to make a value available to every surface:

extern crate guido;
use guido::prelude::*;
fn build_ui() -> Container { container() }
#[derive(Clone, Default)]
struct Config { warn_threshold: f64 }
impl Config { fn load() -> Self { Self::default() } }
fn main() {
let config = SurfaceConfig::new();
App::new().run(|app| {
    provide_context(Config::load());

    app.add_surface(config, || build_ui());
});
;
}

Retrieving Context

use_context (fallible)

Returns Option<T> — None when no scope at or above this one declared it:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Default)]
struct Config { warn_threshold: f64 }
fn main() {
if let Some(cfg) = use_context::<Config>() {
    println!("threshold: {}", cfg.warn_threshold);
}
;
}

expect_context (infallible)

Panics if nothing declared it, naming the type and both of the reasons it can be missing:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Default)]
struct Config { warn_threshold: f64 }
fn main() {
let cfg = expect_context::<Config>();
;
}

with_context (zero-clone)

Borrows the value without cloning — ideal for large structs when you only need one field:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Default)]
struct Config { warn_threshold: f64 }
fn main() {
let threshold = with_context::<Config, _>(|cfg| cfg.warn_threshold);
;
}

has_context (existence check)

Check whether a type is declared without retrieving it:

extern crate guido;
use guido::prelude::*;
#[derive(Clone, Default)]
struct Logger;
impl Logger { fn info(&self, _: &str) {} }
fn main() {
if has_context::<Logger>() {
    expect_context::<Logger>().info("ready");
}
;
}

Reactive Context

For mutable shared state, declare an RwSignal<T>. Any widget reading the signal during paint or layout tracks it, so a write anywhere repaints everything that reads it.

provide_signal_context

Creates an RwSignal and declares it in one step:

extern crate guido;
use guido::prelude::*;
fn build_ui() -> Container { container() }
#[derive(Clone, PartialEq, Default)]
struct Theme { bg_color: Color, title: String }
fn main() {
let config = SurfaceConfig::new();
App::new().run(|app| {
    // Creates RwSignal<Theme> and declares it for the root scope
    let theme = provide_signal_context(Theme::default());

    app.add_surface(config, || build_ui());
});
;
}

What it declares is an RwSignal<T>, and that is the type to ask for. Asking for a Signal<T> finds nothing: a different type is a different key.

Combining with SignalFields

For config structs with many fields, use #[derive(SignalFields)] with context so each widget only repaints when the specific field it reads changes:

extern crate guido;
use guido::prelude::*;
fn build_ui() -> Container { container() }
fn current_cpu() -> f64 { 0.0 }
#[derive(Clone, PartialEq, SignalFields)]
pub struct AppConfig {
    pub cpu_warn: f64,
    pub mem_warn: f64,
    pub title: String,
}

fn main() {
let surface_config = SurfaceConfig::new();
App::new().run(|app| {
    let config = AppConfigSignals::new(AppConfig {
        cpu_warn: 80.0,
        mem_warn: 90.0,
        title: "My App".into(),
    });
    provide_context(config);

    app.add_surface(surface_config, || build_ui());
});
}

// In a widget — only repaints when cpu_warn changes
fn cpu_indicator() -> Container {
    let config = expect_context::<AppConfigSignals>();
    let threshold = config.cpu_warn;  // Signal<f64>

    container()
        .background(move || {
            if current_cpu() > threshold.get() {
                Color::RED
            } else {
                Color::GREEN
            }
        })
}

Context vs Passing Signals

ApproachBest for
Pass signals directlyParent-child, 1-2 levels deep, few consumers
ContextApp-wide state, many consumers across modules

Since Signal<T> is Copy, passing them directly is zero-cost. A context read scans the declarations of the current scope and then of each scope above it, which is negligible but unnecessary when only a few widgets need the value.

API Reference

// Declare a value for the current scope
pub fn provide_context<T: 'static>(value: T);

// Retrieve (clones)
pub fn use_context<T: Clone + 'static>() -> Option<T>;
pub fn expect_context<T: Clone + 'static>() -> T;

// Borrow without cloning
pub fn with_context<T: 'static, R>(f: impl FnOnce(&T) -> R) -> Option<R>;

// Existence check
pub fn has_context<T: 'static>() -> bool;

// Create signal + declare it
pub fn provide_signal_context<T: Clone + PartialEq + Send + 'static>(
    value: T
) -> RwSignal<T>;

Architecture

This section covers Guido’s internal architecture for developers who want to understand how the library works or contribute to it.

System Overview

┌─────────────────────────────────────────────────────────────────┐
│                          Application                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │
│  │   Widgets   │  │  Reactive   │  │       Platform          │  │
│  │  Container  │  │   Signals   │  │   Wayland Layer Shell   │  │
│  │    Text     │  │    Memo     │  │   Event Loop (calloop)  │  │
│  │   Layout    │  │   Effects   │  │                         │  │
│  └──────┬──────┘  └──────┬──────┘  └───────────┬─────────────┘  │
│         │                │                     │                 │
│         └────────────────┼─────────────────────┘                 │
│                          │                                       │
│                    ┌─────┴─────┐                                 │
│                    │  Renderer │                                 │
│                    │   wgpu    │                                 │
│                    │  glyphon  │                                 │
│                    └───────────┘                                 │
└─────────────────────────────────────────────────────────────────┘

Module Structure

ModulePurpose
reactive/Signals, memos, effects
widgets/Container, Text, Layout trait
renderer/wgpu rendering, shaders, text
platform/Wayland layer shell integration
transform.rs2D transformation matrices

In This Section

Key Design Decisions

Fine-Grained Reactivity

Guido uses signals rather than virtual DOM diffing. Widgets are created once; their properties update automatically through signal subscriptions.

Builder Pattern

All configuration uses the builder pattern with method chaining:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .padding(16.0)
    .background(Color::RED)
    .child(text("Hello"))
;
}

SDF Rendering

Shapes use Signed Distance Fields for resolution-independent rendering with crisp anti-aliasing at any scale.

Layer Shell Native

Guido is built specifically for Wayland layer shell, not as a general windowing toolkit with layer shell support added later.

System Overview

This page details Guido’s module structure and key types.

Module Structure

reactive/ - Reactive System

Single-threaded reactive primitives inspired by SolidJS.

Key Types:

  • RwSignal<T> - Read-write reactive values (8 bytes, Copy). Created via create_signal(). Supports .get(), .set(), .update(), .writer()
  • Signal<T> - Read-only reactive values (12 bytes, Copy). Created via create_stored(), create_derived(), or RwSignal::read_only(). Supports .get(), .with() — no .set()
  • Memo<T> - Eager derived values that only notify on actual changes
  • Effect - Side effects that re-run on changes

How It Works:

The runtime uses thread-local storage for dependency tracking. When a signal is read inside a Memo, Effect, or during widget paint()/layout(), it registers as a dependency.

extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let doubled = create_memo(move || count.get() * 2);
// Runtime knows doubled depends on count
;
}

widgets/ - UI Components

Container (widgets/container/)

The primary building block supporting:

  • Padding, backgrounds (solid/gradient)
  • Corners with superellipse curvature
  • Borders with SDF rendering
  • Shadows
  • Transforms
  • State layers (hover/pressed)
  • Ripple effects
  • Event handlers
  • Pluggable layouts

Text (widgets/text.rs)

Text rendering with:

  • Reactive content
  • Font styling (size, weight, color)
  • Wrapping control

Layout (layout/)

Pluggable layouts via the Layout trait:

pub trait Layout {
    fn layout(
        &mut self,
        ctx: &mut LayoutCtx,
        children: &[WidgetId],
        constraints: Constraints,
        origin: (f32, f32),
    ) -> Size;
}

Built-in: Flex for row/column layouts.

renderer/ - GPU Rendering

Components:

  • Renderer - GPU resource management
  • PaintContext - Accumulates shapes during painting
  • WGSL shaders for SDF-based rendering

Features:

  • Superellipse corners (CSS K-values)
  • SDF borders for crisp anti-aliasing
  • Linear gradients
  • Clipping
  • Transform support
  • HiDPI scaling

platform/ - Wayland Integration

Features:

  • Smithay-client-toolkit for protocols
  • Layer shell (Top, Bottom, Overlay, Background)
  • Anchor edges and exclusive zones
  • Event loop via calloop

transform.rs - 2D Transforms

2D affine transforms (6 floats, 24 bytes — exactly what the GPU shader consumes):

container().translate((x, y));
container().rotate(deg);
container().scale(s);
t1.then(&t2);  // Composition
t.inverse()   // Inversion, or None where it collapsed

pivot.rs - Pivot Points

Define rotation/scale pivot:

extern crate guido;
use guido::prelude::*;
fn main() {
Pivot::CENTER;
Pivot::TOP_LEFT;
Pivot::percent(25.0, 75.0)
;
}

Widget Trait

All widgets implement:

pub trait Widget {
    fn layout(&mut self, ctx: &mut LayoutCtx, constraints: Constraints) -> Size;
    fn paint(&self, ctx: &mut PaintContext);
    fn event(&mut self, tree: &mut Tree, id: WidgetId, event: &Event) -> EventResponse;
}

Widgets access children through the Tree parameter, which provides centralized widget storage and layout metadata. Widget bounds and origins are stored in the Tree (use tree.get_bounds(id) and tree.set_origin(id, x, y)).

Constraints System

Parent passes constraints to children:

pub struct Constraints {
    pub min_width: f32,
    pub max_width: f32,
    pub min_height: f32,
    pub max_height: f32,
}

Children choose a size within constraints.

Key Files Reference

FilePurpose
src/lib.rsApp entry, main loop
src/widgets/container/Container implementation
src/widgets/state_layer.rsState layer types
src/renderer/mod.rsRenderer, GPU setup
src/renderer/types.rsShape types (Gradient, Shadow)
src/renderer/shader.wgslGPU shaders
src/reactive/signal.rsSignal implementation
src/transform.rsTransform matrices
src/platform/mod.rsWayland integration

Performance Considerations

Buffer Reuse

PaintContext uses pre-allocated buffers cleared each frame, avoiding per-frame allocations.

Reactive Efficiency

Signals only notify dependents when values actually change. The render loop reads current values without recreating widgets.

GPU Batching

Shapes batch into vertex/index buffers for efficient GPU submission. Text uses glyphon’s atlas system.

Rendering Pipeline

This page explains how Guido renders widgets to the screen.

Pipeline Overview

Main loop (once per iteration):
 1. init_pending_gpu()             → Give any surface born this iteration a
                                     render target and its first layout, at
                                     this iteration's instant — which is what
                                     an enter animation is seeded from
 2. flush_bg_writes()              → Drain queued background-thread signal writes
 3. take_frame_request()           → Check if a frame was requested

Per-surface rendering:
 4. Dispatch events                → Route input events (MouseMoves coalesced),
                                     each one declaring when the compositor saw
                                     it happen — not when this loop got to it
 5. Frame-pacing gate              → Return if the compositor hasn't shown the
                                     previous frame yet (jobs stay queued)
 6. set_frame_instant(now)         → What time it is, for this whole frame:
                                     everything below is asked about this one
                                     moment rather than reading a clock of its
                                     own (cleared again after step 14)
 7. drain_pending_jobs()           → Process jobs: unregister, advance
    + process_jobs()                 animations, reconcile children, mark dirty
 8. Partial layout                 → Only dirty subtrees re-layout
 9. Skip-frame check               → Skip paint if root is clean
10. Tree::paint_widget()           → Build render tree (Rc cache reuse for clean children)
11. flatten_root_into()            → Flatten to draw commands (incremental for clean subtrees)
12. frame() + damage_buffer()      → Re-arm the frame callback and report damage,
                                     both BEFORE presenting
13. GPU rendering + present()      → Instanced SDF shapes with HiDPI scaling;
                                     present() commits the surface
14. cache_paint_results()          → Rc-share rendered nodes into the cache,
                                     clear needs_paint flags

Rendering is paced by the compositor’s wl_surface.frame callbacks: after each present, a new callback is requested, and the surface won’t render again until it fires. An animating surface renders exactly once per compositor frame; an idle surface renders nothing and the loop sleeps.

Layout Pass

The main loop calls layout with screen constraints:

extern crate guido;
use guido::prelude::*;
fn main() {
let constraints = Constraints {
    min_width: 0.0,
    max_width: screen_width,
    min_height: 0.0,
    max_height: screen_height,
};

widget.layout(constraints);
;
}

Each widget:

  1. Calculates its preferred size within constraints
  2. Positions children (if any)
  3. Returns its final size

Paint Pass

After layout, widgets paint to the PaintContext:

fn paint(&self, ctx: &mut PaintContext) {
    // Bounds come from the Tree — the single source of truth — and paint
    // happens in LOCAL coordinates, the parent having placed the node.
    let (tree, id) = (ctx.tree(), ctx.id());
    let bounds = tree.get_bounds(id).unwrap_or_default();
    let local = Rect::new(0.0, 0.0, bounds.width, bounds.height);

    // Background and border are one command: a rounded rect carries its own
    // border, shadow and gradient.
    ctx.draw_rounded_rect(local, self.background, self.corner_radius);

    // Paint children. One call each, and it is the framework's: it culls a
    // child nobody can see, serves a clean one from its cache, places it, and
    // opens its Paint scope.
    for &child_id in self.children.iter() {
        ctx.paint_child(child_id, &ChildPaintOptions::default());
    }
}

PaintContext accumulates:

  • Shapes - Rectangles, rounded rects, gradients, arcs
  • Paths - Fills and strokes, tessellated once and kept
  • Images - Raster and SVG sources
  • Text - Glyphs for text rendering
  • Overlay shapes - Ripples, effects on top of content

HiDPI Scaling

The renderer converts logical coordinates to physical pixels:

extern crate guido;
use guido::prelude::*;
fn main() {
let physical_x = logical_x * scale_factor;
let physical_y = logical_y * scale_factor;
;
}

Widgets work in logical coordinates; scaling is automatic.

SDF Rendering

Shapes use Signed Distance Field techniques:

// In shader
let dist = sdf_rounded_rect(uv, size, radius, k_value);
let alpha = smoothstep(0.0, -pixel_width, dist);

Benefits:

  • Resolution-independent anti-aliasing
  • Crisp edges at any scale
  • Superellipse corner support

Render Order

Within a draw group, each kind of draw is one layer, in this order:

  1. Shapes - Container backgrounds, borders, arcs
  2. Paths - Filled and stroked paths
  3. Images - Image content
  4. Text - Text content
  5. Overlay - Ripple effects, state layer overlays

This ensures ripples appear on top of text. Something painted over a later layer — a background over an image, a box over a path — opens a new group where the two overlap, so the order the tree painted in is the order drawn.

Shape Types

Rounded Rectangle

struct RoundedRect {
    bounds: Rect,
    color: Color,
    corner_radius: f32,
    corner_curvature: f32,  // K-value
}

Gradient

struct GradientRect {
    bounds: Rect,
    start_color: Color,
    end_color: Color,
    direction: GradientDirection,
}

Border

Rendered as SDF outline:

struct Border {
    bounds: Rect,
    width: f32,
    color: Color,
    corner_radius: f32,
}

Transform Handling

The render tree handles transforms hierarchically:

fn paint(&self, ctx: &mut PaintContext) {
    // Get bounds from Tree (single source of truth)
    let bounds = ctx.tree().get_bounds(ctx.id()).unwrap_or_default();

    // Apply user transform (rotation, scale) if set
    if !self.user_transform.is_identity() {
        ctx.apply_transform_with_pivot(self.user_transform, self.pivot);
    }

    // Paint content in LOCAL coordinates (0,0 is widget origin)
    let local_bounds = Rect::new(0.0, 0.0, bounds.width, bounds.height);
    ctx.draw_rounded_rect(local_bounds, Color::BLUE, 8.0);

    // Paint children. `paint_child` places each one from its bounds in the
    // tree, so a parent that wants the ordinary placement writes nothing.
    for &child_id in self.children.iter() {
        ctx.paint_child(child_id, &ChildPaintOptions::default());
    }
}

Transforms are inherited through the render tree hierarchy. Each node has a local transform that is composed with its parent’s world transform during tree flattening.

Text Rendering

Text uses the glyphon library:

  1. Text widget provides content and style
  2. Glyphon lays out glyphs
  3. Glyphs render from a texture atlas
  4. Correct blending with background

Clipping

Containers set a clip region for their content:

extern crate guido;
use guido::prelude::*;
fn main() {
let bounds = tree.get_bounds(id).unwrap_or_default();
let local_bounds = Rect::new(0.0, 0.0, bounds.width, bounds.height);
// Set clip for this node and all children (in local coordinates)
ctx.set_clip(local_bounds, self.corner_radius, self.corner_curvature);

// Set clip for the children only: what the node draws itself is not cut
ctx.set_children_clip(inside_border, inner_radius, self.corner_curvature);

// For overlay-only clipping (e.g., ripple effects)
ctx.set_overlay_clip(local_bounds, self.corner_radius, self.corner_curvature);
;
}

A container clips with the last of these, as CSS clips overflow at the padding box: its children stop inside its border — the box inset by the border width, each corner radius less it — while its own fill, border and shadow are not cut at all.

Clipping respects corner radius and curvature for proper rounded container clipping. Clip regions are inherited through the render tree and transformed along with their parent nodes.

A clip is kept as the shape it was declared, not as the box around it. The flattener carries the declared rect and radii along with the transform that places them, and the shader carries each fragment back into that space before testing it. So Overflow::Hidden on a container rotated 45° clips its children to a rounded square turned 45°, not to the 1.41× larger square around it.

A clip inside a clip is cut by both, exactly. Where one rounded rect is their intersection the two are merged into it; everywhere else — clips turned apart, corners that cross, curvatures that differ — the inner keeps its shape and the outer is tested beside it, so a scroller filling a rounded card keeps the card’s corners. Text is cut the same way: glyphon clips to an integer rectangle, so a text any clip would cut differently from that rectangle is drawn as a texture and cut in the shader instead.

Animation Advancement

Animations advance during per-surface job processing, before layout and paint, so the frame being built always renders with up-to-date animation values. Each advance pushes a continuation job (Animation plus a Paint or Layout follow-up when the value changed), which schedules the next advancement.

Continuation jobs are throttled by the frame-pacing gate: while the compositor hasn’t shown the previous frame, they stay queued, so animations step once per displayed frame instead of once per loop iteration. Widgets like TextInput use this mechanism to drive cursor blinking.

Performance Notes

Vertex Buffer Reuse

PaintContext reuses buffers between frames:

extern crate guido;
use guido::prelude::*;
fn main() {
self.vertices.clear();  // Reuse allocation
self.indices.clear();   // Reuse allocation
;
}

Batching

Similar shapes batch together to reduce draw calls. Text renders in a single pass using the glyph atlas, and small images are packed onto shared atlas pages, so consecutive images on one page are a single draw call too.

Layout Optimization

The layout system includes several optimizations:

Relayout Boundaries: Widgets with fixed width and height are relayout boundaries. Layout changes inside don’t propagate to the parent, limiting recalculation scope. A change to the boundary itself, such as its width or whether it is visible, still lays its parent out, so its siblings move around it.

Layout Caching: Layout results are cached. The system uses reactive version tracking to detect when signals have changed. Layout only runs when:

  • Constraints change
  • Animations are active
  • Reactive state (signals) update

Paint-Only Scrolling: Scroll is implemented as a transform operation during paint, not a layout change. When content scrolls:

  1. Scroll offset is stored as a transform
  2. Transform is applied during paint phase
  3. Children render at their original layout positions
  4. The transform shifts content visually
  5. Clip bounds are adjusted for correct clipping

This means scrolling doesn’t trigger layout, significantly reducing CPU overhead.

Paint Caching and Damage Regions

The rendering pipeline includes several optimizations to avoid redundant work:

Partial Paint: Each widget in the Tree has a needs_paint flag. When a widget’s visual state changes (e.g., a signal update triggers a Paint job), the flag propagates upward to ancestors. During paint, Container checks each child’s flag — clean children reuse their cached RenderNode from the previous frame. The cache is Rc-shared with the render tree: reusing an unmoved child is a refcount bump, and a moved child clones only the node header (its subtree and draw commands stay shared).

Skip Frame: If no widget needs paint after job processing and layout, the entire paint→flatten→render cycle is skipped for that surface.

Damage Regions: As widgets are marked for paint, their surface-relative bounds are accumulated into a per-surface DamageRegion (None, Partial, or Full). The damage is set as pending Wayland state before presenting — present() commits it together with the new buffer — allowing the compositor to optimize its own compositing.

Incremental Flatten: The flattener caches its output per RenderNode. Clean subtrees (where repainted == false) reuse their cached flattened commands with a translation offset, avoiding the cost of recursing into unchanged subtrees.

Event System

This page explains how input events flow through Guido.

Event Flow

Wayland → Platform → App → Widget Tree
                              │
                              ├─ MouseMove
                              ├─ MouseEnter/MouseLeave
                              ├─ MouseDown/MouseUp
                              └─ Scroll/ScrollEnd

Event Types

Mouse Movement

Event::MouseMove { at, pointer };
Event::MouseEnter { at };
Event::MouseLeave

Tracked for hover states. The platform layer determines which widget the cursor is over.

Mouse Buttons

Event::MouseDown { at, button, pointer, count };
Event::MouseUp { at, button }

Used for click detection and pressed states. count is which press of a run this one is — 2 for the second of a double click — and the platform works it out where the presses enter, in src/platform/clicks.rs: Wayland says nothing about double clicks, so both the Wayland platform and the headless recorder pass what they queue through one ClickCounter.

Which Pointer

A move and a press carry a PointerKind — Mouse or Finger. Touch is folded into the pointer pipeline, so a finger arrives as an ordinary MouseDown and every widget that wants to treat the two alike gets that for free.

One field, not a parallel touch event family: Event::Scroll carries a ScrollSource on the same principle. Almost nothing reads it. What does is a scroller, because a finger dragging its content scrolls it and a mouse doing the same must go on selecting text — see Scrolling.

Scrolling

Event::Scroll { at, delta_x, delta_y, source };
Event::ScrollEnd { at }
  • at - Pointer position, or None: see Where an event is
  • delta_x - Horizontal scroll amount, in pixels
  • delta_y - Vertical scroll amount, in pixels
  • source - Wheel, Finger or Continuous

ScrollEnd is the end of a scroll gesture — the finger lifting off a touchpad, which the compositor reports as wl_pointer.axis_stop. It carries no delta because nothing moved.

Only Finger is guaranteed to produce one. The protocol says a wl_pointer sequence from a Wheel or Continuous source may or may not be terminated, and that clients “should treat scroll sequences from these scroll sources as unterminated by default” — so a Continuous gesture may simply never end, and a wheel may occasionally send a stop even though nothing was held down.

It is what decides when momentum scrolling may begin. Without it the end of a gesture has to be guessed from a gap between samples, and a slow scroll is made of gaps. A source that never terminates therefore never gets momentum, which is the honest answer rather than a guess.

Event Routing

No event is offered to every widget. Where each one goes is worked out centrally, the way Flutter routes its events, and a widget’s event is only called for what was routed to it:

EventReaches
A pointer event with a position — move, press, release, wheel, a scroll’s end, enterThe widgets that can be under the point, and those the pointer record still owes one
A pointer event without one — the surface leave, or what a collapsed transform passes downThe widgets the pointer record owes one, and nothing else
A key, and the surface gaining or losing the keyboardThe focused widget, then each of its ancestors; a key press or release nobody there took then goes to the containers that declared on_key_down or on_key_up, the innermost first, until one answers Handled; a Tab nobody took then moves the focus
A pasteThe widget that asked for it

The widgets that can be under the point are found the way paint finds what it can see: a container whose children are ordered along an axis searches them by their laid-out bounds, grown by how far any of them draws outside its box, so a transformed child is found where it is drawn. Children in no order are tested one by one against the same reach.

The pointer record keeps two sets of offers, as Flutter’s MouseTracker and GestureBinding do. The last event’s offers make a widget the pointer just left see the move that takes its hover away, however far the pointer went. The press’s offers, kept until the release, make a drag reach the widget it started on wherever it goes. A mouse has one press route however many of its buttons are down: the first button pressed decides it, it is kept until the last one is released, and a press or release of another button in between goes along that route rather than to whatever is under the pointer. A point a clipping container clips away reaches only the record, and without its position — except for the widget a press holds, which keeps it for its drag and its release.

Within a container the children are offered an event from the one drawn on top to the one drawn first, and the first the point is on stops it, whether or not it took it: what that child covers is not under the point. If it did not take the event, the container handles it itself. So the innermost widget hears an event first, and its ancestors after it — with one exception: a scroller answers its own scrollbar, and a content drag it has already won, before its children are asked.

A container is hit where the point is inside its shape and it draws there — a visible fill or gradient, a border, a backdrop blur — or listens there — a handler, a state layer, a cursor — or one of its children is hit. A disabled container asks none of its children and handles nothing, but where it draws it is still hit, and covers what is beneath it; a text field is hit wherever the point is inside it. One that only lays out is not, so a full-size ZStack layer that aligns its content lets the point through to the layers beneath it. This is Flutter’s rule; the DOM, GTK and Qt hit every box under the point and make passing through something to declare. A sibling the point is covered from is told what a point clipped away tells it: its hover falls, and a press it holds keeps the position. The cursor and the hover follow the same walk, so they agree with the click about which widget is under the pointer.

Hit Testing

Basic Hit Test

fn contains(&self, x: f32, y: f32) -> bool {
    x >= self.x && x <= self.x + self.width &&
    y >= self.y && y <= self.y + self.height
}

With Corner Radius

Clicks outside rounded corners don’t register:

extern crate guido;
use guido::prelude::*;
fn main() {
let radius = 8.0f32;
// SDF-based hit test
let dist = sdf_rounded_rect(point, bounds, radius, k);
dist <= 0.0  // Inside if distance is negative
;
}

With Transforms

The transform is undone before the point is compared against the laid-out bounds — and a transform that has collapsed the widget onto a line has no inverse to undo it with:

fn contains(&self, at: Option<Point>) -> bool {
    // `untransform_point` answers None where the transform has no inverse,
    // and `contains_at` answers false for a point that is not there.
    self.bounds.contains_at(untransform_point(&self.transform, at))
}

Where an event is

A pointer event carries at: Option<Point>, and the None is the interesting half. It means one of two things, and a consumer wants the same answer from both: a keyboard or focus event never had a position, and a pointer event that descended into a subtree scaled to nothing has lost the one it had.

scale(0.0) squashes the plane onto a line. The map stops being one-to-one — every point in the box lands on top of every other — so there is no inverse, and no coordinate that could stand for “nowhere”: every number is somewhere, and a descendant that rotates or mirrors would carry a far-away sentinel back into the visible half-plane.

So the position is simply absent, and every bounds test below answers no:

container()
    .width(80.0)
    .height(40.0)
    .scale(Scale::new(1.0, 0.0))   // collapsed: takes no click, hover or scroll
    .on_click(|| unreachable!())

The event still travels. A press given up and a hover cleared are things only a delivered event can do, so a container inside a menu that collapses mid-press hears the release and drops its own pressed state — the state layer stops painting, the ripple is cancelled. What it does not do is call on_click or on_mouse_up: those say where the release landed, and it landed nowhere. An application pairing on_mouse_down with on_mouse_up to track a press of its own should expect the down without the up, and clear on the same signal that collapsed the box.

A key or a focus change is untouched, having never had a position to lose.

Event Handlers

Containers register callbacks:

extern crate guido;
use guido::prelude::*;
fn main() {
container()
    .on_click(|| println!("Clicked!"))
    .on_hover(|hovered| println!("Hover: {}", hovered))
    .on_scroll(|dx, dy, source| println!("Scroll"))
;
}

Internally stored as optional closures:

pub struct Container {
    on_click: Option<Box<dyn Fn()>>,
    on_hover: Option<Box<dyn Fn(bool)>>,
    on_scroll: Option<Box<dyn Fn(f32, f32, ScrollSource)>>,
}

State Layer Integration

The state layer system uses events internally:

  1. MouseEnter → Set hover state true
  2. MouseLeave → Set hover state false, and pressed state false with the ripple cancelled rather than completed — a press that ends this way activated nothing, which is also how a cancelled touch gesture ends
  3. MouseDown → Set pressed state true, record click point
  4. MouseUp → Set pressed state false, trigger ripple contraction
fn event(&mut self, tree: &mut Tree, id: WidgetId, event: &Event) -> EventResponse {
    match event {
        Event::MouseEnter { .. } => {
            self.flags.update(|f| f.insert(InteractionFlags::HOVERED));
        }
        Event::MouseDown { at: Some(at), .. } => {
            self.flags.update(|f| f.insert(InteractionFlags::PRESSED));
            // The event's own moment, not the wall clock: a ripple begins
            // when the press arrived, and `event_instant` is the only thing
            // that knows when that was. `as_frame_start` is the crossing —
            // the ripple then grows on frames.
            self.ripple.start(at.x, at.y, tree.event_instant().as_frame_start());
        }
        // ...
    }
    EventResponse::Ignored
}

The flags live in a signal rather than a plain field, so that a descendant resolving a state layer subscribes to them — see Interactivity.

EventResponse

Widgets return whether the point is on them, and whether they took the event:

pub enum EventResponse {
    Ignored,   // Not on me: ask the sibling beneath
    Hit,       // On me, not taken: the siblings beneath are covered,
               // the ancestors still handle it
    Handled,   // Taken: stop propagation
}

Platform Integration

Wayland Events

The platform layer receives Wayland protocol events:

// From wl_pointer
fn pointer_motion(x: f32, y: f32) {
    self.cursor_x = x;
    self.cursor_y = y;
    self.dispatch(Event::MouseMove {
        at: Some(Point::new(x, y)),
        pointer: PointerKind::Mouse,
    });
}

fn pointer_button(button: u32, state: ButtonState) {
    match state {
        ButtonState::Pressed => self.dispatch(Event::MouseDown { ... }),
        ButtonState::Released => self.dispatch(Event::MouseUp { ... }),
    }
}

Event Loop

Uses calloop for event loop integration:

extern crate guido;
use guido::prelude::*;
fn main() {
// Main loop
loop {
    // 1. Process Wayland events
    event_queue.dispatch_pending()?;

    // 2. Layout and paint
    widget.layout(constraints);
    widget.paint(&mut ctx);

    // 3. Render to screen
    renderer.render(&ctx);
}
;
}