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:
wgpufor GPU-accelerated renderingsmithay-client-toolkitfor Wayland protocol handlingcalloopfor event loop integrationglyphonfor 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?
- Signal -
create_signal(0)creates a reactive value - Hover state -
.when_hovered(|s| s.lighter(0.1))lightens on hover - Pressed state -
.when_pressed(|s| s.ripple())adds a ripple effect - Click handler -
.on_click(...)increments the counter - Reactive text -
text(move || format!(...))updates when the signal changes
Next Steps
You’ve built your first Guido application. Continue learning:
- Running Examples - Explore more complex examples
- Core Concepts - Understand the reactive system
- Building UI - Learn styling and layout
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
SpaceBetweenalignment - 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, SpaceEvenlyCrossAlignment: 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
EndandBaseline— 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
constvalues - 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:
- Core Concepts - Understand the reactive model
- Building UI - Master styling and layout
- Interactivity - Add hover states and events
service_example
Background services with automatic cleanup.
cargo run --example service_example
Features demonstrated:
create_servicefor 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:
- Reactive Signals - UI state is stored in signals that automatically track dependencies and notify when changed
- Composable Widgets - UIs are built by composing simple primitives (containers, text) into complex structures
- 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 -
RwSignalimplementsCopy, so you can use it in multiple closures without cloning - Background updates - Use
.writer()to get aWriteSignal<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-onlySignal<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>viacreate_stored() - Closures → creates a derived
Signal<T>viacreate_derived() Signal<T>→ passed through directlyRwSignal<T>→ automatically converted toSignal<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:
- Platform receives input (mouse, keyboard)
- Event dispatched to the surface’s root widget
- 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
- 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 - Deep dive into the Container widget
- Layout - Learn about flex layout
- Interactivity - Add event handling
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.
| Constructor | Description |
|---|---|
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:
| Gesture | Mouse | Finger |
|---|---|---|
| Wheel or touchpad over the content | yes | — |
| Dragging the scrollbar handle, or clicking its track | yes | yes |
| Dragging the content | no | yes |
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:
| Key | Scrolls |
|---|---|
| Up / Down | one line, 40 pixels — what a wheel notch scrolls |
| Left / Right | one line along the horizontal axis |
| Page Up / Page Down | one viewport height |
| Home / End | to 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 whenNone.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’sborder-box.outline(width, color, offset)- A ring outside the box that takes no room: the focus or hover ring.shadow(shadow)- Shadows: oneShadowor up to four, each an offset, blur, spread and colour, outside the box orinset
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 aZStack
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 valuevalue.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 configureScroll::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 undeclaredhover_widthkeeps that proportion towidth, so a widened bar still growsScroll::track(|t| ..)andScroll::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
| Alignment | Description |
|---|---|
Start | Pack at the beginning |
Center | Center in available space |
End | Pack at the end |
SpaceBetween | Equal space between, none at edges |
SpaceAround | Equal space around each item |
SpaceEvenly | Equal 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
| Alignment | Description |
|---|---|
Start | Align to start of cross axis |
Center | Center on cross axis |
End | Align to end of cross axis |
Stretch | Stretch to fill cross axis |
Baseline | Line 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
widthkeeps 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 withat_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
- Styling Overview - Complete styling reference
- Colors - Color creation and manipulation
- Borders & Corners - Borders, outlines, corner radius, and curvature
- Shadows - offset, blur, spread and colour
- Merged Shapes - children’s backgrounds drawn as one shape
- Text - Text styling and typography
- Arcs & Paths - Rings, gauges, and paths filled and stroked
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
| Style | K Value | Description |
|---|---|---|
| Squircle | 2.0 | Smooth, iOS-style |
| Circle | 1.0 | Standard rounded (default) |
| Bevel | 0.0 | Diagonal/chamfered |
| Scoop | -1.0 | Concave 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 bywhen_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_transformon 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 fontFontFamily::Serif- Serif fontFontFamily::Monospace- Monospace/fixed-width fontFontFamily::Cursive- Cursive fontFontFamily::Fantasy- Fantasy/decorative fontFontFamily::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
eand 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 HebrewTextAlign::Center— each line centredTextAlign::End— where the text’s direction endsTextAlign::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 drawnTextOverflow::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 mattersTextOverflow::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
Stringand 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:
| Shortcut | Action |
|---|---|
Ctrl+A | Select all |
Ctrl+C | Copy selection (not in a password field) |
Ctrl+X | Cut selection (not in a password field) |
Ctrl+V | Paste |
Ctrl+Z | Undo (not in a password field) |
Ctrl+Shift+Z or Ctrl+Y | Redo (not in a password field) |
Left/Right | Move cursor |
Ctrl+Left/Right | Move to the start or the end of a word (to the edge in a password field) |
Shift+Left/Right | Extend selection |
Home/End | Move to start/end |
Backspace | Delete before cursor |
Delete | Delete 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/Rightstops 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:
| Mode | Description |
|---|---|
ContentFit::Contain | Fit within the box, preserving aspect ratio (default) |
ContentFit::Cover | Fill the box, cropping, preserving aspect ratio |
ContentFit::Fill | Stretch to fill exactly, ignoring aspect ratio |
ContentFit::None | Intrinsic 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 forContentFit::Coveronly 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.
| Property | Default | Reactive | Animatable |
|---|---|---|---|
size | 24 | yes | |
stroke | 2 | yes | |
color | white | yes | yes |
track | none | yes | |
caps | Cap::Butt | yes | |
start | 0 | yes | |
sweep | 360 | yes | |
progress | 0 | yes | yes |
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
- State Layer API - Overview of the state layer system
- Hover & Pressed States - Define visual overrides per state
- Disabling a Subtree - Stop a whole subtree taking input, and style it
- Ripple Effects - Material Design-style touch feedback
- Event Handling - Click, hover, and scroll events
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
- Press - a disc appears at the click point, already at about a third of its final size, and starts spreading
- Release inside - the press happened, so the remaining expansion is completed rather than interrupted, while the disc fades out
- Release elsewhere, or drag off the button - nothing was activated, and
the ripple says the same thing
on_clickdoes: 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
| Phase | Duration | Scaled by |
|---|---|---|
| Opacity rise on contact | 75ms | fade_speed |
| Growth while held | 1s | expand_speed |
| Growth remaining after release | 225ms | expand_speed |
| Fade after a release | 375ms | fade_speed |
| Fade after an abandoned press | 75ms | fade_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
| Background | Ripple Color |
|---|---|
| Dark | Color::rgba(1.0, 1.0, 1.0, 0.2-0.3) |
| Light | Color::rgba(0.0, 0.0, 0.0, 0.1-0.2) |
| Colored | Lighter 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 amountdy- Vertical scroll amountsource- 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
- Transitions - Duration-based animations
- Timing Functions - Easing curves for natural motion
- Spring Physics - Physics-based animations
- Keyframes - Sequences played on a trigger
- Animatable Properties - What can be animated
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 millisecondstiming- 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
| Duration | Use Case |
|---|---|
| 100-150ms | Quick feedback (button press) |
| 150-200ms | State changes (hover) |
| 200-300ms | Content changes (expand/collapse) |
| 300-500ms | Major 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
| Property | Declared on | Recommended Transition |
|---|---|---|
| Background | background(..) | Duration, EaseOut |
| Border width | border(width, _) | Duration, EaseOut |
| Border colour | border(_, colour) | Duration, EaseOut |
| Outline width, colour, offset | outline(width, colour, offset) | Duration, EaseOut |
| Corners | corners(..) | Duration |
| Padding | padding(..) | Duration |
| Translate | translate(..) | Spring or Duration |
| Rotate | rotate(..) | Spring or Duration |
| Scale | scale(..) | Spring or Duration |
| Opacity | opacity(..) | Duration, EaseOut |
| Blur | blur(..) | Duration, EaseOut |
| Width, height | width(..), height(..) | Spring |
| Position given by layout | animate_position(..) | Spring |
| Shadow | shadow(..) | Duration, EaseOut |
| Text colour | color(..) on a text or text_input | Duration, EaseOut |
| Font size | font_size(..) on a text or text_input | Duration |
| Arc progress, colour | progress(..), color(..) on an arc | Duration, EaseOut |
Best Practices
Match Durations for Related Properties
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
- Transform Basics - Translate, rotate, and scale
- Pivots - Control the point rotation and scale act about
- Animated Transforms - Smooth transform animations
- Nested Transforms - Parent-child transform composition
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
| Pivot | Position |
|---|---|
CENTER | 50%, 50% (default) |
TOP_LEFT | 0%, 0% |
TOP_RIGHT | 100%, 0% |
BOTTOM_LEFT | 0%, 100% |
BOTTOM_RIGHT | 100%, 100% |
TOP | 50%, 0% |
BOTTOM | 50%, 100% |
LEFT | 0%, 50% |
RIGHT | 100%, 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 edge0.5= center1.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- BalancedSpringConfig::GENTLE- Slower and smooth, minimal overshootSpringConfig::SNAPPY- Quickest response, subtle overshootSpringConfig::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
- Keep it simple - Deep transform nesting can be hard to reason about
- Use for grouping - Apply a transform to a parent to affect all children
- 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
- Creating Components - Reusable widgets with the
#[component]macro - Custom Widgets and Layouts - Implementing
WidgetandLayoutfrom outside the crate - Dynamic Children - Reactive lists and conditional rendering
- Background Tasks - Integrating async operations
- Widget Ref - Querying widget bounds at runtime
- Wayland Layer Shell - Positioning and layer configuration
- App Lifecycle - Quit, restart, and exit handling
- Context - App-wide state without prop drilling
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:
- The widget is dropped
on_cleanupcallbacks run- Effects are disposed
- 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 cornerwidth,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
ZStackand give the followerfill().
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)
;
}
| Layer | Description |
|---|---|
Background | Below all windows |
Bottom | Above background, below windows |
Top | Above windows (default) |
Overlay | Above 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)
;
}
| Mode | Description |
|---|---|
None | Surface never receives keyboard focus |
OnDemand | Surface receives focus when clicked (default) |
Exclusive | Surface 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)
;
}
| Anchor | Effect |
|---|---|
TOP | Attach to top edge |
BOTTOM | Attach to bottom edge |
LEFT | Attach to left edge |
RIGHT | Attach 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_statereturns toUnlockedand the lock surfaces already made are dropped. - A lock surface lasts as long as the lock.
close()on itsSurfaceHandledoes nothing but log a warning — a dismiss button, or a component written for ordinary surfaces, cannot leave an output uncovered — andunlock_sessionis 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_sessioncall. - 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
| Approach | Best for |
|---|---|
| Pass signals directly | Parent-child, 1-2 levels deep, few consumers |
| Context | App-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
| Module | Purpose |
|---|---|
reactive/ | Signals, memos, effects |
widgets/ | Container, Text, Layout trait |
renderer/ | wgpu rendering, shaders, text |
platform/ | Wayland layer shell integration |
transform.rs | 2D transformation matrices |
In This Section
- System Overview - Module structure and key types
- Rendering Pipeline - How shapes and text are drawn
- Event System - Input event flow and handling
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 viacreate_signal(). Supports.get(),.set(),.update(),.writer()Signal<T>- Read-only reactive values (12 bytes,Copy). Created viacreate_stored(),create_derived(), orRwSignal::read_only(). Supports.get(),.with()— no.set()Memo<T>- Eager derived values that only notify on actual changesEffect- 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 managementPaintContext- 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
| File | Purpose |
|---|---|
src/lib.rs | App entry, main loop |
src/widgets/container/ | Container implementation |
src/widgets/state_layer.rs | State layer types |
src/renderer/mod.rs | Renderer, GPU setup |
src/renderer/types.rs | Shape types (Gradient, Shadow) |
src/renderer/shader.wgsl | GPU shaders |
src/reactive/signal.rs | Signal implementation |
src/transform.rs | Transform matrices |
src/platform/mod.rs | Wayland 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:
- Calculates its preferred size within constraints
- Positions children (if any)
- 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:
- Shapes - Container backgrounds, borders, arcs
- Paths - Filled and stroked paths
- Images - Image content
- Text - Text content
- 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:
- Text widget provides content and style
- Glyphon lays out glyphs
- Glyphs render from a texture atlas
- 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:
- Scroll offset is stored as a transform
- Transform is applied during paint phase
- Children render at their original layout positions
- The transform shifts content visually
- 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, orNone: see Where an event isdelta_x- Horizontal scroll amount, in pixelsdelta_y- Vertical scroll amount, in pixelssource-Wheel,FingerorContinuous
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:
| Event | Reaches |
|---|---|
| A pointer event with a position — move, press, release, wheel, a scroll’s end, enter | The 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 down | The widgets the pointer record owes one, and nothing else |
| A key, and the surface gaining or losing the keyboard | The 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 paste | The 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:
- MouseEnter → Set hover state true
- 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
- MouseDown → Set pressed state true, record click point
- 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);
}
;
}