diff --git a/ui-dioxus/src/components/pulsing_dot.rs b/ui-dioxus/src/components/pulsing_dot.rs index e196ed2..3436412 100644 --- a/ui-dioxus/src/components/pulsing_dot.rs +++ b/ui-dioxus/src/components/pulsing_dot.rs @@ -1,10 +1,13 @@ /// PulsingDot — GPU-friendly animated status indicator. /// -/// Uses Dioxus signals + async timer instead of CSS @keyframes. -/// Blitz/wgpu doesn't optimize CSS animations (causes full-scene repaint every frame). -/// Signal-based approach only repaints the dot element on state change (~2x per cycle). +/// Uses Dioxus use_resource + channel pattern for Blitz-compatible animation. +/// Blitz/wgpu doesn't optimize CSS animations (full-scene repaint every frame). +/// This: OS thread does timing → atomic float → use_resource polls it. use dioxus::prelude::*; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; #[derive(Clone, PartialEq)] pub enum DotState { @@ -18,25 +21,46 @@ pub enum DotState { #[component] pub fn PulsingDot(state: DotState, size: Option) -> Element { let should_pulse = matches!(state, DotState::Running | DotState::Stalled); - let mut opacity = use_signal(|| 1.0f32); - // Spring-style pulse: smoothly interpolate opacity between 1.0 and 0.4 + // Shared atomic for cross-thread opacity (f32 stored as u32 bits) + let atomic_opacity = use_hook(|| Arc::new(AtomicU32::new(f32::to_bits(1.0)))); + let mut rendered_opacity = use_signal(|| 1.0f32); + if should_pulse { - use_future(move || async move { - let mut going_down = true; - loop { - // 8 steps per half-cycle = smooth-ish animation at low repaint cost - for step in 0..8 { - let t = step as f32 / 7.0; - let val = if going_down { - 1.0 - (t * 0.6) // 1.0 → 0.4 - } else { - 0.4 + (t * 0.6) // 0.4 → 1.0 - }; - opacity.set(val); - tokio::time::sleep(std::time::Duration::from_millis(125)).await; + let ao = atomic_opacity.clone(); + // Spawn OS thread once to drive animation timing + use_hook(move || { + std::thread::spawn(move || { + let steps = 8u32; + let step_ms = 125u64; + let mut going_down = true; + loop { + for i in 0..steps { + let t = i as f32 / (steps - 1) as f32; + let val = if going_down { + 1.0 - (t * 0.6) + } else { + 0.4 + (t * 0.6) + }; + ao.store(f32::to_bits(val), Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(step_ms)); + } + going_down = !going_down; + } + }); + }); + + // Poll the atomic value and update signal (runs in Dioxus runtime) + let ao2 = atomic_opacity.clone(); + use_future(move || { + let ao3 = ao2.clone(); + async move { + loop { + let bits = ao3.load(Ordering::Relaxed); + let val = f32::from_bits(bits); + rendered_opacity.set(val); + tokio::time::sleep(Duration::from_millis(60)).await; } - going_down = !going_down; } }); } @@ -50,7 +74,7 @@ pub fn PulsingDot(state: DotState, size: Option) -> Element { }; let sz = size.unwrap_or_else(|| "8px".to_string()); - let op = if should_pulse { *opacity.read() } else { 1.0 }; + let op = if should_pulse { *rendered_opacity.read() } else { 1.0 }; rsx! { span {