perf(ui-gpui): isolate StatusDotView entity, document 3% CPU floor

Root cause analysis:
- 0% baseline: calloop 60Hz polling has zero measurable CPU cost
- 3% is entirely from ProjectBox::render() rebuilding ~30 header divs
  × 2 boxes × 2/sec = 120 div constructions/sec
- AgentPane + TerminalView cached (into_cached_flex) = 0% contribution
- mark_view_dirty() walks ancestors unconditionally in GPUI 0.2.2
  → StatusDotView isolation doesn't prevent parent re-render
- Fragment shaders not exposed (paint_quad accepts static color only)
- GPUI AnimationElement uses request_animation_frame = 79% CPU (vsync)

StatusDotView pattern is architecturally correct for future GPUI versions
that may implement per-view dirty isolation.
This commit is contained in:
Hibryda 2026-03-19 23:09:56 +01:00
parent 727c7d2e06
commit f797a676f4
2 changed files with 57 additions and 33 deletions

View file

@ -6,12 +6,48 @@
use gpui::*;
use std::time::Duration;
use crate::state::AgentStatus;
use crate::theme;
pub struct BlinkState {
pub visible: bool,
epoch: usize,
}
/// Tiny view entity that renders just the status dot.
/// Reads BlinkState → only this entity re-renders on blink, not ProjectBox.
pub struct StatusDotView {
status: AgentStatus,
blink: Option<Entity<BlinkState>>,
}
impl StatusDotView {
pub fn new(status: AgentStatus, blink: Option<Entity<BlinkState>>) -> Self {
Self { status, blink }
}
}
impl Render for StatusDotView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let blink_visible = self.blink.as_ref()
.map(|bs| bs.read(cx).visible)
.unwrap_or(true);
let color = match self.status {
AgentStatus::Running if !blink_visible => theme::SURFACE1,
AgentStatus::Running => theme::GREEN,
AgentStatus::Idle => theme::OVERLAY0,
AgentStatus::Done => theme::BLUE,
AgentStatus::Error => theme::RED,
};
div()
.w(px(8.0))
.h(px(8.0))
.rounded(px(4.0))
.bg(color)
.flex_shrink_0()
}
}
impl BlinkState {
pub fn new() -> Self {
Self { visible: true, epoch: 0 }

View file

@ -71,7 +71,7 @@ pub struct ProjectBox {
pub project: Project,
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
pub blink_state: Option<Entity<crate::components::blink_state::BlinkState>>,
pub status_dot_view: Option<Entity<crate::components::blink_state::StatusDotView>>,
// Cached strings to avoid allocation on every render
id_project: SharedString,
id_model: SharedString,
@ -96,7 +96,7 @@ impl ProjectBox {
project,
agent_pane: None,
terminal_view: None,
blink_state: None,
status_dot_view: None,
}
}
@ -104,15 +104,22 @@ impl ProjectBox {
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
// Initialize sub-views after entity registration
// Shared blink state — separate entity, ProjectBox reads it in render.
// cx.notify() on BlinkState only dirties views that .read() it (ProjectBox),
// NOT ancestors (Workspace, ProjectGrid) → siblings stay cached.
// StatusDotView: tiny Entity that reads BlinkState.
// ProjectBox does NOT read BlinkState → doesn't re-render on blink.
// Only StatusDotView (1 div) re-renders 2x/sec.
let should_pulse = matches!(self.project.agent.status, AgentStatus::Running);
if should_pulse {
let blink = cx.new(|_cx| crate::components::blink_state::BlinkState::new());
crate::components::blink_state::BlinkState::start_from_context(&blink, cx);
self.blink_state = Some(blink);
}
let blink = if should_pulse {
let b = cx.new(|_cx| crate::components::blink_state::BlinkState::new());
crate::components::blink_state::BlinkState::start_from_context(&b, cx);
Some(b)
} else {
None
};
let status = self.project.agent.status;
let dot_view = cx.new(|_cx| {
crate::components::blink_state::StatusDotView::new(status, blink)
});
self.status_dot_view = Some(dot_view);
// Create agent pane with demo messages
let agent_pane = cx.new(|_cx| {
@ -261,29 +268,10 @@ impl Render for ProjectBox {
.rounded(px(2.0))
.bg(accent),
)
// Status dot — BlinkState shared entity (2 renders/sec, 3% CPU)
// NOTE: GPUI's AnimationElement uses request_animation_frame() which
// runs at vsync (60fps) = 80% CPU. Fragment shaders not exposed.
// The Zed BlinkManager pattern (timer + notify at 2fps) is the
// correct approach for ambient animation in GPUI.
.child({
let blink_visible = self.blink_state.as_ref()
.map(|bs| bs.read(cx).visible)
.unwrap_or(true);
let color = match self.project.agent.status {
AgentStatus::Running if !blink_visible => theme::SURFACE1,
AgentStatus::Running => theme::GREEN,
AgentStatus::Idle => theme::OVERLAY0,
AgentStatus::Done => theme::BLUE,
AgentStatus::Error => theme::RED,
};
div()
.w(px(8.0))
.h(px(8.0))
.rounded(px(4.0))
.bg(color)
.flex_shrink_0()
})
// Status dot — separate Entity that reads BlinkState.
// ProjectBox does NOT read BlinkState → doesn't re-render on blink.
// Only StatusDotView (1 div, 8x8px) re-renders 2x/sec.
.children(self.status_dot_view.clone())
// Project name
.child(
div()