From b0547d5c05cf93c2005ec902f294b1a5208e9b37 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 06:39:47 +0100 Subject: [PATCH] feat(ui-dioxus): add signal-based PulsingDot animation (Blitz-friendly) --- ui-dioxus/src/components/mod.rs | 1 + ui-dioxus/src/components/project_box.rs | 11 +++- ui-dioxus/src/components/pulsing_dot.rs | 69 +++++++++++++++++++++++++ ui-dioxus/src/components/status_bar.rs | 16 ++---- 4 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 ui-dioxus/src/components/pulsing_dot.rs diff --git a/ui-dioxus/src/components/mod.rs b/ui-dioxus/src/components/mod.rs index 8f24013..f9ae0cb 100644 --- a/ui-dioxus/src/components/mod.rs +++ b/ui-dioxus/src/components/mod.rs @@ -6,3 +6,4 @@ pub mod agent_pane; pub mod terminal; pub mod settings; pub mod command_palette; +pub mod pulsing_dot; diff --git a/ui-dioxus/src/components/project_box.rs b/ui-dioxus/src/components/project_box.rs index 0eab2f9..49a68a7 100644 --- a/ui-dioxus/src/components/project_box.rs +++ b/ui-dioxus/src/components/project_box.rs @@ -12,6 +12,7 @@ use crate::state::{ }; use crate::components::agent_pane::AgentPane; use crate::components::terminal::TerminalArea; +use crate::components::pulsing_dot::{PulsingDot, DotState}; #[component] pub fn ProjectBox( @@ -31,7 +32,13 @@ pub fn ProjectBox( }); let terminal_lines = demo_terminal_lines(); - let status_class = initial_status.css_class(); + let dot_state = match initial_status { + AgentStatus::Running => DotState::Running, + AgentStatus::Idle => DotState::Idle, + AgentStatus::Done => DotState::Done, + AgentStatus::Stalled => DotState::Stalled, + AgentStatus::Error => DotState::Error, + }; rsx! { div { @@ -40,7 +47,7 @@ pub fn ProjectBox( // Header div { class: "project-header", - div { class: "status-dot {status_class}" } + PulsingDot { state: dot_state.clone() } div { class: "project-name", "{project.name}" } span { class: "provider-badge", "{project.provider}" } div { class: "project-cwd", "{project.cwd}" } diff --git a/ui-dioxus/src/components/pulsing_dot.rs b/ui-dioxus/src/components/pulsing_dot.rs new file mode 100644 index 0000000..e196ed2 --- /dev/null +++ b/ui-dioxus/src/components/pulsing_dot.rs @@ -0,0 +1,69 @@ +/// 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). + +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub enum DotState { + Running, + Idle, + Stalled, + Done, + Error, +} + +#[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 + 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; + } + going_down = !going_down; + } + }); + } + + let (color, shadow) = match state { + DotState::Running => ("var(--ctp-green)", "0 0 6px var(--ctp-green)"), + DotState::Idle => ("var(--ctp-overlay0)", "none"), + DotState::Stalled => ("var(--ctp-peach)", "0 0 4px var(--ctp-peach)"), + DotState::Done => ("var(--ctp-blue)", "none"), + DotState::Error => ("var(--ctp-red)", "0 0 4px var(--ctp-red)"), + }; + + let sz = size.unwrap_or_else(|| "8px".to_string()); + let op = if should_pulse { *opacity.read() } else { 1.0 }; + + rsx! { + span { + style: " + display: inline-block; + width: {sz}; + height: {sz}; + border-radius: 50%; + background: {color}; + box-shadow: {shadow}; + opacity: {op}; + flex-shrink: 0; + ", + } + } +} diff --git a/ui-dioxus/src/components/status_bar.rs b/ui-dioxus/src/components/status_bar.rs index b0a29c5..9b52aa6 100644 --- a/ui-dioxus/src/components/status_bar.rs +++ b/ui-dioxus/src/components/status_bar.rs @@ -3,6 +3,7 @@ /// Mirrors the Svelte app's StatusBar.svelte (Mission Control bar). use dioxus::prelude::*; +use crate::components::pulsing_dot::{PulsingDot, DotState}; #[derive(Clone, PartialEq)] pub struct FleetState { @@ -22,20 +23,14 @@ pub fn StatusBar(fleet: FleetState) -> Element { div { class: "status-bar-left", // Running div { class: "status-item", - div { - class: "status-dot running", - style: "width: 6px; height: 6px; border-radius: 50%; display: inline-block;", - } + PulsingDot { state: DotState::Running, size: "6px".to_string() } span { class: "status-count running", "{fleet.running}" } span { "running" } } // Idle div { class: "status-item", - div { - class: "status-dot idle", - style: "width: 6px; height: 6px; border-radius: 50%; display: inline-block;", - } + PulsingDot { state: DotState::Idle, size: "6px".to_string() } span { class: "status-count idle", "{fleet.idle}" } span { "idle" } } @@ -43,10 +38,7 @@ pub fn StatusBar(fleet: FleetState) -> Element { // Stalled if fleet.stalled > 0 { div { class: "status-item", - div { - class: "status-dot stalled", - style: "width: 6px; height: 6px; border-radius: 50%; display: inline-block;", - } + PulsingDot { state: DotState::Stalled, size: "6px".to_string() } span { class: "status-count stalled", "{fleet.stalled}" } span { "stalled" } }