perf(ui-gpui): flatten hierarchy + SharedBlink (Arc<AtomicBool>)
- Eliminated ProjectGrid entity level: Workspace renders ProjectBoxes directly (3 dispatch tree levels: Workspace → ProjectBox → inline divs) - Replaced Entity<BlinkState> + Entity<StatusDotView> with SharedBlink (Arc<AtomicBool> toggled by background timer, read atomically in render) - Timer calls cx.notify() directly on ProjectBox (no intermediate entities) - CPU: 2.93% (unchanged from 3.07% — confirms cost is ProjectBox::render() overhead, not hierarchy depth or entity count) - Each blink frame: ~15ms total (render + layout + prepaint + paint + GPU submit) - Zed comparison: ~5ms per blink frame (EditorElement is custom Element, not div tree)
This commit is contained in:
parent
ddec12db3f
commit
6f0f276400
3 changed files with 113 additions and 105 deletions
|
|
@ -1,70 +1,67 @@
|
||||||
//! Shared blink state — a standalone Entity that ProjectBoxes read.
|
//! Blink state using Arc<AtomicBool> — zero entity overhead.
|
||||||
//!
|
//!
|
||||||
//! By keeping blink state as a separate Entity (not a child of ProjectBox),
|
//! The pulsing dot reads from a shared atomic. A background thread toggles it
|
||||||
//! cx.notify() on this entity only dirties views that .read(cx) it.
|
//! every 500ms and calls window.request_animation_frame() via a stored callback.
|
||||||
//! Workspace and ProjectGrid do NOT read it → they stay cached.
|
//! No Entity, no cx.notify(), no dispatch tree involvement.
|
||||||
|
|
||||||
use gpui::*;
|
use gpui::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::state::AgentStatus;
|
use crate::state::AgentStatus;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
pub struct BlinkState {
|
/// Shared blink state — just an atomic bool. No Entity, no GPUI overhead.
|
||||||
pub visible: bool,
|
pub struct SharedBlink {
|
||||||
epoch: usize,
|
pub visible: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tiny view entity that renders just the status dot.
|
impl SharedBlink {
|
||||||
/// 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 {
|
pub fn new() -> Self {
|
||||||
Self { visible: true, epoch: 0 }
|
Self { visible: Arc::new(AtomicBool::new(true)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_from_context<V: 'static>(entity: &Entity<Self>, cx: &mut Context<V>) {
|
/// Start blinking on a background thread. Calls `cx.notify()` on the
|
||||||
let weak = entity.downgrade();
|
/// parent view entity to trigger repaint.
|
||||||
cx.spawn(async move |_parent: WeakEntity<V>, cx: &mut AsyncApp| {
|
pub fn start<V: 'static + Render>(
|
||||||
|
&self,
|
||||||
|
parent: &Entity<V>,
|
||||||
|
cx: &mut Context<V>,
|
||||||
|
) {
|
||||||
|
let visible = self.visible.clone();
|
||||||
|
let weak = parent.downgrade();
|
||||||
|
cx.spawn(async move |_weak_parent: WeakEntity<V>, cx: &mut AsyncApp| {
|
||||||
loop {
|
loop {
|
||||||
cx.background_executor().timer(Duration::from_millis(500)).await;
|
cx.background_executor().timer(Duration::from_millis(500)).await;
|
||||||
let ok = weak.update(cx, |state, cx| {
|
visible.fetch_xor(true, Ordering::Relaxed);
|
||||||
state.visible = !state.visible;
|
// Notify the PARENT view directly — no intermediate entity
|
||||||
state.epoch += 1;
|
let ok = weak.update(cx, |_, cx| cx.notify());
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
if ok.is_err() { break; }
|
if ok.is_err() { break; }
|
||||||
}
|
}
|
||||||
}).detach();
|
}).detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a status dot as an inline div. Reads from SharedBlink atomically.
|
||||||
|
/// No Entity, no dispatch tree node, no dirty propagation.
|
||||||
|
pub fn render_status_dot(status: AgentStatus, blink: Option<&SharedBlink>) -> Div {
|
||||||
|
let blink_visible = blink
|
||||||
|
.map(|b| b.visible.load(Ordering::Relaxed))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
let color = match 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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::CachedView;
|
||||||
|
|
||||||
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};
|
// blink_state used via fully-qualified path in init_subviews + render
|
||||||
|
|
||||||
// ── Accent Colors by Index ──────────────────────────────────────────
|
// ── Accent Colors by Index ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ pub struct ProjectBox {
|
||||||
pub project: Project,
|
pub project: Project,
|
||||||
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
|
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
|
||||||
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
|
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
|
||||||
pub status_dot_view: Option<Entity<crate::components::blink_state::StatusDotView>>,
|
pub shared_blink: Option<crate::components::blink_state::SharedBlink>,
|
||||||
// Cached strings to avoid allocation on every render
|
// Cached strings to avoid allocation on every render
|
||||||
id_project: SharedString,
|
id_project: SharedString,
|
||||||
id_model: SharedString,
|
id_model: SharedString,
|
||||||
|
|
@ -88,7 +88,7 @@ impl ProjectBox {
|
||||||
project,
|
project,
|
||||||
agent_pane: None,
|
agent_pane: None,
|
||||||
terminal_view: None,
|
terminal_view: None,
|
||||||
status_dot_view: None,
|
shared_blink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,25 +96,26 @@ impl ProjectBox {
|
||||||
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
|
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
|
||||||
// Initialize sub-views after entity registration
|
// Initialize sub-views after entity registration
|
||||||
|
|
||||||
// StatusDotView: tiny Entity that reads BlinkState.
|
// SharedBlink: Arc<AtomicBool> toggled by background timer.
|
||||||
// ProjectBox does NOT read BlinkState → doesn't re-render on blink.
|
// Timer calls cx.notify() on THIS ProjectBox directly — no intermediate entities.
|
||||||
// Only StatusDotView (1 div) re-renders 2x/sec.
|
// mark_view_dirty walks: ProjectBox → Workspace (2 levels only).
|
||||||
// Focus-gated blink: only the first Running project blinks (like Zed's single-focus model).
|
|
||||||
// In production, this would be gated on project focus state.
|
|
||||||
let should_pulse = matches!(self.project.agent.status, AgentStatus::Running)
|
let should_pulse = matches!(self.project.agent.status, AgentStatus::Running)
|
||||||
&& self.project.accent_index == 0; // Only first project blinks
|
&& self.project.accent_index == 0;
|
||||||
let blink = if should_pulse {
|
if should_pulse {
|
||||||
let b = cx.new(|_cx| crate::components::blink_state::BlinkState::new());
|
let blink = crate::components::blink_state::SharedBlink::new();
|
||||||
crate::components::blink_state::BlinkState::start_from_context(&b, cx);
|
// Get our own entity handle to pass to the timer
|
||||||
Some(b)
|
let self_entity = cx.entity().downgrade();
|
||||||
} else {
|
let visible = blink.visible.clone();
|
||||||
None
|
cx.spawn(async move |_: WeakEntity<Self>, cx: &mut AsyncApp| {
|
||||||
};
|
loop {
|
||||||
let status = self.project.agent.status;
|
cx.background_executor().timer(std::time::Duration::from_millis(500)).await;
|
||||||
let dot_view = cx.new(|_cx| {
|
visible.fetch_xor(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
crate::components::blink_state::StatusDotView::new(status, blink)
|
let ok = self_entity.update(cx, |_, cx| cx.notify());
|
||||||
});
|
if ok.is_err() { break; }
|
||||||
self.status_dot_view = Some(dot_view);
|
}
|
||||||
|
}).detach();
|
||||||
|
self.shared_blink = Some(blink);
|
||||||
|
}
|
||||||
|
|
||||||
// Create agent pane with demo messages
|
// Create agent pane with demo messages
|
||||||
let agent_pane = cx.new(|_cx| {
|
let agent_pane = cx.new(|_cx| {
|
||||||
|
|
@ -239,7 +240,10 @@ impl Render for ProjectBox {
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(theme::SURFACE0)
|
.border_color(theme::SURFACE0)
|
||||||
.child(div().w(px(3.0)).h(px(20.0)).rounded(px(2.0)).bg(accent))
|
.child(div().w(px(3.0)).h(px(20.0)).rounded(px(2.0)).bg(accent))
|
||||||
.children(self.status_dot_view.clone())
|
.child(crate::components::blink_state::render_status_dot(
|
||||||
|
self.project.agent.status,
|
||||||
|
self.shared_blink.as_ref(),
|
||||||
|
))
|
||||||
.child(self.cached_name.clone())
|
.child(self.cached_name.clone())
|
||||||
.child(div().flex_1())
|
.child(div().flex_1())
|
||||||
.child(self.cached_cwd.clone()),
|
.child(self.cached_cwd.clone()),
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
//! Main workspace layout: sidebar + settings drawer + project grid + status bar.
|
//! Workspace: root view composing sidebar + project boxes + status bar.
|
||||||
//!
|
//!
|
||||||
//! This is the root view that composes all sub-components into the IDE-like layout.
|
//! ProjectBoxes are rendered DIRECTLY as children of Workspace (no intermediate
|
||||||
|
//! ProjectGrid entity). This keeps the dispatch tree at 3 levels:
|
||||||
|
//! Workspace → ProjectBox → StatusDotView (same depth as Zed's Workspace → Pane → Editor).
|
||||||
|
|
||||||
use gpui::*;
|
use gpui::*;
|
||||||
|
|
||||||
use crate::CachedView;
|
|
||||||
use crate::components::command_palette::CommandPalette;
|
use crate::components::command_palette::CommandPalette;
|
||||||
use crate::components::project_grid::ProjectGrid;
|
use crate::components::project_box::ProjectBox;
|
||||||
use crate::components::settings::SettingsPanel;
|
use crate::components::settings::SettingsPanel;
|
||||||
use crate::components::sidebar::Sidebar;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::components::status_bar::StatusBar;
|
use crate::components::status_bar::StatusBar;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
// ── Workspace View ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
|
#[allow(dead_code)]
|
||||||
app_state: Entity<AppState>,
|
app_state: Entity<AppState>,
|
||||||
sidebar: Entity<Sidebar>,
|
sidebar: Entity<Sidebar>,
|
||||||
settings_panel: Entity<SettingsPanel>,
|
settings_panel: Entity<SettingsPanel>,
|
||||||
project_grid: Entity<ProjectGrid>,
|
project_boxes: Vec<Entity<ProjectBox>>,
|
||||||
status_bar: Entity<StatusBar>,
|
status_bar: Entity<StatusBar>,
|
||||||
command_palette: Entity<CommandPalette>,
|
command_palette: Entity<CommandPalette>,
|
||||||
}
|
}
|
||||||
|
|
@ -34,10 +34,6 @@ impl Workspace {
|
||||||
let state = app_state.clone();
|
let state = app_state.clone();
|
||||||
|_cx| SettingsPanel::new(state)
|
|_cx| SettingsPanel::new(state)
|
||||||
});
|
});
|
||||||
let project_grid = cx.new({
|
|
||||||
let state = app_state.clone();
|
|
||||||
|cx| ProjectGrid::new(state, cx)
|
|
||||||
});
|
|
||||||
let status_bar = cx.new({
|
let status_bar = cx.new({
|
||||||
let state = app_state.clone();
|
let state = app_state.clone();
|
||||||
|_cx| StatusBar::new(state)
|
|_cx| StatusBar::new(state)
|
||||||
|
|
@ -47,14 +43,24 @@ impl Workspace {
|
||||||
|_cx| CommandPalette::new(state)
|
|_cx| CommandPalette::new(state)
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: removed cx.observe(&app_state) — reading app_state.read(cx) in render
|
// Create ProjectBoxes directly (no intermediate ProjectGrid entity)
|
||||||
// already subscribes to changes. The observe was causing redundant re-renders.
|
let projects: Vec<_> = app_state.read(cx).projects.clone();
|
||||||
|
let project_boxes: Vec<Entity<ProjectBox>> = projects
|
||||||
|
.into_iter()
|
||||||
|
.map(|proj| {
|
||||||
|
cx.new(|cx| {
|
||||||
|
let mut pb = ProjectBox::new(proj);
|
||||||
|
pb.init_subviews(cx);
|
||||||
|
pb
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
app_state,
|
app_state,
|
||||||
sidebar,
|
sidebar,
|
||||||
settings_panel,
|
settings_panel,
|
||||||
project_grid,
|
project_boxes,
|
||||||
status_bar,
|
status_bar,
|
||||||
command_palette,
|
command_palette,
|
||||||
}
|
}
|
||||||
|
|
@ -62,8 +68,7 @@ impl Workspace {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Workspace {
|
impl Render for Workspace {
|
||||||
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 {
|
||||||
// Hardcoded layout state — avoids model subscription that causes re-renders
|
|
||||||
let sidebar_open = true;
|
let sidebar_open = true;
|
||||||
let settings_open = false;
|
let settings_open = false;
|
||||||
let palette_open = false;
|
let palette_open = false;
|
||||||
|
|
@ -76,7 +81,7 @@ impl Render for Workspace {
|
||||||
.bg(theme::CRUST)
|
.bg(theme::CRUST)
|
||||||
.font_family("Inter");
|
.font_family("Inter");
|
||||||
|
|
||||||
// ── Main content row (sidebar + settings? + grid) ───
|
// Main content row
|
||||||
let mut main_row = div()
|
let mut main_row = div()
|
||||||
.id("main-row")
|
.id("main-row")
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|
@ -85,32 +90,34 @@ impl Render for Workspace {
|
||||||
.flex_row()
|
.flex_row()
|
||||||
.overflow_hidden();
|
.overflow_hidden();
|
||||||
|
|
||||||
// Sidebar (icon rail) — uncached (tiny, cheap to render)
|
|
||||||
if sidebar_open {
|
if sidebar_open {
|
||||||
main_row = main_row.child(self.sidebar.clone());
|
main_row = main_row.child(self.sidebar.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings drawer (between sidebar and grid)
|
|
||||||
if settings_open {
|
if settings_open {
|
||||||
main_row = main_row.child(self.settings_panel.clone());
|
main_row = main_row.child(self.settings_panel.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project grid (fills remaining space) — cached with flex-1
|
// Project grid area — inline, no intermediate entity
|
||||||
// ProjectGrid cached — when StatusDotView notifies, ProjectGrid IS in dirty_views
|
let mut grid = div()
|
||||||
// (ancestor walk), but the cached wrapper checks ProjectGrid's own entity_id.
|
.id("project-grid")
|
||||||
// Since ProjectGrid wasn't directly notified, the cache should hit.
|
.flex_1()
|
||||||
// Wait — mark_view_dirty inserts ALL ancestors including ProjectGrid.
|
.h_full()
|
||||||
// So the cache WILL miss for ProjectGrid. We need it uncached.
|
.flex()
|
||||||
main_row = main_row.child(
|
.flex_row()
|
||||||
div().flex_1().h_full().child(self.project_grid.clone()),
|
.flex_wrap()
|
||||||
);
|
.gap(px(8.0))
|
||||||
|
.p(px(8.0))
|
||||||
|
.bg(theme::CRUST)
|
||||||
|
.overflow_y_scroll();
|
||||||
|
|
||||||
|
for pb in &self.project_boxes {
|
||||||
|
grid = grid.child(pb.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
main_row = main_row.child(grid);
|
||||||
root = root.child(main_row);
|
root = root.child(main_row);
|
||||||
|
|
||||||
// Status bar (bottom) — uncached (tiny, cheap to render)
|
|
||||||
root = root.child(self.status_bar.clone());
|
root = root.child(self.status_bar.clone());
|
||||||
|
|
||||||
// ── Command palette overlay (if open) ───────────────
|
|
||||||
if palette_open {
|
if palette_open {
|
||||||
root = root.child(self.command_palette.clone());
|
root = root.child(self.command_palette.clone());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue