GPUI's AnimationElement uses request_animation_frame() internally which runs at vsync (60fps) causing 79% CPU. Fragment shaders not exposed through GPUI's Scene API (paint_quad only accepts static color, no time uniform). Reverted to BlinkState timer pattern (3% CPU, 2 renders/sec). This is the same approach Zed uses for cursor blink. Tested approaches and results: - AnimationElement + pulsating_between: 79% CPU (vsync loop) - BlinkState + timer(500ms) + cx.notify(): 3% CPU (correct) - Custom Element + paint_quad: no shader access - CSS animation (Blitz): 30% CPU (full repaint loop)
326 lines
12 KiB
Rust
326 lines
12 KiB
Rust
//! ProjectBox: individual project card with header, tab bar, content area.
|
|
//!
|
|
//! Each project gets a card in the grid with:
|
|
//! - Header: name, status dot, CWD
|
|
//! - Tab bar: Model / Docs / Files
|
|
//! - Content area: AgentPane (Model tab), placeholder (Docs/Files)
|
|
//! - Terminal section in Model tab
|
|
|
|
use gpui::*;
|
|
use crate::CachedView;
|
|
|
|
use crate::state::{AgentStatus, Project, ProjectTab};
|
|
use crate::theme;
|
|
use crate::components::pulsing_dot::{PulsingDot, DotStatus};
|
|
|
|
// ── Accent Colors by Index ──────────────────────────────────────────
|
|
|
|
fn accent_color(index: usize) -> Rgba {
|
|
const ACCENTS: [Rgba; 8] = [
|
|
theme::BLUE,
|
|
theme::MAUVE,
|
|
theme::GREEN,
|
|
theme::PEACH,
|
|
theme::PINK,
|
|
theme::TEAL,
|
|
theme::SAPPHIRE,
|
|
theme::LAVENDER,
|
|
];
|
|
ACCENTS[index % ACCENTS.len()]
|
|
}
|
|
|
|
// ── Status Indicator ────────────────────────────────────────────────
|
|
|
|
fn project_status_dot(status: AgentStatus) -> Div {
|
|
let color = match status {
|
|
AgentStatus::Idle => theme::OVERLAY0,
|
|
AgentStatus::Running => theme::GREEN,
|
|
AgentStatus::Done => theme::BLUE,
|
|
AgentStatus::Error => theme::RED,
|
|
};
|
|
div()
|
|
.w(px(8.0))
|
|
.h(px(8.0))
|
|
.rounded(px(4.0))
|
|
.bg(color)
|
|
}
|
|
|
|
// ── Tab Button ──────────────────────────────────────────────────────
|
|
|
|
fn tab_button(label: &str, active: bool, accent: Rgba) -> Div {
|
|
let fg = if active { accent } else { theme::SUBTEXT0 };
|
|
|
|
let mut tab = div()
|
|
.px(px(10.0))
|
|
.py(px(4.0))
|
|
.text_size(px(11.0))
|
|
.text_color(fg)
|
|
.cursor_pointer()
|
|
.hover(|s| s.text_color(theme::TEXT));
|
|
|
|
if active {
|
|
tab = tab.border_b_2().border_color(accent);
|
|
}
|
|
|
|
tab.child(label.to_string())
|
|
}
|
|
|
|
// ── ProjectBox View ─────────────────────────────────────────────────
|
|
|
|
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 blink_state: Option<Entity<crate::components::blink_state::BlinkState>>,
|
|
// 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,
|
|
blink_state: None,
|
|
}
|
|
}
|
|
|
|
/// Initialize sub-views. Must be called after the ProjectBox entity is created.
|
|
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
|
|
// 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.
|
|
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);
|
|
}
|
|
|
|
// Create agent pane with demo messages
|
|
let agent_pane = cx.new(|_cx| {
|
|
crate::components::agent_pane::AgentPane::with_demo_messages()
|
|
});
|
|
self.agent_pane = Some(agent_pane);
|
|
|
|
// Create terminal view with demo content
|
|
let terminal_view = cx.new(|_cx| {
|
|
let mut tv = crate::terminal::renderer::TerminalView::new(120, 10);
|
|
tv.feed_demo();
|
|
tv
|
|
});
|
|
self.terminal_view = Some(terminal_view);
|
|
}
|
|
}
|
|
|
|
impl Render for ProjectBox {
|
|
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;
|
|
|
|
// Build content area based on active tab
|
|
let content = match active_tab {
|
|
ProjectTab::Model => {
|
|
let mut model_content = div()
|
|
.id(self.id_model.clone())
|
|
.flex_1()
|
|
.w_full()
|
|
.flex()
|
|
.flex_col();
|
|
|
|
// 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().into_cached_flex()),
|
|
);
|
|
}
|
|
|
|
// Resize handle
|
|
model_content = model_content.child(
|
|
div()
|
|
.id(self.id_resize.clone())
|
|
.w_full()
|
|
.h(px(4.0))
|
|
.bg(theme::SURFACE0)
|
|
.cursor_pointer()
|
|
.hover(|s| s.bg(theme::SURFACE1)),
|
|
);
|
|
|
|
// 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().into_cached_flex()),
|
|
);
|
|
}
|
|
|
|
model_content
|
|
}
|
|
ProjectTab::Docs => {
|
|
div()
|
|
.id(self.id_docs.clone())
|
|
.flex_1()
|
|
.w_full()
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.text_size(px(14.0))
|
|
.text_color(theme::OVERLAY0)
|
|
.child("Documentation viewer — renders project markdown files")
|
|
}
|
|
ProjectTab::Files => {
|
|
div()
|
|
.id(self.id_files.clone())
|
|
.flex_1()
|
|
.w_full()
|
|
.flex()
|
|
.flex_col()
|
|
.p(px(12.0))
|
|
.gap(px(4.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(12.0))
|
|
.text_color(theme::SUBTEXT0)
|
|
.child("src/"),
|
|
)
|
|
.child(
|
|
div()
|
|
.pl(px(16.0))
|
|
.text_size(px(12.0))
|
|
.text_color(theme::TEXT)
|
|
.child("main.rs"),
|
|
)
|
|
.child(
|
|
div()
|
|
.pl(px(16.0))
|
|
.text_size(px(12.0))
|
|
.text_color(theme::TEXT)
|
|
.child("lib.rs"),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(12.0))
|
|
.text_color(theme::SUBTEXT0)
|
|
.child("Cargo.toml"),
|
|
)
|
|
}
|
|
};
|
|
|
|
div()
|
|
.id(self.id_project.clone())
|
|
.flex_1()
|
|
.min_w(px(400.0))
|
|
.min_h(px(300.0))
|
|
.flex()
|
|
.flex_col()
|
|
.bg(theme::BASE)
|
|
.rounded(px(8.0))
|
|
.border_1()
|
|
.border_color(theme::SURFACE0)
|
|
.overflow_hidden()
|
|
// ── Header ──────────────────────────────────────────
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.h(px(36.0))
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.px(px(12.0))
|
|
.gap(px(8.0))
|
|
.bg(theme::MANTLE)
|
|
.border_b_1()
|
|
.border_color(theme::SURFACE0)
|
|
// Accent stripe on left
|
|
.child(
|
|
div()
|
|
.w(px(3.0))
|
|
.h(px(20.0))
|
|
.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()
|
|
})
|
|
// Project name
|
|
.child(
|
|
div()
|
|
.text_size(px(13.0))
|
|
.text_color(theme::TEXT)
|
|
.child(self.cached_name.clone()),
|
|
)
|
|
.child(div().flex_1())
|
|
// CWD (ellipsized)
|
|
.child(
|
|
div()
|
|
.text_size(px(10.0))
|
|
.text_color(theme::OVERLAY0)
|
|
.max_w(px(200.0))
|
|
.overflow_hidden()
|
|
.whitespace_nowrap()
|
|
.child(self.cached_cwd.clone()),
|
|
),
|
|
)
|
|
// ── Tab Bar ─────────────────────────────────────────
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.h(px(32.0))
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.px(px(8.0))
|
|
.gap(px(2.0))
|
|
.bg(theme::MANTLE)
|
|
.border_b_1()
|
|
.border_color(theme::SURFACE0)
|
|
.child(tab_button("Model", active_tab == ProjectTab::Model, accent))
|
|
.child(tab_button("Docs", active_tab == ProjectTab::Docs, accent))
|
|
.child(tab_button("Files", active_tab == ProjectTab::Files, accent)),
|
|
)
|
|
// ── Content Area ────────────────────────────────────
|
|
.child(content)
|
|
}
|
|
}
|