From 338333482163282450aa3717bea1fa72d0c3a61d Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 08:16:44 +0100 Subject: [PATCH] feat(ui-gpui): custom Element with direct paint_quad() for zero-overhead pulse --- ui-gpui/src/components/project_box.rs | 33 +++---- ui-gpui/src/components/pulsing_dot.rs | 125 ++++++++++++++++---------- 2 files changed, 90 insertions(+), 68 deletions(-) diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index 0f04dfb..d94a725 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::{PulsingDot, DotStatus}; +use crate::components::pulsing_dot::{PulsingDotElement, DotStatus}; // ── Accent Colors by Index ────────────────────────────────────────── @@ -72,8 +72,7 @@ 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>, + _status_dot_placeholder: (), // PulsingDotElement is created inline during render } impl ProjectBox { @@ -82,27 +81,13 @@ impl ProjectBox { project, agent_pane: None, terminal_view: None, - status_dot: None, + _status_dot_placeholder: (), } } /// 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 status_dot = cx.new(|cx: &mut Context| { - let dot = PulsingDot::new(dot_status, 8.0); - dot.start_throttled_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() @@ -253,8 +238,16 @@ impl Render for ProjectBox { .rounded(px(2.0)) .bg(accent), ) - // Animated status dot - .children(self.status_dot.clone()) + // 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) + }) // Project name .child( div() diff --git a/ui-gpui/src/components/pulsing_dot.rs b/ui-gpui/src/components/pulsing_dot.rs index 6f344ec..6def815 100644 --- a/ui-gpui/src/components/pulsing_dot.rs +++ b/ui-gpui/src/components/pulsing_dot.rs @@ -1,11 +1,12 @@ -//! PulsingDot — throttled render-driven animation at ~5fps (not vsync 60fps). +//! PulsingDot — custom GPUI Element that paints a single animated quad. //! -//! request_animation_frame() at vsync = 90% CPU. Instead, use on_next_frame() -//! with a 200ms sleep to schedule the NEXT render 200ms later. -//! 5 repaints/sec for a pulsing dot is smooth enough and costs ~1-2% CPU. +//! 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. use gpui::*; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::theme; @@ -18,20 +19,18 @@ pub enum DotStatus { Error, } -pub struct PulsingDot { +pub struct PulsingDotElement { status: DotStatus, - size: f32, + size: Pixels, start_time: Instant, - last_render: Instant, } -impl PulsingDot { +impl PulsingDotElement { pub fn new(status: DotStatus, size: f32) -> Self { Self { status, - size, + size: px(size), start_time: Instant::now(), - last_render: Instant::now(), } } @@ -49,58 +48,88 @@ impl PulsingDot { } } - fn current_color(&self) -> Rgba { + fn current_color(&self) -> Hsla { let base = self.base_color(); if !self.should_pulse() { - return base; + 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; - Rgba { + 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, - } - } - - /// Start a throttled animation loop using cx.spawn + background timer - pub fn start_throttled_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.last_render = Instant::now(); - cx.notify(); - }); - if ok.is_err() { - break; - } - } - }).detach(); + }; + color.into() } } -impl Render for PulsingDot { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { +impl IntoElement for PulsingDotElement { + type Element = Self; + fn into_element(self) -> Self { self } +} + +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; - // NO request_animation_frame() — animation driven by spawn timer above + // Paint a single rounded quad — this is the ONLY thing that gets drawn + window.paint_quad(fill(bounds, color).corner_radii(radius)); - 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); - - div() - .w(px(self.size)) - .h(px(self.size)) - .rounded(px(self.size / 2.0)) - .bg(hex) - .flex_shrink_0() + // 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(); + } } }