Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Canvas Animation Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

// Canvas dimensions (logical pixels).
const W: f32 = 480.0;
const H: f32 = 360.0;

const PALETTE: [&str; 7] = [
    "#ef4444", "#3b82f6", "#10b981", "#f59e0b",
    "#8b5cf6", "#ec4899", "#06b6d4",
];

#[derive(Clone, PartialEq)]
struct Particle {
    x: f32,
    y: f32,
    vx: f32,
    vy: f32,
    radius: f32,
    color_idx: usize,
}

fn init_particles(n: usize) -> Vec<Particle> {
    (0..n)
        .map(|i| {
            let angle = (i as f32) * 2.399;
            let speed = 1.2 + (i as f32 % 3.0) * 0.6;
            Particle {
                x: W * 0.5 + ((i as f32 * 41.0) % (W * 0.5)),
                y: H * 0.5 + ((i as f32 * 67.0) % (H * 0.5)),
                vx: angle.cos() * speed,
                vy: angle.sin() * speed,
                radius: 5.0 + (i as f32 % 3.0) * 2.0,
                color_idx: i % PALETTE.len(),
            }
        })
        .collect()
}

fn app() -> impl gpui::IntoElement {
    let (particles, set_particles) = create_signal(init_particles(12));
    let (running, set_running) = create_signal(true);
    let (speed, set_speed) = create_signal(2u64);
    let (count, set_count) = create_signal(12u32);

    // Derive the effective interval delay from `running` and `speed`.
    // 0 pauses the interval; otherwise ~50ms / speed gives 20–100 fps.
    let delay = create_memo({
        let running = running.clone();
        let speed = speed.clone();
        move || if running.get() { 50u64 / speed.get().max(1) } else { 0 }
    });

    // Clone before moving into use_interval — needed by Reset and
    // particle-count buttons later.
    let set_particles_reset = set_particles.clone();

    // Animation loop: update particle positions on every tick.
    use_interval(
        move |cx| {
            set_particles.update(cx, |ps| {
                for p in ps.iter_mut() {
                    p.x += p.vx;
                    p.y += p.vy;
                    if p.x < p.radius {
                        p.x = p.radius;
                        p.vx = p.vx.abs();
                    }
                    if p.x > W - p.radius {
                        p.x = W - p.radius;
                        p.vx = -p.vx.abs();
                    }
                    if p.y < p.radius {
                        p.y = p.radius;
                        p.vy = p.vy.abs();
                    }
                    if p.y > H - p.radius {
                        p.y = H - p.radius;
                        p.vy = -p.vy.abs();
                    }
                }
            });
        },
        delay,
    );

    // Read signal values during render so the canvas paint closure
    // captures the current snapshot.  The signal reads are tracked,
    // so any change triggers a re-render and a fresh canvas paint.
    let snapshot = particles.get();
    let is_running = running.get();
    let cur_count = count.get();

    // Clones for closures that outlive this render call.
    let set_running_play = set_running.clone();
    let set_running_pause = set_running.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#0f0f1e] w-full h-full text-white rounded">
            <h2 class="text-lg font-bold">{"Particle Animation"}</h2>
            <span class="text-sm text-[#888] -mt-2">
                {"use_interval + canvas — reactive timer-driven rendering"}
            </span>

            // ── Canvas ──────────────────────────────────────────────
            <canvas
                class="w-[480px] h-[360px] bg-[#16162a] rounded"
                paint={move |ctx: &mut Context2D| {
                    // Constellation lines: connect nearby particles.
                    for i in 0..snapshot.len() {
                        for j in (i + 1)..snapshot.len() {
                            let dx = snapshot[i].x - snapshot[j].x;
                            let dy = snapshot[i].y - snapshot[j].y;
                            let dist = (dx * dx + dy * dy).sqrt();
                            if dist < 110.0 {
                                let alpha = (1.0 - dist / 110.0) * 0.4;
                                ctx.set_global_alpha(alpha);
                                ctx.set_stroke_style(color("#5588cc"));
                                ctx.set_line_width(1.0);
                                ctx.begin_path();
                                ctx.move_to(snapshot[i].x, snapshot[i].y);
                                ctx.line_to(snapshot[j].x, snapshot[j].y);
                                ctx.stroke();
                            }
                        }
                    }
                    ctx.set_global_alpha(1.0);

                    // Particles.
                    for p in &snapshot {
                        ctx.set_fill_style(color(PALETTE[p.color_idx]));
                        ctx.begin_path();
                        ctx.arc(p.x, p.y, p.radius, 0.0, std::f32::consts::TAU, false);
                        ctx.fill();
                    }
                }}
            />

            // ── Play / Pause / Reset ────────────────────────────────
            <div class="flex gap-2">
                <button
                    class={twc!(
                        "px-4 py-2 rounded font-medium",
                        is_running.then_some("bg-[#3b82f6] hover:bg-[#2563eb]"),
                        (!is_running).then_some("bg-[#444] hover:bg-[#555]")
                    )}
                    on:click={click(move |cx| set_running_play.set(cx, true))}
                >
                    {"Play"}
                </button>
                <button
                    class={twc!(
                        "px-4 py-2 rounded font-medium",
                        (!is_running).then_some("bg-[#ef4444] hover:bg-[#dc2626]"),
                        is_running.then_some("bg-[#444] hover:bg-[#555]")
                    )}
                    on:click={click(move |cx| set_running_pause.set(cx, false))}
                >
                    {"Pause"}
                </button>
                <button
                    class="px-4 py-2 rounded font-medium bg-[#6366f1] hover:bg-[#4f46e5]"
                    on:click={click({
                        let sp = set_particles_reset.clone();
                        move |cx| sp.set(cx, init_particles(cur_count as usize))
                    })}
                >
                    {"Reset"}
                </button>
            </div>

            // ── Speed selector ──────────────────────────────────────
            <div class="flex gap-2 items-center">
                <span class="text-sm text-[#888] w-16">{"Speed"}</span>
                {vgui::for_each([1u64, 2, 3, 5], move |s, _| {
                    let set_speed = set_speed.clone();
                    let speed = speed.clone();
                    view! {
                        <button
                            class={twc!(
                                "px-3 py-1.5 rounded text-sm",
                                (speed.get() == s).then_some("bg-[#10b981] text-white"),
                                (speed.get() != s).then_some("bg-[#333] hover:bg-[#444] text-[#ccc]")
                            )}
                            on:click={click(move |cx| set_speed.set(cx, s))}
                        >
                            {format!("{}x", s)}
                        </button>
                    }
                })}
            </div>

            // ── Particle count selector ─────────────────────────────
            <div class="flex gap-2 items-center">
                <span class="text-sm text-[#888] w-16">{"Particles"}</span>
                {vgui::for_each([6u32, 12, 48, 96], move |n, _| {
                    let set_count = set_count.clone();
                    let count = count.clone();
                    let set_particles = set_particles_reset.clone();
                    view! {
                        <button
                            class={twc!(
                                "px-3 py-1.5 rounded text-sm",
                                (count.get() == n).then_some("bg-[#10b981] text-white"),
                                (count.get() != n).then_some("bg-[#333] hover:bg-[#444] text-[#ccc]")
                            )}
                            on:click={click(move |cx| {
                                set_count.set(cx, n);
                                set_particles.set(cx, init_particles(n as usize));
                            })}
                        >
                            {format!("{}", n)}
                        </button>
                    }
                })}
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(540.), px(600.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Combining use_interval with <canvas>

The animation loop is driven by use_interval, which ticks a reactive timer and updates a Vec<Particle> signal on every tick. The <canvas> element’s paint closure reads the particle snapshot during render, so each signal update triggers a re-render and a fresh canvas paint.

Reactive speed control

The interval delay is derived from a speed signal via create_memo:

#![allow(unused)]
fn main() {
let delay = create_memo(move || {
    if running.get() { 50 / speed.get().max(1) } else { 0 }
});
use_interval(move |cx| set_particles.update(cx, |ps| { /* ... */ }), delay);
}

Changing the speed or pausing re-runs the effect inside use_interval, cancelling the old timer and starting a new one. Setting delay to 0 pauses the animation entirely.

Particle physics

Each tick updates particle positions and bounces them off the canvas boundaries. The paint closure also draws constellation lines between nearby particles, with opacity proportional to distance.

Running

Native

cargo run -p vgui-canvas-animation

Web (WASM)

cargo build --target wasm32-unknown-unknown -p vgui-canvas-animation --release
wasm-bindgen --target web --out-dir examples/canvas-animation/dist \
    --no-typescript target/wasm32-unknown-unknown/release/canvas-animation.wasm
python3 scripts/serve_plain.py 8080 examples/canvas-animation