Codex Handbook
tui/src/onboarding/welcome.rs 220 lines
use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;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 std::cell::Cell;use crate::ascii_animation::AsciiAnimation;use crate::key_hint::KeyBindingListExt;use crate::onboarding::keys;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 = 37;const MIN_ANIMATION_WIDTH: u16 = 60;pub(crate) struct WelcomeWidget {    pub is_logged_in: bool,    animation: AsciiAnimation,    animations_enabled: bool,    animations_suppressed: Cell<bool>,    layout_area: Cell<Option<Rect>>,}impl KeyboardHandler for WelcomeWidget {    /// Rotate the welcome animation when the fixed toggle shortcut fires.    ///    /// The key list includes compatibility variants for terminals that report    /// modifier bits differently.    fn handle_key_event(&mut self, key_event: KeyEvent) {        if !self.animations_enabled {            return;        }        if key_event.kind == KeyEventKind::Press && keys::TOGGLE_ANIMATION.is_pressed(key_event) {            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,        animations_enabled: bool,    ) -> Self {        Self {            is_logged_in,            animation: AsciiAnimation::new(request_frame),            animations_enabled,            animations_suppressed: Cell::new(false),            layout_area: Cell::new(None),        }    }    pub(crate) fn update_layout_area(&self, area: Rect) {        self.layout_area.set(Some(area));    }    pub(crate) fn set_animations_suppressed(&self, suppressed: bool) {        self.animations_suppressed.set(suppressed);    }}impl WidgetRef for &WelcomeWidget {    fn render_ref(&self, area: Rect, buf: &mut Buffer) {        Clear.render(area, buf);        if self.animations_enabled && !self.animations_suppressed.get() {            self.animation.schedule_next_frame();        }        let layout_area = self.layout_area.get().unwrap_or(area);        // Skip the animation entirely when the viewport is too small so we don't clip frames.        let show_animation = self.animations_enabled            && !self.animations_suppressed.get()            && layout_area.height >= MIN_ANIMATION_HEIGHT            && layout_area.width >= MIN_ANIMATION_WIDTH;        let mut lines: Vec<Line> = Vec::new();        if show_animation {            let frame = self.animation.current_frame();            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 crossterm::event::KeyCode;    use crossterm::event::KeyModifiers;    use pretty_assertions::assert_eq;    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];    fn row_containing(buf: &Buffer, needle: &str) -> Option<u16> {        (0..buf.area.height).find(|&y| {            let mut row = String::new();            for x in 0..buf.area.width {                row.push_str(buf[(x, y)].symbol());            }            row.contains(needle)        })    }    #[test]    fn welcome_renders_animation_on_first_draw() {        let widget = WelcomeWidget::new(            /*is_logged_in*/ false,            FrameRequester::test_dummy(),            /*animations_enabled*/ true,        );        let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);        let mut buf = Buffer::empty(area);        let frame_lines = widget.animation.current_frame().lines().count() as u16;        (&widget).render(area, &mut buf);        let welcome_row = row_containing(&buf, "Welcome");        assert_eq!(welcome_row, Some(frame_lines + 1));    }    #[test]    fn welcome_skips_animation_below_height_breakpoint() {        let widget = WelcomeWidget::new(            /*is_logged_in*/ false,            FrameRequester::test_dummy(),            /*animations_enabled*/ true,        );        let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);        let mut buf = Buffer::empty(area);        (&widget).render(area, &mut buf);        let welcome_row = row_containing(&buf, "Welcome");        assert_eq!(welcome_row, Some(0));    }    #[test]    fn ctrl_dot_changes_animation_variant() {        let mut widget = WelcomeWidget {            is_logged_in: false,            animation: AsciiAnimation::with_variants(                FrameRequester::test_dummy(),                &VARIANTS,                /*variant_idx*/ 0,            ),            animations_enabled: true,            animations_suppressed: Cell::new(false),            layout_area: Cell::new(None),        };        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"        );    }    #[test]    fn ctrl_shift_dot_changes_animation_variant() {        let mut widget = WelcomeWidget {            is_logged_in: false,            animation: AsciiAnimation::with_variants(                FrameRequester::test_dummy(),                &VARIANTS,                /*variant_idx*/ 0,            ),            animations_enabled: true,            animations_suppressed: Cell::new(false),            layout_area: Cell::new(None),        };        let before = widget.animation.current_frame();        widget.handle_key_event(KeyEvent::new(            KeyCode::Char('.'),            KeyModifiers::CONTROL | KeyModifiers::SHIFT,        ));        let after = widget.animation.current_frame();        assert_ne!(            before, after,            "expected ctrl+shift+. to switch welcome animation variant"        );    }}