use a central animation loop (#2268)

instead of each shimmer needing to have its own animation thread, have
render_ref schedule a new frame if it wants one and coalesce to the
earliest next frame. this also makes the animations
frame-timing-independent, based on start time instead of frame count.
This commit is contained in:
Jeremy Rose
2025-08-14 16:59:47 -04:00
committed by GitHub
parent fd2b059504
commit 20cd61e2a4
5 changed files with 97 additions and 161 deletions

View File

@@ -1,11 +1,6 @@
//! A live status indicator that shows the *latest* log line emitted by the
//! application while the agent is processing a longrunning task.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use std::time::Instant;
@@ -23,6 +18,7 @@ use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::shimmer::shimmer_spans;
// We render the live text using markdown so it visually matches the history
// cells. Before rendering we strip any ANSI escape sequences to avoid writing
@@ -41,42 +37,17 @@ pub(crate) struct StatusIndicatorWidget {
last_target_len: usize,
base_frame: usize,
reveal_len_at_base: usize,
frame_idx: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
start_time: Instant,
app_event_tx: AppEventSender,
}
impl StatusIndicatorWidget {
/// Create a new status indicator and start the animation timer.
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
let frame_idx = Arc::new(AtomicUsize::new(0));
let running = Arc::new(AtomicBool::new(true));
// Animation thread.
{
let frame_idx_clone = Arc::clone(&frame_idx);
let running_clone = Arc::clone(&running);
let app_event_tx_clone = app_event_tx.clone();
thread::spawn(move || {
let mut counter = 0usize;
while running_clone.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(100));
counter = counter.wrapping_add(1);
frame_idx_clone.store(counter, Ordering::Relaxed);
app_event_tx_clone.send(AppEvent::RequestRedraw);
}
});
}
Self {
text: String::from("waiting for model"),
last_target_len: 0,
base_frame: 0,
reveal_len_at_base: 0,
frame_idx,
running,
start_time: Instant::now(),
app_event_tx,
@@ -108,7 +79,7 @@ impl StatusIndicatorWidget {
// Compute how many characters are currently revealed so we can carry
// this forward as the new baseline when target text changes.
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let current_frame = self.current_frame();
let shown_now = self.current_shown_len(current_frame);
self.text = text;
@@ -135,7 +106,7 @@ impl StatusIndicatorWidget {
};
let new_len = stripped.chars().count();
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let current_frame = self.current_frame();
self.text = sanitized;
self.last_target_len = new_len;
@@ -155,12 +126,12 @@ impl StatusIndicatorWidget {
.saturating_add(frames.saturating_mul(TYPING_CHARS_PER_FRAME));
advanced.min(self.last_target_len)
}
}
impl Drop for StatusIndicatorWidget {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.running.store(false, Ordering::Relaxed);
fn current_frame(&self) -> usize {
// Derive frame index from wall-clock time. 100ms per frame to match
// the previous ticker cadence.
let since_start = self.start_time.elapsed();
(since_start.as_millis() / 100) as usize
}
}
@@ -171,45 +142,14 @@ impl WidgetRef for StatusIndicatorWidget {
return;
}
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
// Schedule next animation frame.
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
let idx = self.current_frame();
let elapsed = self.start_time.elapsed().as_secs();
let shown_now = self.current_shown_len(idx);
let status_prefix: String = self.text.chars().take(shown_now).collect();
let animated_text = "Working";
let header_chars: Vec<char> = animated_text.chars().collect();
let padding = 4usize; // virtual padding around the animated segment for smoother loop
let period = header_chars.len() + padding * 2;
let pos = idx % period;
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false);
let band_half_width = 2.0; // width of the bright band in characters
let mut animated_spans: Vec<Span<'static>> = Vec::new();
for (i, ch) in header_chars.iter().enumerate() {
let i_pos = i as isize + padding as isize;
let pos = pos as isize;
let dist = (i_pos - pos).abs() as f32;
let t = if dist <= band_half_width {
let x = std::f32::consts::PI * (dist / band_half_width);
0.5 * (1.0 + x.cos())
} else {
0.0
};
let brightness = 0.4 + 0.6 * t;
let level = (brightness * 255.0).clamp(0.0, 255.0) as u8;
let style = if has_true_color {
Style::default()
.fg(Color::Rgb(level, level, level))
.add_modifier(Modifier::BOLD)
} else {
color_for_level(level)
};
animated_spans.push(Span::styled(ch.to_string(), style));
}
let animated_spans = shimmer_spans("Working");
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let inner_width = area.width as usize;
@@ -268,16 +208,6 @@ impl WidgetRef for StatusIndicatorWidget {
}
}
fn color_for_level(level: u8) -> Style {
if level < 144 {
Style::default().add_modifier(Modifier::DIM)
} else if level < 208 {
Style::default()
} else {
Style::default().add_modifier(Modifier::BOLD)
}
}
#[cfg(test)]
mod tests {
use super::*;