Unify animations (#3729)
Unify the animation in a single code and add the CTRL + . in the onboarding
This commit is contained in:
110
codex-rs/tui/src/ascii_animation.rs
Normal file
110
codex-rs/tui/src/ascii_animation.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ModelUpgradeDecision>,
|
||||
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<ModelUpgradeOption> 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<Line> = 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<ModelUpgrad
|
||||
frame.render_widget_ref(&popup, frame.area());
|
||||
})?;
|
||||
|
||||
popup.advance_animation();
|
||||
|
||||
let events = tui.event_stream();
|
||||
tokio::pin!(events);
|
||||
while popup.decision.is_none() {
|
||||
@@ -197,7 +164,6 @@ pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result<ModelUpgrad
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => popup.handle_key_event(key_event),
|
||||
TuiEvent::Draw => {
|
||||
popup.advance_animation();
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&popup, frame.area());
|
||||
});
|
||||
|
||||
@@ -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<Step> = vec![Step::Welcome(WelcomeWidget {
|
||||
is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated),
|
||||
request_frame: tui.frame_requester(),
|
||||
start: Instant::now(),
|
||||
})];
|
||||
let mut steps: Vec<Step> = 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),
|
||||
}
|
||||
|
||||
@@ -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<Line> = 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<u16> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user