feat(ui-gpui): add pulsing dot via GPUI async runtime (entity-scoped repaint)

This commit is contained in:
Hibryda 2026-03-19 07:51:25 +01:00
parent 3e6307ffd0
commit 57e0e3a087
3 changed files with 116 additions and 2 deletions

View file

@ -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;

View file

@ -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<crate::components::agent_pane::AgentPane>>,
/// Entity handle for the embedded terminal (Model tab)
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
/// Animated status dot
pub status_dot: Option<Entity<PulsingDot>>,
}
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<Self>) {
// 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<PulsingDot>| {
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()

View file

@ -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<Self>) {
if !self.should_pulse() {
return;
}
cx.spawn(async move |weak: WeakEntity<Self>, 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<Self>) -> 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()
}
}