kael_ui 0.2.0

Professional shadcn-inspired UI component library for Kael. 100+ accessible components for building beautiful, performant desktop applications.
//! Lightweight particle system using canvas painting.

use kael::{prelude::FluentBuilder as _, *};
use std::time::Duration;

#[derive(Clone)]
pub struct Particle {
    pub position: Point<f32>,
    pub velocity: Point<f32>,
    pub age: f32,
    pub lifetime: f32,
    pub size: f32,
    pub color: Hsla,
}

#[derive(Clone)]
pub struct ParticleEmitterConfig {
    pub spawn_rate: f32,
    pub lifetime: Duration,
    pub velocity_range: (f32, f32),
    pub size_range: (f32, f32),
    pub color_start: Hsla,
    pub color_end: Hsla,
    pub gravity: f32,
    pub spread_angle: f32,
    pub max_particles: usize,
    pub origin: Point<f32>,
}

impl Default for ParticleEmitterConfig {
    fn default() -> Self {
        Self {
            spawn_rate: 10.0,
            lifetime: Duration::from_millis(1500),
            velocity_range: (50.0, 150.0),
            size_range: (2.0, 6.0),
            color_start: hsla(0.55, 0.8, 0.6, 1.0),
            color_end: hsla(0.55, 0.8, 0.6, 0.0),
            gravity: 80.0,
            spread_angle: std::f32::consts::PI,
            max_particles: 200,
            origin: Point { x: 0.0, y: 0.0 },
        }
    }
}

pub struct ParticleEmitterState {
    particles: Vec<Particle>,
    config: ParticleEmitterConfig,
    accumulator: f32,
    running: bool,
}

impl ParticleEmitterState {
    pub fn new(_cx: &mut Context<Self>) -> Self {
        Self {
            particles: Vec::with_capacity(200),
            config: ParticleEmitterConfig::default(),
            accumulator: 0.0,
            running: false,
        }
    }

    pub fn with_config(config: ParticleEmitterConfig, _cx: &mut Context<Self>) -> Self {
        let cap = config.max_particles;
        Self {
            particles: Vec::with_capacity(cap),
            config,
            accumulator: 0.0,
            running: false,
        }
    }

    pub fn set_config(&mut self, config: ParticleEmitterConfig, cx: &mut Context<Self>) {
        self.config = config;
        cx.notify();
    }

    pub fn set_origin(&mut self, origin: Point<f32>, cx: &mut Context<Self>) {
        self.config.origin = origin;
        cx.notify();
    }

    pub fn start(&mut self, cx: &mut Context<Self>) {
        if self.running {
            return;
        }
        self.running = true;
        self.schedule_tick(cx);
        cx.notify();
    }

    pub fn stop(&mut self, cx: &mut Context<Self>) {
        self.running = false;
        cx.notify();
    }

    pub fn is_running(&self) -> bool {
        self.running
    }

    pub fn clear(&mut self, cx: &mut Context<Self>) {
        self.particles.clear();
        cx.notify();
    }

    pub fn emit(&mut self, count: usize) {
        let config = &self.config;
        let lifetime_secs = config.lifetime.as_secs_f32();

        for _ in 0..count {
            if self.particles.len() >= config.max_particles {
                break;
            }

            let angle_offset =
                (pseudo_random_f32(self.particles.len() as u32) - 0.5) * config.spread_angle;
            let speed = config.velocity_range.0
                + pseudo_random_f32(self.particles.len() as u32 + 7)
                    * (config.velocity_range.1 - config.velocity_range.0);
            let particle_size = config.size_range.0
                + pseudo_random_f32(self.particles.len() as u32 + 13)
                    * (config.size_range.1 - config.size_range.0);

            let vx = angle_offset.sin() * speed;
            let vy = -angle_offset.cos() * speed;

            self.particles.push(Particle {
                position: config.origin,
                velocity: Point { x: vx, y: vy },
                age: 0.0,
                lifetime: lifetime_secs,
                size: particle_size,
                color: config.color_start,
            });
        }
    }

    pub fn update(&mut self, dt: f32) {
        let gravity = self.config.gravity;
        let color_start = self.config.color_start;
        let color_end = self.config.color_end;

        for particle in &mut self.particles {
            particle.age += dt;
            particle.velocity.y += gravity * dt;
            particle.position.x += particle.velocity.x * dt;
            particle.position.y += particle.velocity.y * dt;

            let t = (particle.age / particle.lifetime).clamp(0.0, 1.0);
            particle.color = lerp_hsla(color_start, color_end, t);
        }

        self.particles.retain(|p| p.age < p.lifetime);
    }

