diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index 890b50e..693a52e 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -68,16 +68,30 @@ fn tab_button(label: &str, active: bool, accent: Rgba) -> Div { pub struct ProjectBox { pub project: Project, - /// Entity handle for the embedded AgentPane (Model tab) pub agent_pane: Option>, - /// Entity handle for the embedded terminal (Model tab) pub terminal_view: Option>, pub status_dot: Option>, + // Cached strings to avoid allocation on every render + id_project: SharedString, + id_model: SharedString, + id_resize: SharedString, + id_docs: SharedString, + id_files: SharedString, + cached_name: SharedString, + cached_cwd: SharedString, } impl ProjectBox { pub fn new(project: Project) -> Self { + let id = &project.id; Self { + id_project: SharedString::from(format!("project-{id}")), + id_model: SharedString::from(format!("model-{id}")), + id_resize: SharedString::from(format!("resize-{id}")), + id_docs: SharedString::from(format!("docs-{id}")), + id_files: SharedString::from(format!("files-{id}")), + cached_name: SharedString::from(project.name.clone()), + cached_cwd: SharedString::from(project.cwd.clone()), project, agent_pane: None, terminal_view: None, @@ -117,23 +131,16 @@ impl ProjectBox { } } -static PB_RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - impl Render for ProjectBox { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let pbc = PB_RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if pbc % 10 == 0 { eprintln!("[ProjectBox] render #{pbc}"); } let accent = accent_color(self.project.accent_index); - let name = self.project.name.clone(); - let cwd = self.project.cwd.clone(); - let status = self.project.agent.status; let active_tab = self.project.active_tab; // Build content area based on active tab let content = match active_tab { ProjectTab::Model => { let mut model_content = div() - .id(SharedString::from(format!("model-content-{}", self.project.id))) + .id(self.id_model.clone()) .flex_1() .w_full() .flex() @@ -152,7 +159,7 @@ impl Render for ProjectBox { // Resize handle model_content = model_content.child( div() - .id(SharedString::from(format!("resize-{}", self.project.id))) + .id(self.id_resize.clone()) .w_full() .h(px(4.0)) .bg(theme::SURFACE0) @@ -174,7 +181,7 @@ impl Render for ProjectBox { } ProjectTab::Docs => { div() - .id(SharedString::from(format!("docs-content-{}", self.project.id))) + .id(self.id_docs.clone()) .flex_1() .w_full() .flex() @@ -186,7 +193,7 @@ impl Render for ProjectBox { } ProjectTab::Files => { div() - .id(SharedString::from(format!("files-content-{}", self.project.id))) + .id(self.id_files.clone()) .flex_1() .w_full() .flex() @@ -223,7 +230,7 @@ impl Render for ProjectBox { }; div() - .id(SharedString::from(format!("project-{}", self.project.id))) + .id(self.id_project.clone()) .flex_1() .min_w(px(400.0)) .min_h(px(300.0)) @@ -262,7 +269,7 @@ impl Render for ProjectBox { div() .text_size(px(13.0)) .text_color(theme::TEXT) - .child(name), + .child(self.cached_name.clone()), ) .child(div().flex_1()) // CWD (ellipsized) @@ -273,7 +280,7 @@ impl Render for ProjectBox { .max_w(px(200.0)) .overflow_hidden() .whitespace_nowrap() - .child(cwd), + .child(self.cached_cwd.clone()), ), ) // ── Tab Bar ───────────────────────────────────────── diff --git a/ui-gpui/src/components/pulsing_dot.rs b/ui-gpui/src/components/pulsing_dot.rs index ae95a29..7f0bdf8 100644 --- a/ui-gpui/src/components/pulsing_dot.rs +++ b/ui-gpui/src/components/pulsing_dot.rs @@ -1,13 +1,9 @@ -//! PulsingDot — hybrid approach: custom Element for paint, timer for scheduling. +//! PulsingDot — self-scheduling animation that minimizes parent re-renders. //! -//! Problem: cx.notify() propagates to parent views → full tree re-render. -//! Solution: custom Element that paints directly + on_next_frame with 500ms delay. -//! The Element's paint() reads time and computes color — no parent notification needed. -//! on_next_frame fires once per blink, not at vsync rate. +//! Uses window.on_next_frame() with a 500ms delay to schedule exactly 1 repaint +//! per blink cycle. No continuous request_animation_frame loop. use gpui::*; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; use std::time::{Duration, Instant}; use crate::theme; @@ -21,20 +17,11 @@ pub enum DotStatus { Error, } -static RENDER_COUNT: AtomicU64 = AtomicU64::new(0); -static BLINK_COUNT: AtomicU64 = AtomicU64::new(0); - -/// Shared state between the timer thread and the Element -pub struct DotAnimState { - pub visible: AtomicBool, -} - -/// PulsingDot as a View that manages the animation timer pub struct PulsingDot { status: DotStatus, size: f32, - anim: Arc, - start_time: Instant, + visible: bool, + last_toggle: Instant, } impl PulsingDot { @@ -42,8 +29,8 @@ impl PulsingDot { Self { status, size, - anim: Arc::new(DotAnimState { visible: AtomicBool::new(true) }), - start_time: Instant::now(), + visible: true, + last_toggle: Instant::now(), } } @@ -61,28 +48,16 @@ impl PulsingDot { } } - fn current_color(&self) -> Rgba { - let base = self.base_color(); - if !self.should_pulse() { - return base; - } - let visible = self.anim.visible.load(Ordering::Relaxed); - if visible { base } else { theme::SURFACE1 } - } - - /// Start the blink timer. Uses cx.spawn for proper GPUI integration. - pub fn start_blinking(&mut self, cx: &mut Context) { - if !self.should_pulse() { - return; - } - let anim = self.anim.clone(); - cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + /// Start blink scheduling from context (after entity registered) + pub fn start_blinking(&self, cx: &mut Context) { + if !self.should_pulse() { return; } + // Schedule first blink via background timer + cx.spawn(async move |weak: WeakEntity, cx: &mut AsyncApp| { loop { cx.background_executor().timer(Duration::from_millis(500)).await; - BLINK_COUNT.fetch_add(1, Ordering::Relaxed); - anim.visible.fetch_xor(true, Ordering::Relaxed); - // Notify ONLY this entity — GPUI will repaint only this view - let ok = this.update(cx, |_dot, cx| { + let ok = weak.update(cx, |dot, cx| { + dot.visible = !dot.visible; + dot.last_toggle = Instant::now(); cx.notify(); }); if ok.is_err() { break; } @@ -93,13 +68,12 @@ impl PulsingDot { impl Render for PulsingDot { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let rc = RENDER_COUNT.fetch_add(1, Ordering::Relaxed); - if rc % 60 == 0 { - let bc = BLINK_COUNT.load(Ordering::Relaxed); - eprintln!("[PulsingDot] renders={rc} blinks={bc}"); - } + let color = if self.should_pulse() && !self.visible { + theme::SURFACE1 + } else { + self.base_color() + }; - let color = self.current_color(); let r = (color.r * 255.0) as u32; let g = (color.g * 255.0) as u32; let b = (color.b * 255.0) as u32; diff --git a/ui-gpui/src/workspace.rs b/ui-gpui/src/workspace.rs index 2ae55e8..ba431d1 100644 --- a/ui-gpui/src/workspace.rs +++ b/ui-gpui/src/workspace.rs @@ -60,15 +60,9 @@ impl Workspace { } } -static WORKSPACE_RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - impl Render for Workspace { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let wc = WORKSPACE_RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if wc % 10 == 0 { - eprintln!("[Workspace] render #{wc}"); - } - // Don't read app_state in render — it subscribes and causes re-renders on every tick + // Hardcoded layout state — avoids model subscription that causes re-renders let sidebar_open = true; let settings_open = false; let palette_open = false;