From 1b3c8b8e946c453d5e1e68eb7e81a2546c3135db Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 16:27:15 +0100 Subject: [PATCH] Unify animations (#3729) Unify the animation in a single code and add the CTRL + . in the onboarding --- codex-rs/tui/src/ascii_animation.rs | 110 +++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/new_model_popup.rs | 50 ++------ .../tui/src/onboarding/onboarding_screen.rs | 28 +++-- codex-rs/tui/src/onboarding/welcome.rs | 112 +++++++++++++----- 5 files changed, 220 insertions(+), 81 deletions(-) create mode 100644 codex-rs/tui/src/ascii_animation.rs diff --git a/codex-rs/tui/src/ascii_animation.rs b/codex-rs/tui/src/ascii_animation.rs new file mode 100644 index 00000000..ee901911 --- /dev/null +++ b/codex-rs/tui/src/ascii_animation.rs @@ -0,0 +1,110 @@ +use std::convert::TryFrom; +use std::time::Duration; +use std::time::Instant; + +use rand::Rng as _; + +use crate::frames::ALL_VARIANTS; +use crate::frames::FRAME_TICK_DEFAULT; +use crate::tui::FrameRequester; + +/// Drives ASCII art animations shared across popups and onboarding widgets. +pub(crate) struct AsciiAnimation { + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + frame_tick: Duration, + start: Instant, +} + +impl AsciiAnimation { + pub(crate) fn new(request_frame: FrameRequester) -> Self { + Self::with_variants(request_frame, ALL_VARIANTS, 0) + } + + pub(crate) fn with_variants( + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + ) -> Self { + assert!( + !variants.is_empty(), + "AsciiAnimation requires at least one animation variant", + ); + let clamped_idx = variant_idx.min(variants.len() - 1); + Self { + request_frame, + variants, + variant_idx: clamped_idx, + frame_tick: FRAME_TICK_DEFAULT, + start: Instant::now(), + } + } + + pub(crate) fn schedule_next_frame(&self) { + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + self.request_frame.schedule_frame(); + return; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let rem_ms = elapsed_ms % tick_ms; + let delay_ms = if rem_ms == 0 { + tick_ms + } else { + tick_ms - rem_ms + }; + if let Ok(delay_ms_u64) = u64::try_from(delay_ms) { + self.request_frame + .schedule_frame_in(Duration::from_millis(delay_ms_u64)); + } else { + self.request_frame.schedule_frame(); + } + } + + pub(crate) fn current_frame(&self) -> &'static str { + let frames = self.frames(); + if frames.is_empty() { + return ""; + } + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + return frames[0]; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize; + frames[idx] + } + + pub(crate) fn pick_random_variant(&mut self) -> bool { + if self.variants.len() <= 1 { + return false; + } + let mut rng = rand::rng(); + let mut next = self.variant_idx; + while next == self.variant_idx { + next = rng.random_range(0..self.variants.len()); + } + self.variant_idx = next; + self.request_frame.schedule_frame(); + true + } + + pub(crate) fn request_frame(&self) { + self.request_frame.schedule_frame(); + } + + fn frames(&self) -> &'static [&'static str] { + self.variants[self.variant_idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_tick_must_be_nonzero() { + assert!(FRAME_TICK_DEFAULT.as_millis() > 0); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 995ca17a..3f4d66ee 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -32,6 +32,7 @@ mod app; mod app_backtrack; mod app_event; mod app_event_sender; +mod ascii_animation; mod backtrack_helpers; mod bottom_pane; mod chatwidget; diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs index 12325074..2106e46c 100644 --- a/codex-rs/tui/src/new_model_popup.rs +++ b/codex-rs/tui/src/new_model_popup.rs @@ -1,5 +1,4 @@ -use crate::frames::ALL_VARIANTS as FRAME_VARIANTS; -use crate::frames::FRAME_TICK_DEFAULT; +use crate::ascii_animation::AsciiAnimation; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; @@ -7,7 +6,6 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; -use rand::Rng as _; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -17,10 +15,8 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use std::time::Duration; use tokio_stream::StreamExt; -const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; const MIN_ANIMATION_HEIGHT: u16 = 24; const MIN_ANIMATION_WIDTH: u16 = 60; @@ -39,9 +35,7 @@ enum ModelUpgradeOption { struct ModelUpgradePopup { highlighted: ModelUpgradeOption, decision: Option, - request_frame: FrameRequester, - frame_idx: usize, - variant_idx: usize, + animation: AsciiAnimation, } impl ModelUpgradePopup { @@ -49,9 +43,7 @@ impl ModelUpgradePopup { Self { highlighted: ModelUpgradeOption::TryNewModel, decision: None, - request_frame, - frame_idx: 0, - variant_idx: 0, + animation: AsciiAnimation::new(request_frame), } } @@ -65,7 +57,7 @@ impl ModelUpgradePopup { KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent), KeyCode::Char('.') => { if key_event.modifiers.contains(KeyModifiers::CONTROL) { - self.pick_random_variant(); + let _ = self.animation.pick_random_variant(); } } _ => {} @@ -75,37 +67,13 @@ impl ModelUpgradePopup { fn highlight(&mut self, option: ModelUpgradeOption) { if self.highlighted != option { self.highlighted = option; - self.request_frame.schedule_frame(); + self.animation.request_frame(); } } fn select(&mut self, option: ModelUpgradeOption) { self.decision = Some(option.into()); - self.request_frame.schedule_frame(); - } - - fn advance_animation(&mut self) { - let len = self.frames().len(); - self.frame_idx = (self.frame_idx + 1) % len; - self.request_frame.schedule_frame_in(FRAME_TICK); - } - - fn frames(&self) -> &'static [&'static str] { - FRAME_VARIANTS[self.variant_idx] - } - - fn pick_random_variant(&mut self) { - let total = FRAME_VARIANTS.len(); - if total <= 1 { - return; - } - let mut rng = rand::rng(); - let mut next = self.variant_idx; - while next == self.variant_idx { - next = rng.random_range(0..total); - } - self.variant_idx = next; - self.request_frame.schedule_frame(); + self.animation.request_frame(); } } @@ -121,6 +89,7 @@ impl From for ModelUpgradeDecision { impl WidgetRef for &ModelUpgradePopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); + self.animation.schedule_next_frame(); // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = @@ -128,7 +97,7 @@ impl WidgetRef for &ModelUpgradePopup { let mut lines: Vec = Vec::new(); if show_animation { - let frame = self.frames()[self.frame_idx]; + let frame = self.animation.current_frame(); lines.extend(frame.lines().map(|l| l.into())); // Spacer between animation and text content. lines.push("".into()); @@ -188,8 +157,6 @@ pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result Result popup.handle_key_event(key_event), TuiEvent::Draw => { - popup.advance_animation(); let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&popup, frame.area()); }); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 12a2b4b1..5806424b 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -7,6 +7,7 @@ use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; +use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; @@ -24,7 +25,6 @@ use crate::tui::TuiEvent; use color_eyre::eyre::Result; use std::sync::Arc; use std::sync::RwLock; -use std::time::Instant; #[allow(clippy::large_enum_variant)] enum Step { @@ -73,11 +73,10 @@ impl OnboardingScreen { } = args; let cwd = config.cwd.clone(); let codex_home = config.codex_home; - let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { - is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), - request_frame: tui.frame_requester(), - start: Instant::now(), - })]; + let mut steps: Vec = vec![Step::Welcome(WelcomeWidget::new( + !matches!(login_status, LoginStatus::NotAuthenticated), + tui.frame_requester(), + ))]; if show_login_screen { steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), @@ -189,6 +188,13 @@ impl KeyboardHandler for OnboardingScreen { self.is_done = true; } _ => { + if let Some(Step::Welcome(widget)) = self + .steps + .iter_mut() + .find(|step| matches!(step, Step::Welcome(_))) + { + widget.handle_key_event(key_event); + } if let Some(active_step) = self.current_steps_mut().into_iter().last() { active_step.handle_key_event(key_event); } @@ -226,8 +232,12 @@ impl WidgetRef for &OnboardingScreen { for yy in 0..height { let mut any = false; for xx in 0..width { - let sym = tmp[(xx, yy)].symbol(); - if !sym.trim().is_empty() { + let cell = &tmp[(xx, yy)]; + let has_symbol = !cell.symbol().trim().is_empty(); + let has_style = cell.fg != Color::Reset + || cell.bg != Color::Reset + || !cell.modifier.is_empty(); + if has_symbol || has_style { any = true; break; } @@ -271,7 +281,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { - Step::Welcome(_) => (), + Step::Welcome(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index 578e2b42..c62b6402 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,60 +1,68 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use crate::frames::FRAME_TICK_DEFAULT; -use crate::frames::FRAMES_DEFAULT; +use crate::ascii_animation::AsciiAnimation; +use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::tui::FrameRequester; use super::onboarding_screen::StepState; -use std::time::Duration; -use std::time::Instant; -const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; const MIN_ANIMATION_HEIGHT: u16 = 20; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { pub is_logged_in: bool, - pub request_frame: FrameRequester, - pub start: Instant, + animation: AsciiAnimation, +} + +impl KeyboardHandler for WelcomeWidget { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Char('.') + && key_event.modifiers.contains(KeyModifiers::CONTROL) + { + tracing::warn!("Welcome background to press '.'"); + let _ = self.animation.pick_random_variant(); + } + } +} + +impl WelcomeWidget { + pub(crate) fn new(is_logged_in: bool, request_frame: FrameRequester) -> Self { + Self { + is_logged_in, + animation: AsciiAnimation::new(request_frame), + } + } } impl WidgetRef for &WelcomeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let elapsed_ms = self.start.elapsed().as_millis(); + Clear.render(area, buf); + self.animation.schedule_next_frame(); - // Align next draw to the next FRAME_TICK boundary to reduce jitter. - { - let tick_ms = FRAME_TICK.as_millis(); - let rem_ms = elapsed_ms % tick_ms; - let delay_ms = if rem_ms == 0 { - tick_ms - } else { - tick_ms - rem_ms - }; - // Safe cast: delay_ms < tick_ms and FRAME_TICK is small. - self.request_frame - .schedule_frame_in(Duration::from_millis(delay_ms as u64)); - } - - let frames = &FRAMES_DEFAULT; - let idx = ((elapsed_ms / FRAME_TICK.as_millis()) % frames.len() as u128) as usize; // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; let mut lines: Vec = Vec::new(); if show_animation { - let frame_line_count = frames[idx].lines().count(); - lines.reserve(frame_line_count + 2); - lines.extend(frames[idx].lines().map(|l| l.into())); + let frame = self.animation.current_frame(); + // let frame_line_count = frame.lines().count(); + // lines.reserve(frame_line_count + 2); + lines.extend(frame.lines().map(|l| l.into())); lines.push("".into()); } lines.push(Line::from(vec![ @@ -82,10 +90,54 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + static VARIANT_A: [&str; 1] = ["frame-a"]; + static VARIANT_B: [&str; 1] = ["frame-b"]; + static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B]; - /// A number of things break down if FRAME_TICK is zero. #[test] - fn frame_tick_must_be_nonzero() { - assert!(FRAME_TICK.as_millis() > 0); + fn welcome_renders_animation_on_first_draw() { + let widget = WelcomeWidget::new(false, FrameRequester::test_dummy()); + let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); + let mut buf = Buffer::empty(area); + (&widget).render(area, &mut buf); + + let mut found = false; + let mut last_non_empty: Option = None; + for y in 0..area.height { + for x in 0..area.width { + if !buf[(x, y)].symbol().trim().is_empty() { + found = true; + last_non_empty = Some(y); + break; + } + } + } + + assert!(found, "expected welcome animation to render characters"); + let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0); + assert!( + measured_rows >= MIN_ANIMATION_HEIGHT, + "expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}" + ); + } + + #[test] + fn ctrl_dot_changes_animation_variant() { + let mut widget = WelcomeWidget { + is_logged_in: false, + animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), + }; + + let before = widget.animation.current_frame(); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL)); + let after = widget.animation.current_frame(); + + assert_ne!( + before, after, + "expected ctrl+. to switch welcome animation variant" + ); } }