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

@@ -30,6 +30,7 @@ use std::sync::mpsc::Receiver;
use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
use std::time::Instant;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
@@ -59,15 +60,16 @@ pub(crate) struct App<'a> {
file_search: FileSearchManager,
/// True when a redraw has been scheduled but not yet executed.
pending_redraw: Arc<AtomicBool>,
pending_history_lines: Vec<Line<'static>>,
enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
/// Channel to schedule one-shot animation frames; coalesced by a single
/// scheduler thread.
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -91,7 +93,6 @@ impl App<'_> {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let pending_redraw = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -169,6 +170,47 @@ impl App<'_> {
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
// Spawn a single scheduler thread that coalesces both debounced redraw
// requests and animation frame requests, and emits a single Redraw event
// at the earliest requested time.
let (frame_tx, frame_rx) = channel::<Instant>();
{
let app_event_tx = app_event_tx.clone();
std::thread::spawn(move || {
use std::sync::mpsc::RecvTimeoutError;
let mut next_deadline: Option<Instant> = None;
loop {
if next_deadline.is_none() {
match frame_rx.recv() {
Ok(deadline) => next_deadline = Some(deadline),
Err(_) => break,
}
}
#[allow(clippy::expect_used)]
let deadline = next_deadline.expect("deadline set");
let now = Instant::now();
let timeout = if deadline > now {
deadline - now
} else {
Duration::from_millis(0)
};
match frame_rx.recv_timeout(timeout) {
Ok(new_deadline) => {
next_deadline =
Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline)));
}
Err(RecvTimeoutError::Timeout) => {
app_event_tx.send(AppEvent::Redraw);
next_deadline = None;
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
});
}
Self {
server: conversation_manager,
app_event_tx,
@@ -177,38 +219,19 @@ impl App<'_> {
app_state,
config,
file_search,
pending_redraw,
enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)),
frame_schedule_tx: frame_tx,
}
}
/// Schedule a redraw if one is not already pending.
#[allow(clippy::unwrap_used)]
fn schedule_redraw(&self) {
// Attempt to set the flag to `true`. If it was already `true`, another
// redraw is already pending so we can return early.
if self
.pending_redraw
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
}
let tx = self.app_event_tx.clone();
let pending_redraw = self.pending_redraw.clone();
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
pending_redraw.store(false, Ordering::Release);
});
fn schedule_frame_in(&self, dur: Duration) {
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::RequestRedraw);
// Schedule the first render immediately.
let _ = self.frame_schedule_tx.send(Instant::now());
while let Ok(event) = self.app_event_rx.recv() {
match event {
@@ -217,7 +240,10 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_redraw();
self.schedule_frame_in(REDRAW_DEBOUNCE);
}
AppEvent::ScheduleFrameIn(dur) => {
self.schedule_frame_in(dur);
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
@@ -447,7 +473,7 @@ impl App<'_> {
widget: Box::new(ChatWidget::new(
config,
self.server.clone(),
app_event_tx.clone(),
self.app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,

View File

@@ -2,6 +2,7 @@ use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::text::Line;
use std::time::Duration;
use crate::app::ChatWidgetArgs;
use crate::slash_command::SlashCommand;
@@ -17,6 +18,10 @@ pub(crate) enum AppEvent {
/// Actually draw the next frame.
Redraw,
/// Schedule a one-shot animation frame roughly after the given duration.
/// Multiple requests are coalesced by the central frame scheduler.
ScheduleFrameIn(Duration),
KeyEvent(KeyEvent),
/// Text pasted from the terminal clipboard.

View File

@@ -20,7 +20,6 @@ use crate::colors::LIGHT_BLUE;
use crate::colors::SUCCESS_GREEN;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::FrameTicker;
use crate::shimmer::shimmer_spans;
use std::path::PathBuf;
@@ -38,10 +37,9 @@ pub(crate) enum SignInState {
}
#[derive(Debug)]
/// Used to manage the lifecycle of SpawnedLogin and FrameTicker and ensure they get cleaned up.
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState {
login_child: Option<codex_login::SpawnedLogin>,
_frame_ticker: Option<FrameTicker>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
@@ -180,9 +178,13 @@ impl AuthModeWidget {
}
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let idx = self.current_frame();
let mut spans = vec![Span::from("> ")];
spans.extend(shimmer_spans("Finish signing in via your browser", idx));
// Schedule a follow-up frame to keep the shimmer animation going.
self.event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(
100,
)));
spans.extend(shimmer_spans("Finish signing in via your browser"));
let mut lines = vec![Line::from(spans), Line::from("")];
if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state {
@@ -297,7 +299,6 @@ impl AuthModeWidget {
self.sign_in_state =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_child: Some(child),
_frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
});
self.event_tx.send(AppEvent::RequestRedraw);
}
@@ -353,16 +354,6 @@ impl AuthModeWidget {
}
});
}
fn current_frame(&self) -> usize {
// Derive frame index from wall-clock time to avoid storing animation state.
// 100ms per frame to match the previous ticker cadence.
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
(now_ms / 100) as usize
}
}
impl StepStateProvider for AuthModeWidget {

View File

@@ -1,51 +1,35 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
static PROCESS_START: OnceLock<Instant> = OnceLock::new();
#[derive(Debug)]
pub(crate) struct FrameTicker {
running: Arc<AtomicBool>,
fn elapsed_since_start() -> Duration {
let start = PROCESS_START.get_or_init(Instant::now);
start.elapsed()
}
impl FrameTicker {
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let app_event_tx_clone = app_event_tx.clone();
std::thread::spawn(move || {
while running_clone.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(100));
app_event_tx_clone.send(AppEvent::RequestRedraw);
}
});
Self { running }
}
}
impl Drop for FrameTicker {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
}
}
pub(crate) fn shimmer_spans(text: &str, frame_idx: usize) -> Vec<Span<'static>> {
pub(crate) fn shimmer_spans(text: &str) -> Vec<Span<'static>> {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return Vec::new();
}
// Use time-based sweep synchronized to process start.
let padding = 10usize;
let period = chars.len() + padding * 2;
let pos = frame_idx % period;
let sweep_seconds = 2.5f32;
let pos_f =
(elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32);
let pos = pos_f as usize;
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false);
let band_half_width = 6.0;
let band_half_width = 3.0;
let mut spans: Vec<Span<'static>> = Vec::with_capacity(chars.len());
for (i, ch) in chars.iter().enumerate() {

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::*;