From 57e0e3a087c2e75cb47e02da63efad71b5ad6af0 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 07:51:25 +0100 Subject: [PATCH] feat(ui-gpui): add pulsing dot via GPUI async runtime (entity-scoped repaint) --- ui-gpui/src/components/mod.rs | 1 + ui-gpui/src/components/project_box.rs | 22 ++++++- ui-gpui/src/components/pulsing_dot.rs | 95 +++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 ui-gpui/src/components/pulsing_dot.rs diff --git a/ui-gpui/src/components/mod.rs b/ui-gpui/src/components/mod.rs index 0c33f49..b76950a 100644 --- a/ui-gpui/src/components/mod.rs +++ b/ui-gpui/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod agent_pane; pub mod command_palette; pub mod project_box; pub mod project_grid; +pub mod pulsing_dot; pub mod settings; pub mod sidebar; pub mod status_bar; diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index dfbc476..e5a35a9 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -10,6 +10,7 @@ use gpui::*; use crate::state::{AgentStatus, Project, ProjectTab}; use crate::theme; +use crate::components::pulsing_dot::{PulsingDot, DotStatus}; // ── Accent Colors by Index ────────────────────────────────────────── @@ -71,6 +72,8 @@ pub struct ProjectBox { pub agent_pane: Option>, /// Entity handle for the embedded terminal (Model tab) pub terminal_view: Option>, + /// Animated status dot + pub status_dot: Option>, } impl ProjectBox { @@ -79,11 +82,26 @@ impl ProjectBox { project, agent_pane: None, terminal_view: None, + status_dot: None, } } /// Initialize sub-views. Must be called after the ProjectBox entity is created. pub fn init_subviews(&mut self, cx: &mut Context) { + // Create pulsing status dot + let dot_status = match self.project.agent.status { + AgentStatus::Running => DotStatus::Running, + AgentStatus::Idle => DotStatus::Idle, + AgentStatus::Done => DotStatus::Done, + AgentStatus::Error => DotStatus::Error, + }; + let status_dot = cx.new(|cx: &mut Context| { + let dot = PulsingDot::new(dot_status, 8.0); + dot.start_animation(cx); + dot + }); + self.status_dot = Some(status_dot); + // Create agent pane with demo messages let agent_pane = cx.new(|_cx| { crate::components::agent_pane::AgentPane::with_demo_messages() @@ -234,8 +252,8 @@ impl Render for ProjectBox { .rounded(px(2.0)) .bg(accent), ) - // Status dot - .child(project_status_dot(status)) + // Animated status dot + .children(self.status_dot.clone()) // Project name .child( div() diff --git a/ui-gpui/src/components/pulsing_dot.rs b/ui-gpui/src/components/pulsing_dot.rs new file mode 100644 index 0000000..948c44e --- /dev/null +++ b/ui-gpui/src/components/pulsing_dot.rs @@ -0,0 +1,95 @@ +//! PulsingDot — GPU-native smooth pulse using GPUI's async runtime. +//! +//! Uses cx.spawn() + background_executor().timer() + entity.update() to +//! smoothly cycle through opacity steps. GPUI only repaints the dirty view. + +use gpui::*; +use std::time::Duration; + +use crate::theme; + +#[derive(Clone, Copy, PartialEq)] +pub enum DotStatus { + Running, + Idle, + Stalled, + Done, + Error, +} + +/// Pre-computed opacity values for smooth sine-wave pulse (6 steps) +const OPACITIES: [f32; 6] = [1.0, 0.85, 0.6, 0.4, 0.6, 0.85]; + +pub struct PulsingDot { + status: DotStatus, + step: u8, + size: f32, +} + +impl PulsingDot { + pub fn new(status: DotStatus, size: f32) -> Self { + Self { + status, + step: 0, + size, + } + } + + fn should_pulse(&self) -> bool { + matches!(self.status, DotStatus::Running | DotStatus::Stalled) + } + + fn base_color(&self) -> Rgba { + match self.status { + DotStatus::Running => theme::GREEN, + DotStatus::Idle => theme::OVERLAY0, + DotStatus::Stalled => theme::PEACH, + DotStatus::Done => theme::BLUE, + DotStatus::Error => theme::RED, + } + } + + /// Start the pulse animation. Call from Context after entity creation. + pub fn start_animation(&self, cx: &mut Context) { + if !self.should_pulse() { + return; + } + + cx.spawn(async move |weak: WeakEntity, cx: &mut AsyncApp| { + loop { + cx.background_executor().timer(Duration::from_millis(200)).await; + let ok = weak.update(cx, |dot, cx| { + dot.step = (dot.step + 1) % 6; + cx.notify(); + }); + if ok.is_err() { + break; // Entity dropped + } + } + }).detach(); + } +} + +impl Render for PulsingDot { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let base = self.base_color(); + let alpha = if self.should_pulse() { + OPACITIES[self.step as usize % 6] + } else { + 1.0 + }; + + let r = (base.r * 255.0) as u32; + let g = (base.g * 255.0) as u32; + let b = (base.b * 255.0) as u32; + let a = (alpha * 255.0) as u32; + let color = rgba(r * 0x1000000 + g * 0x10000 + b * 0x100 + a); + + div() + .w(px(self.size)) + .h(px(self.size)) + .rounded(px(self.size / 2.0)) + .bg(color) + .flex_shrink_0() + } +}