Unify animations (#3729)

Unify the animation in a single code and add the CTRL + . in the
onboarding
This commit is contained in:
jif-oai
2025-09-18 16:27:15 +01:00
committed by GitHub
parent d4aba772cb
commit 1b3c8b8e94
5 changed files with 220 additions and 81 deletions

View File

@@ -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),
}

View File

@@ -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"
);
}
}