feat(ui-gpui): custom Element with direct paint_quad() for zero-overhead pulse

This commit is contained in:
Hibryda 2026-03-19 08:16:44 +01:00
parent 713b53ba0c
commit 3383334821
2 changed files with 90 additions and 68 deletions

View file

@ -10,7 +10,7 @@ use gpui::*;
use crate::state::{AgentStatus, Project, ProjectTab}; use crate::state::{AgentStatus, Project, ProjectTab};
use crate::theme; use crate::theme;
use crate::components::pulsing_dot::{PulsingDot, DotStatus}; use crate::components::pulsing_dot::{PulsingDotElement, DotStatus};
// ── Accent Colors by Index ────────────────────────────────────────── // ── Accent Colors by Index ──────────────────────────────────────────
@ -72,8 +72,7 @@ pub struct ProjectBox {
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>, pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
/// Entity handle for the embedded terminal (Model tab) /// Entity handle for the embedded terminal (Model tab)
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>, pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
/// Animated status dot _status_dot_placeholder: (), // PulsingDotElement is created inline during render
pub status_dot: Option<Entity<PulsingDot>>,
} }
impl ProjectBox { impl ProjectBox {
@ -82,27 +81,13 @@ impl ProjectBox {
project, project,
agent_pane: None, agent_pane: None,
terminal_view: None, terminal_view: None,
status_dot: None, _status_dot_placeholder: (),
} }
} }
/// Initialize sub-views. Must be called after the ProjectBox entity is created. /// Initialize sub-views. Must be called after the ProjectBox entity is created.
pub fn init_subviews(&mut self, cx: &mut Context<Self>) { pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
eprintln!("[ProjectBox] init_subviews for {}", self.project.name); 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<PulsingDot>| {
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 // Create agent pane with demo messages
let agent_pane = cx.new(|_cx| { let agent_pane = cx.new(|_cx| {
crate::components::agent_pane::AgentPane::with_demo_messages() crate::components::agent_pane::AgentPane::with_demo_messages()
@ -253,8 +238,16 @@ impl Render for ProjectBox {
.rounded(px(2.0)) .rounded(px(2.0))
.bg(accent), .bg(accent),
) )
// Animated status dot // Animated status dot — custom Element, paints directly to GPU
.children(self.status_dot.clone()) .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 // Project name
.child( .child(
div() div()

View file

@ -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() //! Instead of using the vdom (div().bg(color)), we implement Element directly
//! with a 200ms sleep to schedule the NEXT render 200ms later. //! and call window.paint_quad() in paint(). Combined with request_animation_frame(),
//! 5 repaints/sec for a pulsing dot is smooth enough and costs ~1-2% CPU. //! 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 gpui::*;
use std::time::{Duration, Instant}; use std::time::Instant;
use crate::theme; use crate::theme;
@ -18,20 +19,18 @@ pub enum DotStatus {
Error, Error,
} }
pub struct PulsingDot { pub struct PulsingDotElement {
status: DotStatus, status: DotStatus,
size: f32, size: Pixels,
start_time: Instant, start_time: Instant,
last_render: Instant,
} }
impl PulsingDot { impl PulsingDotElement {
pub fn new(status: DotStatus, size: f32) -> Self { pub fn new(status: DotStatus, size: f32) -> Self {
Self { Self {
status, status,
size, size: px(size),
start_time: Instant::now(), 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(); let base = self.base_color();
if !self.should_pulse() { if !self.should_pulse() {
return base; return base.into();
} }
let elapsed = self.start_time.elapsed().as_secs_f32(); let elapsed = self.start_time.elapsed().as_secs_f32();
let t = 0.5 - 0.5 * (elapsed * std::f32::consts::PI).sin(); let t = 0.5 - 0.5 * (elapsed * std::f32::consts::PI).sin();
let bg = theme::SURFACE0; let bg = theme::SURFACE0;
Rgba { let color = Rgba {
r: base.r + (bg.r - base.r) * t, r: base.r + (bg.r - base.r) * t,
g: base.g + (bg.g - base.g) * t, g: base.g + (bg.g - base.g) * t,
b: base.b + (bg.b - base.b) * t, b: base.b + (bg.b - base.b) * t,
a: 1.0, a: 1.0,
} };
} color.into()
/// Start a throttled animation loop using cx.spawn + background timer
pub fn start_throttled_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.last_render = Instant::now();
cx.notify();
});
if ok.is_err() {
break;
}
}
}).detach();
} }
} }
impl Render for PulsingDot { impl IntoElement for PulsingDotElement {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { type Element = Self;
fn into_element(self) -> Self { self }
}
impl Element for PulsingDotElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> { 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<Pixels>,
_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<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
_cx: &mut App,
) {
let color = self.current_color(); 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; // Schedule next animation frame — GPUI will call paint() again
let g = (color.g * 255.0) as u32; // But this time, ONLY this element gets repainted (not the full vdom)
let b = (color.b * 255.0) as u32; if self.should_pulse() {
let hex = rgba(r * 0x1000000 + g * 0x10000 + b * 0x100 + 0xFF); window.request_animation_frame();
}
div()
.w(px(self.size))
.h(px(self.size))
.rounded(px(self.size / 2.0))
.bg(hex)
.flex_shrink_0()
} }
} }