    pub fn particles(&self) -> &[Particle] {
        &self.particles
    }

    fn schedule_tick(&self, cx: &mut Context<Self>) {
        if !self.running {
            return;
        }

        cx.spawn(async |this, cx| {
            cx.background_executor()
                .timer(Duration::from_millis(16))
                .await;

            _ = this.update(cx, |state, cx| {
                if !state.running {
                    return;
                }

                let dt = 1.0 / 60.0;
                state.accumulator += state.config.spawn_rate * dt;

                let to_spawn = state.accumulator as usize;
                state.accumulator -= to_spawn as f32;
                state.emit(to_spawn);
                state.update(dt);

                state.schedule_tick(cx);
                cx.notify();
            });
        })
        .detach();
    }
}

impl Render for ParticleEmitterState {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div()
    }
}

struct EmitterPaintData {
    particles: Vec<Particle>,
}

#[derive(IntoElement)]
pub struct ParticleEmitter {
    id: ElementId,
    state: Entity<ParticleEmitterState>,
    style: StyleRefinement,
}

impl ParticleEmitter {
    pub fn new(id: impl Into<ElementId>, state: Entity<ParticleEmitterState>) -> Self {
        Self {
            id: id.into(),
            state,
            style: StyleRefinement::default(),
        }
    }

    pub fn spawn_rate(self, rate: f32, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.spawn_rate = rate);
        self
    }

    pub fn lifetime(self, lifetime: Duration, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.lifetime = lifetime);
        self
    }

    pub fn velocity_range(self, range: (f32, f32), cx: &mut App) -> Self {
        self.state
            .update(cx, |s, _| s.config.velocity_range = range);
        self
    }

    pub fn size_range(self, range: (f32, f32), cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.size_range = range);
        self
    }

    pub fn color_start(self, color: Hsla, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.color_start = color);
        self
    }

    pub fn color_end(self, color: Hsla, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.color_end = color);
        self
    }

    pub fn gravity(self, gravity: f32, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.gravity = gravity);
        self
    }

    pub fn spread_angle(self, angle: f32, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.spread_angle = angle);
        self
    }

    pub fn max_particles(self, max: usize, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.max_particles = max);
        self
    }

    pub fn origin(self, origin: Point<f32>, cx: &mut App) -> Self {
        self.state.update(cx, |s, _| s.config.origin = origin);
        self
    }
}

impl Styled for ParticleEmitter {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for ParticleEmitter {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let user_style = self.style;
        let state = self.state.read(cx);
        let paint_data = EmitterPaintData {
            particles: state.particles().to_vec(),
        };

        div()
            .id(self.id)
            .relative()
            .size_full()
            .child(
                canvas_with_prepaint(
                    move |_bounds, _window, _cx| paint_data,
                    move |bounds, data, window, _cx| {
                        paint_particles(bounds, &data, window);
                    },
                )
                .absolute()
                .inset_0()
                .size_full(),
            )
            .map(|this| {
                let mut el = this;
                el.style().refine(&user_style);
                el
            })
    }
}

fn paint_particles(bounds: Bounds<Pixels>, data: &EmitterPaintData, window: &mut Window) {
    if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) {
        return;
    }

    for particle in &data.particles {
        let x = bounds.left() + px(particle.position.x);
        let y = bounds.top() + px(particle.position.y);
        let half = particle.size * 0.5;

        if x + px(half) < bounds.left()
            || x - px(half) > bounds.right()
            || y + px(half) < bounds.top()
            || y - px(half) > bounds.bottom()
        {
            continue;
        }

        window.paint_quad(PaintQuad {
            bounds: Bounds {
                origin: point(x - px(half), y - px(half)),
                size: kael::size(px(particle.size), px(particle.size)),
            },
            corner_radii: Corners::all(px(half)),
            background: particle.color.into(),
            border_widths: Edges::default(),
            border_color: transparent_black(),
            border_style: BorderStyle::default(),
            continuous_corners: false,
            transform: Default::default(),
            blend_mode: Default::default(),
        });
    }
}

fn lerp_hsla(a: Hsla, b: Hsla, t: f32) -> Hsla {
    hsla(
        a.h + (b.h - a.h) * t,
        a.s + (b.s - a.s) * t,
        a.l + (b.l - a.l) * t,
        a.a + (b.a - a.a) * t,
    )
}

fn pseudo_random_f32(seed: u32) -> f32 {
    let mut x = seed.wrapping_add(0x9E3779B9);
    x ^= x >> 16;
    x = x.wrapping_mul(0x45D9F3B);
    x ^= x >> 16;
    (x & 0xFFFF) as f32 / 65535.0
}