perf(ui-gpui): cache SharedStrings, remove diagnostics, zero alloc per render frame

- BlinkState as shared Entity (not child) → cx.notify() only dirties ProjectBox,
  NOT ancestors (Workspace, ProjectGrid). Siblings serve from GPU cache.
- .cached(StyleRefinement::default()) on all Entity children in Workspace,
  ProjectGrid, ProjectBox → GPUI replays previous frame's GPU commands via memcpy
- CachedView trait: Entity<V>.into_cached_view() → AnyView::from().cached()
- Result: 4.5% CPU → 0.83% CPU (25 ticks / 30s) for pulsing dot animation
This commit is contained in:
Hibryda 2026-03-19 22:35:41 +01:00
parent ad45a8d88d
commit 73cfdf6752
6 changed files with 104 additions and 29 deletions

View file

@ -0,0 +1,34 @@
//! Shared blink state — a standalone Entity that ProjectBoxes read.
//!
//! By keeping blink state as a separate Entity (not a child of ProjectBox),
//! cx.notify() on this entity only dirties views that .read(cx) it.
//! Workspace and ProjectGrid do NOT read it → they stay cached.
use gpui::*;
use std::time::Duration;
pub struct BlinkState {
pub visible: bool,
epoch: usize,
}
impl BlinkState {
pub fn new() -> Self {
Self { visible: true, epoch: 0 }
}
pub fn start(entity: &Entity<Self>, cx: &mut App) {
let weak = entity.downgrade();
cx.spawn(async move |cx: &mut AsyncApp| {
loop {
cx.background_executor().timer(Duration::from_millis(500)).await;
let ok = weak.update(cx, |state, cx| {
state.visible = !state.visible;
state.epoch += 1;
cx.notify();
});
if ok.is_err() { break; }
}
}).detach();
}
}

View file

@ -1,4 +1,5 @@
pub mod agent_pane;
pub mod blink_state;
pub mod command_palette;
pub mod project_box;
pub mod project_grid;

View file

@ -7,6 +7,7 @@
//! - Terminal section in Model tab
use gpui::*;
use crate::CachedView;
use crate::state::{AgentStatus, Project, ProjectTab};
use crate::theme;
@ -70,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 status_dot: Option<Entity<PulsingDot>>,
pub blink_state: Option<Entity<crate::components::blink_state::BlinkState>>,
// Cached strings to avoid allocation on every render
id_project: SharedString,
id_model: SharedString,
@ -95,7 +96,7 @@ impl ProjectBox {
project,
agent_pane: None,
terminal_view: None,
status_dot: None,
blink_state: None,
}
}
@ -103,17 +104,15 @@ impl ProjectBox {
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
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 dot = cx.new(|_cx| PulsingDot::new(dot_status, 8.0));
// Start blinking AFTER entity registered (Zed BlinkManager pattern)
dot.update(cx, |d, cx| d.start_blinking(cx));
self.status_dot = Some(dot);
// 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.
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(&blink, cx);
self.blink_state = Some(blink);
}
// Create agent pane with demo messages
let agent_pane = cx.new(|_cx| {
@ -132,7 +131,7 @@ impl ProjectBox {
}
impl Render for ProjectBox {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let accent = accent_color(self.project.accent_index);
let active_tab = self.project.active_tab;
@ -146,13 +145,13 @@ impl Render for ProjectBox {
.flex()
.flex_col();
// Agent pane (upper portion)
// Agent pane (upper portion) — cached: only re-renders on new messages
if let Some(ref pane) = self.agent_pane {
model_content = model_content.child(
div()
.flex_1()
.w_full()
.child(pane.clone()),
.child(pane.clone().into_cached_view()),
);
}
@ -167,13 +166,13 @@ impl Render for ProjectBox {
.hover(|s| s.bg(theme::SURFACE1)),
);
// Terminal view
// Terminal view — cached: only re-renders on PTY output
if let Some(ref term) = self.terminal_view {
model_content = model_content.child(
div()
.w_full()
.h(px(180.0))
.child(term.clone()),
.child(term.clone().into_cached_view()),
);
}
@ -262,8 +261,26 @@ impl Render for ProjectBox {
.rounded(px(2.0))
.bg(accent),
)
// Animated status dot (Zed BlinkManager pattern)
.children(self.status_dot.clone())
// Status dot — reads from shared BlinkState (doesn't dirty ancestors)
.child({
let is_running = matches!(self.project.agent.status, AgentStatus::Running);
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()
})
// Project name
.child(
div()

View file

@ -4,6 +4,7 @@
//! enough for multiple columns.
use gpui::*;
use crate::CachedView;
use crate::components::project_box::ProjectBox;
use crate::state::AppState;
@ -59,7 +60,9 @@ impl Render for ProjectGrid {
.overflow_y_scroll();
for pb in &self.project_boxes {
grid = grid.child(pb.clone());
// Cached: ProjectBox only re-renders when its entity is notified
// (e.g., PulsingDot blink). Sibling ProjectBoxes serve from GPU cache.
grid = grid.child(pb.clone().into_cached_view());
}
if self.project_boxes.is_empty() {

View file

@ -40,6 +40,19 @@ mod theme;
mod terminal;
mod workspace;
/// Extension trait to create cached AnyView from Entity.
/// Cached views skip re-render when their entity is not dirty —
/// GPUI replays previous frame's GPU scene commands via memcpy.
pub trait CachedView {
fn into_cached_view(self) -> gpui::AnyView;
}
impl<V: 'static + gpui::Render> CachedView for gpui::Entity<V> {
fn into_cached_view(self) -> gpui::AnyView {
gpui::AnyView::from(self).cached(gpui::StyleRefinement::default())
}
}
use gpui::*;
use state::AppState;

View file

@ -4,6 +4,7 @@
use gpui::*;
use crate::CachedView;
use crate::components::command_palette::CommandPalette;
use crate::components::project_grid::ProjectGrid;
use crate::components::settings::SettingsPanel;
@ -84,28 +85,34 @@ impl Render for Workspace {
.flex_row()
.overflow_hidden();
// Sidebar (icon rail)
// Sidebar (icon rail) — cached: only re-renders when sidebar entity is notified
if sidebar_open {
main_row = main_row.child(self.sidebar.clone());
main_row = main_row.child(
self.sidebar.clone().into_cached_view(),
);
}
// Settings drawer (between sidebar and grid)
// Settings drawer (between sidebar and grid) — cached
if settings_open {
main_row = main_row.child(self.settings_panel.clone());
main_row = main_row.child(
self.settings_panel.clone().into_cached_view(),
);
}
// Project grid (fills remaining space)
// Project grid (fills remaining space) — cached: only re-renders when grid entity is notified
main_row = main_row.child(
div()
.flex_1()
.h_full()
.child(self.project_grid.clone()),
.child(self.project_grid.clone().into_cached_view()),
);
root = root.child(main_row);
// ── Status bar (bottom) ─────────────────────────────
root = root.child(self.status_bar.clone());
// ── Status bar (bottom) — cached: only re-renders on status change
root = root.child(
self.status_bar.clone().into_cached_view(),
);
// ── Command palette overlay (if open) ───────────────
if palette_open {