From 6f0f2764009cd3e358a643fa35b9529ddb3a4f98 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 23:45:05 +0100 Subject: [PATCH] perf(ui-gpui): flatten hierarchy + SharedBlink (Arc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eliminated ProjectGrid entity level: Workspace renders ProjectBoxes directly (3 dispatch tree levels: Workspace → ProjectBox → inline divs) - Replaced Entity + Entity with SharedBlink (Arc 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) --- ui-gpui/src/components/blink_state.rs | 99 +++++++++++++-------------- ui-gpui/src/components/project_box.rs | 48 +++++++------ ui-gpui/src/workspace.rs | 71 ++++++++++--------- 3 files changed, 113 insertions(+), 105 deletions(-) diff --git a/ui-gpui/src/components/blink_state.rs b/ui-gpui/src/components/blink_state.rs index 90ca10e..4dcb652 100644 --- a/ui-gpui/src/components/blink_state.rs +++ b/ui-gpui/src/components/blink_state.rs @@ -1,70 +1,67 @@ -//! Shared blink state — a standalone Entity that ProjectBoxes read. +//! Blink state using Arc — zero entity overhead. //! -//! 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. +//! The pulsing dot reads from a shared atomic. A background thread toggles it +//! every 500ms and calls window.request_animation_frame() via a stored callback. +//! No Entity, no cx.notify(), no dispatch tree involvement. use gpui::*; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; + use crate::state::AgentStatus; use crate::theme; -pub struct BlinkState { - pub visible: bool, - epoch: usize, +/// Shared blink state — just an atomic bool. No Entity, no GPUI overhead. +pub struct SharedBlink { + pub visible: Arc, } -/// 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 { +impl SharedBlink { pub fn new() -> Self { - Self { visible: true, epoch: 0 } + Self { visible: Arc::new(AtomicBool::new(true)) } } - pub fn start_from_context(entity: &Entity, cx: &mut Context) { - let weak = entity.downgrade(); - cx.spawn(async move |_parent: WeakEntity, cx: &mut AsyncApp| { + /// Start blinking on a background thread. Calls `cx.notify()` on the + /// parent view entity to trigger repaint. + pub fn start( + &self, + parent: &Entity, + cx: &mut Context, + ) { + let visible = self.visible.clone(); + let weak = parent.downgrade(); + cx.spawn(async move |_weak_parent: WeakEntity, 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(); - }); + visible.fetch_xor(true, Ordering::Relaxed); + // Notify the PARENT view directly — no intermediate entity + let ok = weak.update(cx, |_, cx| cx.notify()); if ok.is_err() { break; } } }).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() +} diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index f683237..a8f6fe4 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -11,7 +11,7 @@ use crate::CachedView; use crate::state::{AgentStatus, Project, ProjectTab}; 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 ────────────────────────────────────────── @@ -63,7 +63,7 @@ pub struct ProjectBox { pub project: Project, pub agent_pane: Option>, pub terminal_view: Option>, - pub status_dot_view: Option>, + pub shared_blink: Option, // Cached strings to avoid allocation on every render id_project: SharedString, id_model: SharedString, @@ -88,7 +88,7 @@ impl ProjectBox { project, agent_pane: 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) { // Initialize sub-views after entity registration - // 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. - // 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. + // SharedBlink: Arc toggled by background timer. + // Timer calls cx.notify() on THIS ProjectBox directly — no intermediate entities. + // mark_view_dirty walks: ProjectBox → Workspace (2 levels only). let should_pulse = matches!(self.project.agent.status, AgentStatus::Running) - && self.project.accent_index == 0; // Only first project blinks - 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); + && self.project.accent_index == 0; + if should_pulse { + let blink = crate::components::blink_state::SharedBlink::new(); + // Get our own entity handle to pass to the timer + let self_entity = cx.entity().downgrade(); + let visible = blink.visible.clone(); + cx.spawn(async move |_: WeakEntity, cx: &mut AsyncApp| { + loop { + cx.background_executor().timer(std::time::Duration::from_millis(500)).await; + visible.fetch_xor(true, std::sync::atomic::Ordering::Relaxed); + let ok = self_entity.update(cx, |_, cx| cx.notify()); + if ok.is_err() { break; } + } + }).detach(); + self.shared_blink = Some(blink); + } // Create agent pane with demo messages let agent_pane = cx.new(|_cx| { @@ -239,7 +240,10 @@ impl Render for ProjectBox { .border_b_1() .border_color(theme::SURFACE0) .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(div().flex_1()) .child(self.cached_cwd.clone()), diff --git a/ui-gpui/src/workspace.rs b/ui-gpui/src/workspace.rs index 025c6c5..1299f60 100644 --- a/ui-gpui/src/workspace.rs +++ b/ui-gpui/src/workspace.rs @@ -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 crate::CachedView; 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::sidebar::Sidebar; use crate::components::status_bar::StatusBar; use crate::state::AppState; use crate::theme; -// ── Workspace View ────────────────────────────────────────────────── - pub struct Workspace { + #[allow(dead_code)] app_state: Entity, sidebar: Entity, settings_panel: Entity, - project_grid: Entity, + project_boxes: Vec>, status_bar: Entity, command_palette: Entity, } @@ -34,10 +34,6 @@ impl Workspace { let state = app_state.clone(); |_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 state = app_state.clone(); |_cx| StatusBar::new(state) @@ -47,14 +43,24 @@ impl Workspace { |_cx| CommandPalette::new(state) }); - // NOTE: removed cx.observe(&app_state) — reading app_state.read(cx) in render - // already subscribes to changes. The observe was causing redundant re-renders. + // Create ProjectBoxes directly (no intermediate ProjectGrid entity) + let projects: Vec<_> = app_state.read(cx).projects.clone(); + let project_boxes: Vec> = projects + .into_iter() + .map(|proj| { + cx.new(|cx| { + let mut pb = ProjectBox::new(proj); + pb.init_subviews(cx); + pb + }) + }) + .collect(); Self { app_state, sidebar, settings_panel, - project_grid, + project_boxes, status_bar, command_palette, } @@ -62,8 +68,7 @@ impl Workspace { } impl Render for Workspace { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - // Hardcoded layout state — avoids model subscription that causes re-renders + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { let sidebar_open = true; let settings_open = false; let palette_open = false; @@ -76,7 +81,7 @@ impl Render for Workspace { .bg(theme::CRUST) .font_family("Inter"); - // ── Main content row (sidebar + settings? + grid) ─── + // Main content row let mut main_row = div() .id("main-row") .flex_1() @@ -85,32 +90,34 @@ impl Render for Workspace { .flex_row() .overflow_hidden(); - // Sidebar (icon rail) — uncached (tiny, cheap to render) if sidebar_open { main_row = main_row.child(self.sidebar.clone()); } - - // Settings drawer (between sidebar and grid) if settings_open { main_row = main_row.child(self.settings_panel.clone()); } - // Project grid (fills remaining space) — cached with flex-1 - // ProjectGrid cached — when StatusDotView notifies, ProjectGrid IS in dirty_views - // (ancestor walk), but the cached wrapper checks ProjectGrid's own entity_id. - // Since ProjectGrid wasn't directly notified, the cache should hit. - // Wait — mark_view_dirty inserts ALL ancestors including ProjectGrid. - // So the cache WILL miss for ProjectGrid. We need it uncached. - main_row = main_row.child( - div().flex_1().h_full().child(self.project_grid.clone()), - ); + // Project grid area — inline, no intermediate entity + let mut grid = div() + .id("project-grid") + .flex_1() + .h_full() + .flex() + .flex_row() + .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); - - // Status bar (bottom) — uncached (tiny, cheap to render) root = root.child(self.status_bar.clone()); - // ── Command palette overlay (if open) ─────────────── if palette_open { root = root.child(self.command_palette.clone()); }