From ba74e19ff29ae1968445ed75359d69bcae2f6516 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 09:20:04 +0100 Subject: [PATCH] feat(ui-gpui): Zed-style BlinkManager pattern for pulsing dot (epoch guard, 500ms timer) --- ui-gpui/src/components/project_box.rs | 30 ++--- ui-gpui/src/components/pulsing_dot.rs | 156 ++++++++++---------------- 2 files changed, 77 insertions(+), 109 deletions(-) diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index d94a725..973a8a5 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -10,7 +10,7 @@ use gpui::*; use crate::state::{AgentStatus, Project, ProjectTab}; use crate::theme; -use crate::components::pulsing_dot::{PulsingDotElement, DotStatus}; +use crate::components::pulsing_dot::{PulsingDot, DotStatus}; // ── Accent Colors by Index ────────────────────────────────────────── @@ -72,7 +72,7 @@ pub struct ProjectBox { pub agent_pane: Option>, /// Entity handle for the embedded terminal (Model tab) pub terminal_view: Option>, - _status_dot_placeholder: (), // PulsingDotElement is created inline during render + pub status_dot: Option>, } impl ProjectBox { @@ -81,13 +81,25 @@ impl ProjectBox { project, agent_pane: None, terminal_view: None, - _status_dot_placeholder: (), + status_dot: None, } } /// Initialize sub-views. Must be called after the ProjectBox entity is created. pub fn init_subviews(&mut self, cx: &mut Context) { eprintln!("[ProjectBox] init_subviews for {}", self.project.name); + + // 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 dot = cx.new(|_cx| PulsingDot::new(dot_status, 8.0)); + self.status_dot = Some(dot); + // NOTE: start_blinking NOT called yet — testing if app stays alive without it + // Create agent pane with demo messages let agent_pane = cx.new(|_cx| { crate::components::agent_pane::AgentPane::with_demo_messages() @@ -238,16 +250,8 @@ impl Render for ProjectBox { .rounded(px(2.0)) .bg(accent), ) - // Animated status dot — custom Element, paints directly to GPU - .child({ - let dot_status = match status { - AgentStatus::Running => DotStatus::Running, - AgentStatus::Idle => DotStatus::Idle, - AgentStatus::Done => DotStatus::Done, - AgentStatus::Error => DotStatus::Error, - }; - PulsingDotElement::new(dot_status, 8.0) - }) + // Animated status dot (Zed BlinkManager pattern) + .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 index 6def815..a25b959 100644 --- a/ui-gpui/src/components/pulsing_dot.rs +++ b/ui-gpui/src/components/pulsing_dot.rs @@ -1,12 +1,13 @@ -//! PulsingDot — custom GPUI Element that paints a single animated quad. +//! PulsingDot — Zed-style BlinkManager pattern. //! -//! Instead of using the vdom (div().bg(color)), we implement Element directly -//! and call window.paint_quad() in paint(). Combined with request_animation_frame(), -//! GPUI only repaints THIS element's quad — not the full vdom tree. -//! The GPU handles the color interpolation via the paint_quad shader. +//! Uses cx.spawn() + background_executor().timer() + cx.notify() — exactly +//! how Zed's BlinkManager achieves ~1% CPU cursor blink. +//! +//! Key: spawn must be called AFTER entity is registered (not inside cx.new()). +//! The epoch counter cancels stale timers automatically. use gpui::*; -use std::time::Instant; +use std::time::Duration; use crate::theme; @@ -19,18 +20,20 @@ pub enum DotStatus { Error, } -pub struct PulsingDotElement { +pub struct PulsingDot { status: DotStatus, - size: Pixels, - start_time: Instant, + size: f32, + visible: bool, // toggles each blink + blink_epoch: usize, // cancels stale timers (Zed pattern) } -impl PulsingDotElement { +impl PulsingDot { pub fn new(status: DotStatus, size: f32) -> Self { Self { status, - size: px(size), - start_time: Instant::now(), + size, + visible: true, + blink_epoch: 0, } } @@ -38,98 +41,59 @@ impl PulsingDotElement { matches!(self.status, DotStatus::Running | DotStatus::Stalled) } - fn base_color(&self) -> Rgba { + fn next_epoch(&mut self) -> usize { + self.blink_epoch += 1; + self.blink_epoch + } + + /// Start blinking. MUST be called after entity is registered (not in cx.new). + /// This is the Zed BlinkManager pattern — recursive spawn with epoch guard. + pub fn start_blinking(&mut self, cx: &mut Context) { + if !self.should_pulse() { + return; + } + let epoch = self.next_epoch(); + self.schedule_blink(epoch, cx); + } + + fn schedule_blink(&self, epoch: usize, cx: &mut Context) { + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor().timer(Duration::from_millis(500)).await; + this.update(cx, |dot, cx| { + // Epoch guard: if epoch changed (pause/resume), this timer is stale + if dot.blink_epoch == epoch { + dot.visible = !dot.visible; + cx.notify(); // marks ONLY this view dirty + dot.schedule_blink(epoch, cx); // recursive schedule + } + }).ok(); + }).detach(); + } + + fn color(&self) -> Rgba { match self.status { - DotStatus::Running => theme::GREEN, + DotStatus::Running => if self.visible { theme::GREEN } else { theme::SURFACE1 }, DotStatus::Idle => theme::OVERLAY0, - DotStatus::Stalled => theme::PEACH, + DotStatus::Stalled => if self.visible { theme::PEACH } else { theme::SURFACE1 }, DotStatus::Done => theme::BLUE, DotStatus::Error => theme::RED, } } - - fn current_color(&self) -> Hsla { - let base = self.base_color(); - if !self.should_pulse() { - return base.into(); - } - let elapsed = self.start_time.elapsed().as_secs_f32(); - let t = 0.5 - 0.5 * (elapsed * std::f32::consts::PI).sin(); - let bg = theme::SURFACE0; - let color = Rgba { - r: base.r + (bg.r - base.r) * t, - g: base.g + (bg.g - base.g) * t, - b: base.b + (bg.b - base.b) * t, - a: 1.0, - }; - color.into() - } } -impl IntoElement for PulsingDotElement { - type Element = Self; - fn into_element(self) -> Self { self } -} +impl Render for PulsingDot { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let color = self.color(); + let r = (color.r * 255.0) as u32; + let g = (color.g * 255.0) as u32; + let b = (color.b * 255.0) as u32; + let hex = rgba(r * 0x1000000 + g * 0x10000 + b * 0x100 + 0xFF); -impl Element for PulsingDotElement { - type RequestLayoutState = (); - type PrepaintState = (); - - fn id(&self) -> Option { None } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let layout_id = window.request_layout( - Style { - size: Size { width: self.size.into(), height: self.size.into() }, - flex_shrink: 0.0, - ..Default::default() - }, - [], - cx, - ); - (layout_id, ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - _window: &mut Window, - _cx: &mut App, - ) -> Self::PrepaintState { - () - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - _prepaint: &mut Self::PrepaintState, - window: &mut Window, - _cx: &mut App, - ) { - let color = self.current_color(); - let radius = self.size / 2.0; - - // Paint a single rounded quad — this is the ONLY thing that gets drawn - window.paint_quad(fill(bounds, color).corner_radii(radius)); - - // Schedule next animation frame — GPUI will call paint() again - // But this time, ONLY this element gets repainted (not the full vdom) - if self.should_pulse() { - window.request_animation_frame(); - } + div() + .w(px(self.size)) + .h(px(self.size)) + .rounded(px(self.size / 2.0)) + .bg(hex) + .flex_shrink_0() } }