use std::{ io, time::{Duration, Instant}, }; use ansi_width::ansi_width; use anyhow::Result; use crossterm::{ event::{self, Event, KeyCode, KeyModifiers}, style::Color as CColor, }; use lipsum::lipsum; use ratatui::{ TerminalOptions, Viewport, prelude::CrosstermBackend, style::{Color, Style}, widgets::Block, }; use xai_ratatui_inline::{ Terminal, emit_to_scrollback, resize_purge_rerender, resize_viewport_height, with_synchronized_output, }; /// Build a line with markers for visualization fn build_marked_line(line_num: usize, content: &str) -> String { format!("[[{:02}]] {} [[{:02}]]", line_num, content, line_num) } /// Generate test content based on pattern fn generate_test_line(line_num: usize, terminal_width: usize) -> String { let i = line_num - 1; let mut line = match i % 3 { 0 => lipsum(4), // Short line 1 => lipsum(40), // Long line 2 => { // Unicode string with ANSI colors and hyperlink for testing let unicode_with_ansi = "\x1b[31m😀\u{200D}\x1b[0m\x1b[32mé\x1b[0m中\u{0300}\x1b[34mX\x1b[0m\x1b]8;;https://example.com\x1b\\H\x1b]8;;\x1b\\"; let visual_width = ansi_width(unicode_with_ansi); let marker_adjustment = if i % 6 >= 3 { 7 } else { 14 }; let remaining_width = terminal_width * 2 - marker_adjustment - visual_width; format!("{unicode_with_ansi}{}", "B".repeat(remaining_width)) } _ => unreachable!(), }; // Add newline prefix for cases 3-5, alternating between \n and \r\n if i % 6 >= 3 { line = format!("{}\n{}\r\n{line}", lipsum(2), lipsum(8)); } line } /// Generate tall content that's 1.5x terminal height fn generate_tall_content(terminal_width: usize, terminal_height: usize) -> String { let num_lines = terminal_height; let mut lines = Vec::new(); for i in 1..=num_lines { lines.push(format!( "Line {i:03}/{num_lines:03}: {}", if i % 2 == 1 { lipsum((terminal_width / 25).max(1)) } else { lipsum((terminal_width / 5).max(1)) } )); } lines.join("\n") } /// Build content with ANSI color codes fn colorize_content(content: &str, color: CColor) -> String { let color_code = match color { CColor::Black => 30, CColor::Red | CColor::DarkRed => 31, CColor::Green | CColor::DarkGreen => 32, CColor::Yellow | CColor::DarkYellow => 33, CColor::Blue | CColor::DarkBlue => 34, CColor::Magenta | CColor::DarkMagenta => 35, CColor::Cyan | CColor::DarkCyan => 36, CColor::White | CColor::Grey | CColor::DarkGrey => 37, _ => 37, // Default to white for RGB or other colors }; format!("\x1b[{}m{}\x1b[0m", color_code, content) } fn init_terminal(inline_height: u16) -> io::Result>> { crossterm::terminal::enable_raw_mode()?; let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { fn try_restore() -> io::Result<()> { crossterm::terminal::disable_raw_mode()?; Ok(()) } if let Err(err) = try_restore() { eprintln!("Failed to restore terminal: {err}"); } hook(info); })); let backend = CrosstermBackend::new(io::stdout()); let options = TerminalOptions { viewport: Viewport::Inline(inline_height), }; let terminal = Terminal::with_options(backend, options)?; Ok(terminal) } fn restore_terminal(terminal: &mut Terminal>) -> io::Result<()> { // Clear only the viewport, preserving log content above it terminal.clear()?; crossterm::terminal::disable_raw_mode()?; Ok(()) } enum KeyAction { Quit, Normal, Tall, IncreaseViewport, DecreaseViewport, } /// Spinner animation state struct Spinner { frames: Vec<&'static str>, current: usize, interval: Duration, next_frame_time: Instant, } impl Spinner { fn new() -> Self { let frames = vec!["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"]; Self { frames, current: 0, interval: Duration::from_millis(100), next_frame_time: Instant::now() + Duration::from_millis(100), } } fn tick(&mut self) { self.current = (self.current + 1) % self.frames.len(); self.next_frame_time = Instant::now() + self.interval; } fn current_frame(&self) -> &str { self.frames[self.current] } fn time_until_next_frame(&self) -> Duration { self.next_frame_time .saturating_duration_since(Instant::now()) } } enum PollResult { KeyPressed(KeyAction), AnimationTick, Resize, } fn poll_for_key_or_animation(spinner: &Spinner) -> io::Result { let timeout = spinner.time_until_next_frame(); if event::poll(timeout)? { match event::read()? { Event::Key(key) => { if key.code == KeyCode::Esc || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) || key.code == KeyCode::Char('q') || key.code == KeyCode::Char('Q') { return Ok(PollResult::KeyPressed(KeyAction::Quit)); } else if key.code == KeyCode::Char('t') || key.code == KeyCode::Char('T') { return Ok(PollResult::KeyPressed(KeyAction::Tall)); } else if key.code == KeyCode::Char('+') || key.code == KeyCode::Char('=') { return Ok(PollResult::KeyPressed(KeyAction::IncreaseViewport)); } else if key.code == KeyCode::Char('-') || key.code == KeyCode::Char('_') { return Ok(PollResult::KeyPressed(KeyAction::DecreaseViewport)); } else if matches!(key.code, KeyCode::Char(_) | KeyCode::Enter) { return Ok(PollResult::KeyPressed(KeyAction::Normal)); } // Key we don't care about, continue polling poll_for_key_or_animation(spinner) } Event::Resize(_, _) => Ok(PollResult::Resize), _ => { // Other event we don't care about, continue polling poll_for_key_or_animation(spinner) } } } else { // Timeout reached, time to animate Ok(PollResult::AnimationTick) } } fn render_viewport( terminal: &mut Terminal>, spinner: &Spinner, viewport_height: u16, ) -> io::Result<()> { terminal.draw(|frame| { let area = frame.area(); let spinner_text = format!( " {} Inline Viewport ({} lines) {} ", spinner.current_frame(), viewport_height, spinner.current_frame() ); let block = Block::bordered() .title(spinner_text) .border_style(Style::default().fg(Color::Cyan)); frame.render_widget(block, area); })?; Ok(()) } fn main() -> Result<()> { const INITIAL_HEIGHT: u16 = 3; const MIN_HEIGHT: u16 = 2; let mut terminal = init_terminal(INITIAL_HEIGHT)?; let mut spinner = Spinner::new(); let mut viewport_height = INITIAL_HEIGHT; render_viewport(&mut terminal, &spinner, viewport_height)?; let colors = [ CColor::Green, CColor::Yellow, CColor::Magenta, CColor::Blue, CColor::Red, CColor::Cyan, ]; let mut line_num = 1; let mut scrollback_history = String::new(); // has crlf-s instead of lf-s loop { match poll_for_key_or_animation(&spinner)? { PollResult::AnimationTick => { spinner.tick(); render_viewport(&mut terminal, &spinner, viewport_height)?; } PollResult::Resize => { with_synchronized_output(&mut terminal, |terminal| { resize_purge_rerender(terminal, &scrollback_history)?; render_viewport(terminal, &spinner, viewport_height)?; Ok(()) })?; } PollResult::KeyPressed(action) => match action { KeyAction::Quit => break, KeyAction::IncreaseViewport | KeyAction::DecreaseViewport => { let new_viewport_height = if let KeyAction::IncreaseViewport = action { viewport_height + 1 } else { viewport_height.saturating_sub(1) } .clamp(MIN_HEIGHT, terminal.size()?.height.saturating_sub(1)); if new_viewport_height != viewport_height { viewport_height = new_viewport_height; with_synchronized_output(&mut terminal, |terminal| { resize_viewport_height(terminal, viewport_height)?; render_viewport(terminal, &spinner, viewport_height)?; Ok(()) })?; } } KeyAction::Tall | KeyAction::Normal => { let area = terminal.size()?; let (terminal_width, terminal_height) = (area.width as usize, area.height as usize); let content = match action { KeyAction::Tall => generate_tall_content(terminal_width, terminal_height), KeyAction::Normal => generate_test_line(line_num, terminal_width), _ => unreachable!(), }; let marked_line = build_marked_line(line_num, &content); let colored_content = colorize_content(&marked_line, colors[(line_num - 1) % colors.len()]); // Save to scrollback history, replace LF with CRLF if !scrollback_history.is_empty() { scrollback_history.push_str("\r\n"); } scrollback_history.push_str(&colored_content.replace('\n', "\r\n")); // Emit to scrollback and render viewport with_synchronized_output(&mut terminal, |terminal| { emit_to_scrollback(terminal, &colored_content)?; render_viewport(terminal, &spinner, viewport_height)?; Ok(()) })?; line_num += 1; } }, } } restore_terminal(&mut terminal)?; Ok(()) }