From 573105eae6b1d14e7797545b68267859346dc545 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 07:59:32 +0100 Subject: [PATCH] feat(ui-gpui): render-driven pulse via request_animation_frame() (GPUI native) --- ui-gpui/src/components/project_box.rs | 7 ++-- ui-gpui/src/components/pulsing_dot.rs | 49 ++++++++++----------------- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index e5a35a9..f9bf022 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -88,6 +88,7 @@ impl ProjectBox { /// 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, @@ -95,10 +96,8 @@ impl ProjectBox { 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 + let status_dot = cx.new(|_cx: &mut Context| { + PulsingDot::new(dot_status, 8.0) }); self.status_dot = Some(status_dot); diff --git a/ui-gpui/src/components/pulsing_dot.rs b/ui-gpui/src/components/pulsing_dot.rs index 948c44e..9d9d65c 100644 --- a/ui-gpui/src/components/pulsing_dot.rs +++ b/ui-gpui/src/components/pulsing_dot.rs @@ -1,10 +1,10 @@ -//! PulsingDot — GPU-native smooth pulse using GPUI's async runtime. +//! PulsingDot — GPU-native animation using render-driven frame requests. //! -//! Uses cx.spawn() + background_executor().timer() + entity.update() to -//! smoothly cycle through opacity steps. GPUI only repaints the dirty view. +//! Uses request_animation_frame() during render to schedule next frame. +//! Only runs while the dot is visible. GPUI repaints only dirty views. use gpui::*; -use std::time::Duration; +use std::time::Instant; use crate::theme; @@ -17,21 +17,18 @@ pub enum DotStatus { 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, + start_time: Instant, } impl PulsingDot { pub fn new(status: DotStatus, size: f32) -> Self { Self { status, - step: 0, size, + start_time: Instant::now(), } } @@ -49,35 +46,25 @@ impl PulsingDot { } } - /// Start the pulse animation. Call from Context after entity creation. - pub fn start_animation(&self, cx: &mut Context) { + fn current_opacity(&self) -> f32 { if !self.should_pulse() { - return; + return 1.0; } - - 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(); + let elapsed = self.start_time.elapsed().as_secs_f32(); + // Sine wave: 2s period, oscillates between 0.4 and 1.0 + 0.7 + 0.3 * (elapsed * std::f32::consts::PI).sin() } } impl Render for PulsingDot { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + 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 alpha = self.current_opacity(); + + // Schedule next frame if pulsing — this is how GPUI does continuous animation + if self.should_pulse() { + window.request_animation_frame(); + } let r = (base.r * 255.0) as u32; let g = (base.g * 255.0) as u32;