feat(ui-gpui): Zed-style BlinkManager pattern for pulsing dot (epoch guard, 500ms timer)
This commit is contained in:
parent
84324f9ae3
commit
ba74e19ff2
2 changed files with 77 additions and 109 deletions
|
|
@ -10,7 +10,7 @@ use gpui::*;
|
||||||
|
|
||||||
use crate::state::{AgentStatus, Project, ProjectTab};
|
use crate::state::{AgentStatus, Project, ProjectTab};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::components::pulsing_dot::{PulsingDotElement, DotStatus};
|
use crate::components::pulsing_dot::{PulsingDot, DotStatus};
|
||||||
|
|
||||||
// ── Accent Colors by Index ──────────────────────────────────────────
|
// ── Accent Colors by Index ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ pub struct ProjectBox {
|
||||||
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
|
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
|
||||||
/// Entity handle for the embedded terminal (Model tab)
|
/// Entity handle for the embedded terminal (Model tab)
|
||||||
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
|
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
|
||||||
_status_dot_placeholder: (), // PulsingDotElement is created inline during render
|
pub status_dot: Option<Entity<PulsingDot>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectBox {
|
impl ProjectBox {
|
||||||
|
|
@ -81,13 +81,25 @@ impl ProjectBox {
|
||||||
project,
|
project,
|
||||||
agent_pane: None,
|
agent_pane: None,
|
||||||
terminal_view: None,
|
terminal_view: None,
|
||||||
_status_dot_placeholder: (),
|
status_dot: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize sub-views. Must be called after the ProjectBox entity is created.
|
/// Initialize sub-views. Must be called after the ProjectBox entity is created.
|
||||||
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
|
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
|
||||||
eprintln!("[ProjectBox] init_subviews for {}", self.project.name);
|
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));
|
||||||
|
self.status_dot = Some(dot);
|
||||||
|
// NOTE: start_blinking NOT called yet — testing if app stays alive without it
|
||||||
|
|
||||||
// Create agent pane with demo messages
|
// Create agent pane with demo messages
|
||||||
let agent_pane = cx.new(|_cx| {
|
let agent_pane = cx.new(|_cx| {
|
||||||
crate::components::agent_pane::AgentPane::with_demo_messages()
|
crate::components::agent_pane::AgentPane::with_demo_messages()
|
||||||
|
|
@ -238,16 +250,8 @@ impl Render for ProjectBox {
|
||||||
.rounded(px(2.0))
|
.rounded(px(2.0))
|
||||||
.bg(accent),
|
.bg(accent),
|
||||||
)
|
)
|
||||||
// Animated status dot — custom Element, paints directly to GPU
|
// Animated status dot (Zed BlinkManager pattern)
|
||||||
.child({
|
.children(self.status_dot.clone())
|
||||||
let dot_status = match status {
|
|
||||||
AgentStatus::Running => DotStatus::Running,
|
|
||||||
AgentStatus::Idle => DotStatus::Idle,
|
|
||||||
AgentStatus::Done => DotStatus::Done,
|
|
||||||
AgentStatus::Error => DotStatus::Error,
|
|
||||||
};
|
|
||||||
PulsingDotElement::new(dot_status, 8.0)
|
|
||||||
})
|
|
||||||
// Project name
|
// Project name
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
//! PulsingDot — custom GPUI Element that paints a single animated quad.
|
//! PulsingDot — Zed-style BlinkManager pattern.
|
||||||
//!
|
//!
|
||||||
//! Instead of using the vdom (div().bg(color)), we implement Element directly
|
//! Uses cx.spawn() + background_executor().timer() + cx.notify() — exactly
|
||||||
//! and call window.paint_quad() in paint(). Combined with request_animation_frame(),
|
//! how Zed's BlinkManager achieves ~1% CPU cursor blink.
|
||||||
//! GPUI only repaints THIS element's quad — not the full vdom tree.
|
//!
|
||||||
//! The GPU handles the color interpolation via the paint_quad shader.
|
//! Key: spawn must be called AFTER entity is registered (not inside cx.new()).
|
||||||
|
//! The epoch counter cancels stale timers automatically.
|
||||||
|
|
||||||
use gpui::*;
|
use gpui::*;
|
||||||
use std::time::Instant;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
|
|
@ -19,18 +20,20 @@ pub enum DotStatus {
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PulsingDotElement {
|
pub struct PulsingDot {
|
||||||
status: DotStatus,
|
status: DotStatus,
|
||||||
size: Pixels,
|
size: f32,
|
||||||
start_time: Instant,
|
visible: bool, // toggles each blink
|
||||||
|
blink_epoch: usize, // cancels stale timers (Zed pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PulsingDotElement {
|
impl PulsingDot {
|
||||||
pub fn new(status: DotStatus, size: f32) -> Self {
|
pub fn new(status: DotStatus, size: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
size: px(size),
|
size,
|
||||||
start_time: Instant::now(),
|
visible: true,
|
||||||
|
blink_epoch: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,98 +41,59 @@ impl PulsingDotElement {
|
||||||
matches!(self.status, DotStatus::Running | DotStatus::Stalled)
|
matches!(self.status, DotStatus::Running | DotStatus::Stalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base_color(&self) -> Rgba {
|
fn next_epoch(&mut self) -> usize {
|
||||||
|
self.blink_epoch += 1;
|
||||||
|
self.blink_epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start blinking. MUST be called after entity is registered (not in cx.new).
|
||||||
|
/// This is the Zed BlinkManager pattern — recursive spawn with epoch guard.
|
||||||
|
pub fn start_blinking(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.should_pulse() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let epoch = self.next_epoch();
|
||||||
|
self.schedule_blink(epoch, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_blink(&self, epoch: usize, cx: &mut Context<Self>) {
|
||||||
|
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
|
||||||
|
cx.background_executor().timer(Duration::from_millis(500)).await;
|
||||||
|
this.update(cx, |dot, cx| {
|
||||||
|
// Epoch guard: if epoch changed (pause/resume), this timer is stale
|
||||||
|
if dot.blink_epoch == epoch {
|
||||||
|
dot.visible = !dot.visible;
|
||||||
|
cx.notify(); // marks ONLY this view dirty
|
||||||
|
dot.schedule_blink(epoch, cx); // recursive schedule
|
||||||
|
}
|
||||||
|
}).ok();
|
||||||
|
}).detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color(&self) -> Rgba {
|
||||||
match self.status {
|
match self.status {
|
||||||
DotStatus::Running => theme::GREEN,
|
DotStatus::Running => if self.visible { theme::GREEN } else { theme::SURFACE1 },
|
||||||
DotStatus::Idle => theme::OVERLAY0,
|
DotStatus::Idle => theme::OVERLAY0,
|
||||||
DotStatus::Stalled => theme::PEACH,
|
DotStatus::Stalled => if self.visible { theme::PEACH } else { theme::SURFACE1 },
|
||||||
DotStatus::Done => theme::BLUE,
|
DotStatus::Done => theme::BLUE,
|
||||||
DotStatus::Error => theme::RED,
|
DotStatus::Error => theme::RED,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_color(&self) -> Hsla {
|
|
||||||
let base = self.base_color();
|
|
||||||
if !self.should_pulse() {
|
|
||||||
return base.into();
|
|
||||||
}
|
|
||||||
let elapsed = self.start_time.elapsed().as_secs_f32();
|
|
||||||
let t = 0.5 - 0.5 * (elapsed * std::f32::consts::PI).sin();
|
|
||||||
let bg = theme::SURFACE0;
|
|
||||||
let color = Rgba {
|
|
||||||
r: base.r + (bg.r - base.r) * t,
|
|
||||||
g: base.g + (bg.g - base.g) * t,
|
|
||||||
b: base.b + (bg.b - base.b) * t,
|
|
||||||
a: 1.0,
|
|
||||||
};
|
|
||||||
color.into()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoElement for PulsingDotElement {
|
impl Render for PulsingDot {
|
||||||
type Element = Self;
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
fn into_element(self) -> Self { self }
|
let color = self.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;
|
||||||
|
let hex = rgba(r * 0x1000000 + g * 0x10000 + b * 0x100 + 0xFF);
|
||||||
|
|
||||||
impl Element for PulsingDotElement {
|
div()
|
||||||
type RequestLayoutState = ();
|
.w(px(self.size))
|
||||||
type PrepaintState = ();
|
.h(px(self.size))
|
||||||
|
.rounded(px(self.size / 2.0))
|
||||||
fn id(&self) -> Option<ElementId> { None }
|
.bg(hex)
|
||||||
|
.flex_shrink_0()
|
||||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None }
|
|
||||||
|
|
||||||
fn request_layout(
|
|
||||||
&mut self,
|
|
||||||
_id: Option<&GlobalElementId>,
|
|
||||||
_inspector_id: Option<&InspectorElementId>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> (LayoutId, Self::RequestLayoutState) {
|
|
||||||
let layout_id = window.request_layout(
|
|
||||||
Style {
|
|
||||||
size: Size { width: self.size.into(), height: self.size.into() },
|
|
||||||
flex_shrink: 0.0,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
(layout_id, ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepaint(
|
|
||||||
&mut self,
|
|
||||||
_id: Option<&GlobalElementId>,
|
|
||||||
_inspector_id: Option<&InspectorElementId>,
|
|
||||||
_bounds: Bounds<Pixels>,
|
|
||||||
_request_layout: &mut Self::RequestLayoutState,
|
|
||||||
_window: &mut Window,
|
|
||||||
_cx: &mut App,
|
|
||||||
) -> Self::PrepaintState {
|
|
||||||
()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint(
|
|
||||||
&mut self,
|
|
||||||
_id: Option<&GlobalElementId>,
|
|
||||||
_inspector_id: Option<&InspectorElementId>,
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
_request_layout: &mut Self::RequestLayoutState,
|
|
||||||
_prepaint: &mut Self::PrepaintState,
|
|
||||||
window: &mut Window,
|
|
||||||
_cx: &mut App,
|
|
||||||
) {
|
|
||||||
let color = self.current_color();
|
|
||||||
let radius = self.size / 2.0;
|
|
||||||
|
|
||||||
// Paint a single rounded quad — this is the ONLY thing that gets drawn
|
|
||||||
window.paint_quad(fill(bounds, color).corner_radii(radius));
|
|
||||||
|
|
||||||
// Schedule next animation frame — GPUI will call paint() again
|
|
||||||
// But this time, ONLY this element gets repainted (not the full vdom)
|
|
||||||
if self.should_pulse() {
|
|
||||||
window.request_animation_frame();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue