feat(ui-dioxus): add signal-based PulsingDot animation (Blitz-friendly)

This commit is contained in:
Hibryda 2026-03-19 06:39:47 +01:00
parent 6f9607d1ba
commit b0547d5c05
4 changed files with 83 additions and 14 deletions

View file

@ -6,3 +6,4 @@ pub mod agent_pane;
pub mod terminal;
pub mod settings;
pub mod command_palette;
pub mod pulsing_dot;

View file

@ -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}" }

View file

@ -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<String>) -> 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;
",
}
}
}

View file

@ -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" }
}