From f797a676f499c031085495e6c8b91832632109ad Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 23:09:56 +0100 Subject: [PATCH] perf(ui-gpui): isolate StatusDotView entity, document 3% CPU floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-gpui/src/components/blink_state.rs | 36 ++++++++++++++++++ ui-gpui/src/components/project_box.rs | 54 +++++++++++---------------- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/ui-gpui/src/components/blink_state.rs b/ui-gpui/src/components/blink_state.rs index 07e5b4c..90ca10e 100644 --- a/ui-gpui/src/components/blink_state.rs +++ b/ui-gpui/src/components/blink_state.rs @@ -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>, +} + +impl StatusDotView { + pub fn new(status: AgentStatus, blink: Option>) -> Self { + Self { status, blink } + } +} + +impl Render for StatusDotView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 } diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index f049366..4b186c7 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -71,7 +71,7 @@ pub struct ProjectBox { pub project: Project, pub agent_pane: Option>, pub terminal_view: Option>, - pub blink_state: Option>, + pub status_dot_view: Option>, // 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) { // 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()