diff --git a/cortex-tui/src/runner/login_screen.rs b/cortex-tui/src/runner/login_screen.rs index 695cb176..abcdf5b4 100644 --- a/cortex-tui/src/runner/login_screen.rs +++ b/cortex-tui/src/runner/login_screen.rs @@ -1,19 +1,20 @@ -//! Login Screen - Inline CLI +//! Login Screen - Full-screen TUI //! -//! Minimalist inline login screen with animation, no full-screen takeover. +//! Full-screen login screen using ratatui and alternate screen buffer for reliable +//! rendering across all terminal emulators. -use std::io::{Write, stdout}; +use std::io::stdout; use std::path::PathBuf; use std::time::{Duration, Instant}; use anyhow::Result; -use crossterm::{ - cursor, - event::{self, Event, KeyCode, KeyEvent}, - execute, queue, - style::{Color, Print, ResetColor, SetForegroundColor}, - terminal::{Clear, ClearType}, -}; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use tokio::sync::mpsc; use cortex_login::{SecureAuthData, save_auth_with_fallback}; @@ -25,19 +26,10 @@ use cortex_tui_components::spinner::SpinnerStyle; const API_BASE_URL: &str = "https://api.cortex.foundation"; const AUTH_BASE_URL: &str = "https://auth.cortex.foundation"; -const RENDER_LINES: u16 = 12; // Number of lines we use for rendering - -// Colors -const PRIMARY: Color = Color::Rgb { - r: 0x00, - g: 0xFF, - b: 0xA3, -}; -const DIM: Color = Color::Rgb { - r: 0x6b, - g: 0x6b, - b: 0x7b, -}; + +// Colors matching the original design +const PRIMARY: Color = Color::Rgb(0x00, 0xFF, 0xA3); +const DIM: Color = Color::Rgb(0x6b, 0x6b, 0x7b); const CYAN: Color = Color::Cyan; // ============================================================================ @@ -144,35 +136,37 @@ impl LoginScreen { } pub async fn run(&mut self) -> Result { - let mut stdout = stdout(); - - // Add 1 empty line to separate from previous output - println!(); - - // Print empty lines to make space for our render area - for _ in 0..RENDER_LINES { - println!(); - } - - // Move cursor back up to start of render area - execute!(stdout, cursor::MoveUp(RENDER_LINES))?; - + // Enter alternate screen mode for reliable rendering crossterm::terminal::enable_raw_mode()?; - execute!(stdout, cursor::Hide)?; + let mut stdout = stdout(); + crossterm::execute!( + stdout, + crossterm::terminal::EnterAlternateScreen, + crossterm::event::EnableMouseCapture, + )?; - init_panic_hook(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; - let result = self.run_loop(&mut stdout).await; + let result = self.run_loop(&mut terminal).await; - // Cleanup - execute!(stdout, cursor::Show)?; + // Cleanup - leave alternate screen crossterm::terminal::disable_raw_mode()?; - println!(); + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::event::DisableMouseCapture, + )?; + terminal.show_cursor()?; result } - async fn run_loop(&mut self, stdout: &mut std::io::Stdout) -> Result { + async fn run_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result { // Flush any pending input events to prevent stale keypresses while event::poll(Duration::from_millis(0))? { let _ = event::read()?; @@ -197,7 +191,7 @@ impl LoginScreen { } // Render - self.render(stdout)?; + terminal.draw(|f| self.render(f))?; // Check async messages self.process_async_messages(); @@ -208,8 +202,6 @@ impl LoginScreen { // Filter to only handle key press events (not release) if key.kind == crossterm::event::KeyEventKind::Press { if let Some(result) = self.handle_key(key) { - // Move cursor to end of render area before returning - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; return Ok(result); } } @@ -218,15 +210,12 @@ impl LoginScreen { match self.state { LoginState::Success => { - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; return Ok(LoginResult::LoggedIn); } LoginState::Exit => { - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; return Ok(LoginResult::Exit); } LoginState::Failed => { - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; let msg = self.error_message.clone().unwrap_or_default(); return Ok(LoginResult::Failed(msg)); } @@ -235,127 +224,113 @@ impl LoginScreen { } } - fn render(&self, stdout: &mut std::io::Stdout) -> Result<()> { + fn render(&self, f: &mut ratatui::Frame) { + let area = f.area(); + f.render_widget(Clear, area); + match self.state { - LoginState::SelectMethod => self.render_select_method(stdout)?, - LoginState::WaitingForAuth => self.render_waiting(stdout)?, + LoginState::SelectMethod => self.render_select_method(f, area), + LoginState::WaitingForAuth => self.render_waiting(f, area), _ => {} } - - stdout.flush()?; - - // Move cursor back up to start for next frame - execute!(stdout, cursor::MoveUp(RENDER_LINES))?; - - Ok(()) } - fn render_select_method(&self, stdout: &mut std::io::Stdout) -> Result<()> { + fn render_select_method(&self, f: &mut ratatui::Frame, area: Rect) { let version = env!("CARGO_PKG_VERSION"); - // Line 1: Separator line - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("────────────────────────────────────────────────────────────"), - ResetColor, - Print("\n") - )?; + // Center the content + let content_width = 70.min(area.width.saturating_sub(4)); + let content_height = 14; + let content_x = (area.width.saturating_sub(content_width)) / 2; + let content_y = (area.height.saturating_sub(content_height)) / 2; + let content_area = Rect::new(content_x, content_y, content_width, content_height); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Separator + Constraint::Length(1), // Welcome message + Constraint::Length(1), // Description + Constraint::Length(1), // Empty + Constraint::Length(1), // Select header + Constraint::Length(1), // Empty + Constraint::Length(3), // Method options + Constraint::Length(1), // Empty + Constraint::Length(1), // Hints + Constraint::Length(1), // Error message (if any) + ]) + .split(content_area); + + // Line 1: Separator + let separator = + Paragraph::new("────────────────────────────────────────────────────────────") + .style(Style::default().fg(DIM)); + f.render_widget(separator, chunks[0]); // Line 2: Welcome message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print("Welcome to Cortex CLI"), - SetForegroundColor(DIM), - Print(format!(" v{}", version)), - ResetColor, - Print("\n") - )?; + let welcome = Paragraph::new(Line::from(vec![ + Span::styled( + "Welcome to Cortex CLI", + Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" v{}", version), Style::default().fg(DIM)), + ])); + f.render_widget(welcome, chunks[1]); // Line 3: Description - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("Cortex can be used with your Cortex Foundation account or API key."), - ResetColor, - Print("\n") - )?; - - // Line 4: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + let description = + Paragraph::new("Cortex can be used with your Cortex Foundation account or API key.") + .style(Style::default().fg(DIM)); + f.render_widget(description, chunks[2]); // Line 5: Select header - queue!( - stdout, - Clear(ClearType::CurrentLine), - Print(" Select login method:"), - Print("\n") - )?; - - // Line 6: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + let header = Paragraph::new(" Select login method:"); + f.render_widget(header, chunks[4]); // Lines 7-9: Method options + let mut lines: Vec = Vec::new(); for (i, method) in LoginMethod::all().iter().enumerate() { let is_selected = i == self.selected_method; let prefix = if is_selected { " › " } else { " " }; - let color = if is_selected { PRIMARY } else { DIM }; - let desc = method.description(); - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(color) - )?; - - if desc.is_empty() { - // Exit option - no description - queue!( - stdout, - Print(format!("{}{}. ", prefix, i + 1)), - SetForegroundColor(if is_selected { PRIMARY } else { Color::White }), - Print(method.label()), - ResetColor, - Print("\n") - )?; - } else { - queue!( - stdout, - Print(format!("{}{}. ", prefix, i + 1)), - SetForegroundColor(if is_selected { PRIMARY } else { Color::White }), - Print(method.label()), - SetForegroundColor(DIM), - Print(format!(" · {}", desc)), - ResetColor, - Print("\n") - )?; + let mut spans = vec![ + Span::styled( + format!("{}{}. ", prefix, i + 1), + Style::default().fg(if is_selected { PRIMARY } else { DIM }), + ), + Span::styled( + method.label(), + Style::default().fg(if is_selected { PRIMARY } else { Color::White }), + ), + ]; + + let desc = method.description(); + if !desc.is_empty() { + spans.push(Span::styled( + format!(" · {}", desc), + Style::default().fg(DIM), + )); } - } - // Line 10: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + lines.push(Line::from(spans)); + } + let options = Paragraph::new(lines); + f.render_widget(options, chunks[6]); // Line 11: Hints - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("↑↓ to select · Enter to confirm · Ctrl+C to exit"), - ResetColor, - Print("\n") - )?; - - // Line 12: padding - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; - - Ok(()) + let hints = Paragraph::new("↑↓ to select · Enter to confirm · Ctrl+C to exit") + .style(Style::default().fg(DIM)); + f.render_widget(hints, chunks[8]); + + // Line 12: Error message (if any) + if let Some(ref error) = self.error_message { + let error_msg = + Paragraph::new(format!("Error: {}", error)).style(Style::default().fg(Color::Red)); + f.render_widget(error_msg, chunks[9]); + } } - fn render_waiting(&self, stdout: &mut std::io::Stdout) -> Result<()> { + fn render_waiting(&self, f: &mut ratatui::Frame, area: Rect) { let version = env!("CARGO_PKG_VERSION"); let breathing = SpinnerStyle::Breathing.frames(); let spinner = breathing[(self.frame_count % breathing.len() as u64) as usize]; @@ -367,108 +342,85 @@ impl LoginScreen { format!("{}/device", AUTH_BASE_URL) }; - // Line 1: Welcome message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print("Welcome to Cortex CLI"), - SetForegroundColor(DIM), - Print(format!(" v{}", version)), - ResetColor, - Print("\n") - )?; + // Center the content + let content_width = 70.min(area.width.saturating_sub(4)); + let content_height = 14; + let content_x = (area.width.saturating_sub(content_width)) / 2; + let content_y = (area.height.saturating_sub(content_height)) / 2; + let content_area = Rect::new(content_x, content_y, content_width, content_height); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Welcome message + Constraint::Length(1), // Empty + Constraint::Length(1), // Mascot top + Constraint::Length(1), // Mascot + waiting message + Constraint::Length(1), // Mascot bottom + Constraint::Length(1), // Mascot legs + Constraint::Length(1), // Empty + Constraint::Length(1), // Browser message + Constraint::Length(1), // URL + Constraint::Length(1), // Empty + Constraint::Length(1), // Hints + ]) + .split(content_area); - // Line 2: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + // Line 1: Welcome message + let welcome = Paragraph::new(Line::from(vec![ + Span::styled( + "Welcome to Cortex CLI", + Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" v{}", version), Style::default().fg(DIM)), + ])); + f.render_widget(welcome, chunks[0]); // Line 3: Mascot top - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print(" ▄█▀▀▀▀█▄ "), - ResetColor, - Print("\n") - )?; - - // Line 4: Mascot + Waiting message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print("██ ▌ ▐ ██ "), - Print("Waiting for browser authentication "), - Print(spinner), - ResetColor, - Print("\n") - )?; + let mascot_top = Paragraph::new(" ▄█▀▀▀▀█▄ ").style(Style::default().fg(PRIMARY)); + f.render_widget(mascot_top, chunks[2]); + + // Line 4: Mascot + waiting message + let mascot_middle = Paragraph::new(Line::from(vec![ + Span::styled("██ ▌ ▐ ██ ", Style::default().fg(PRIMARY)), + Span::styled( + format!("Waiting for browser authentication {}", spinner), + Style::default().fg(PRIMARY), + ), + ])); + f.render_widget(mascot_middle, chunks[3]); // Line 5: Mascot bottom - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print(" █▄▄▄▄▄▄█ "), - ResetColor, - Print("\n") - )?; + let mascot_bottom = Paragraph::new(" █▄▄▄▄▄▄█ ").style(Style::default().fg(PRIMARY)); + f.render_widget(mascot_bottom, chunks[4]); // Line 6: Mascot legs - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print(" █ █"), - ResetColor, - Print("\n") - )?; + let mascot_legs = Paragraph::new(" █ █").style(Style::default().fg(PRIMARY)); + f.render_widget(mascot_legs, chunks[5]); - // Line 7: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; - - // Line 8: Browser didn't open message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("Browser didn't open? Click the URL below "), - Print(if self.copied_notification.is_some() { - "(✓ Copied!)" - } else { - "(c to copy)" - }), - ResetColor, - Print("\n") - )?; - - // Line 9: Direct URL - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(CYAN), - Print(&direct_url), - ResetColor, - Print("\n") - )?; - - // Line 10: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + // Line 8: Browser message + let copy_hint = if self.copied_notification.is_some() { + "(✓ Copied!)" + } else { + "(c to copy)" + }; + let browser_msg = Paragraph::new(Line::from(vec![ + Span::styled( + "Browser didn't open? Click the URL below ", + Style::default().fg(DIM), + ), + Span::styled(copy_hint, Style::default().fg(DIM)), + ])); + f.render_widget(browser_msg, chunks[7]); + + // Line 9: URL + let url_line = Paragraph::new(&*direct_url).style(Style::default().fg(CYAN)); + f.render_widget(url_line, chunks[8]); // Line 11: Hints - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("Esc to go back · Ctrl+C to exit"), - ResetColor, - Print("\n") - )?; - - // Line 12: padding - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; - - Ok(()) + let hints = + Paragraph::new("Esc to go back · Ctrl+C to exit").style(Style::default().fg(DIM)); + f.render_widget(hints, chunks[10]); } fn get_direct_url(&self) -> String { @@ -825,12 +777,3 @@ async fn poll_for_token_async( )) .await; } - -fn init_panic_hook() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let _ = crossterm::terminal::disable_raw_mode(); - let _ = execute!(std::io::stdout(), cursor::Show); - original_hook(panic_info); - })); -}