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::ascii_animation::AsciiAnimation; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::tui::FrameRequester; use super::onboarding_screen::StepState; const MIN_ANIMATION_HEIGHT: u16 = 20; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { pub is_logged_in: bool, 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) { 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 = area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; let mut lines: Vec = Vec::new(); if show_animation { let frame = self.animation.current_frame(); // let frame_line_count = frame.lines().count(); // lines.reserve(frame_line_count + 2); lines.extend(frame.lines().map(Into::into)); lines.push("".into()); } lines.push(Line::from(vec![ " ".into(), "Welcome to ".into(), "Codex".bold(), ", OpenAI's command-line coding agent".into(), ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } } impl StepStateProvider for WelcomeWidget { fn get_step_state(&self) -> StepState { match self.is_logged_in { true => StepState::Hidden, false => StepState::Complete, } } } #[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]; #[test] 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" ); } }