scroll-chat 0.2.0

A secure terminal chat over SSH - host or join chatrooms with end-to-end encryption
//! TUI rendering for the chat interface

use crate::room::{ChatRoom, Message, MessageContent};
use chrono::Local;
use crossterm::{
    event::{KeyCode, KeyEvent, KeyModifiers},
    style::Color,
};
use std::sync::Arc;

/// Color palette for usernames
const USERNAME_COLORS: [Color; 12] = [
    Color::Rgb { r: 255, g: 107, b: 107 }, // Red
    Color::Rgb { r: 78, g: 205, b: 196 },  // Cyan
    Color::Rgb { r: 255, g: 217, b: 61 },  // Yellow
    Color::Rgb { r: 199, g: 125, b: 255 }, // Purple
    Color::Rgb { r: 107, g: 185, b: 240 }, // Blue
    Color::Rgb { r: 255, g: 159, b: 67 },  // Orange
    Color::Rgb { r: 46, g: 213, b: 115 },  // Green
    Color::Rgb { r: 255, g: 121, b: 198 }, // Pink
    Color::Rgb { r: 189, g: 147, b: 249 }, // Light purple
    Color::Rgb { r: 139, g: 233, b: 253 }, // Light cyan
    Color::Rgb { r: 80, g: 250, b: 123 },  // Light green
    Color::Rgb { r: 255, g: 184, b: 108 }, // Peach
];

/// Get a consistent color for a username
fn username_color(username: &str) -> Color {
    let hash: usize = username.bytes().map(|b| b as usize).sum();
    USERNAME_COLORS[hash % USERNAME_COLORS.len()]
}

/// TUI state
pub struct ChatTui {
    /// Terminal size (cols, rows)
    size: (u16, u16),
    /// Input buffer
    input: String,
    /// Cursor position in input
    cursor_pos: usize,
    /// Message scroll offset
    scroll_offset: usize,
    /// Room reference
    room: Arc<ChatRoom>,
    /// Username
    username: String,
    /// Cached messages (updated on room events)
    cached_messages: Vec<Message>,
    /// Cached user count
    cached_user_count: usize,
    /// Cached tunnel URL
    cached_tunnel_url: Option<String>,
}

impl ChatTui {
    pub fn new(room: Arc<ChatRoom>, username: String, cols: u16, rows: u16) -> Self {
        Self {
            size: (cols, rows),
            input: String::new(),
            cursor_pos: 0,
            scroll_offset: 0,
            room,
            username,
            cached_messages: Vec::new(),
            cached_user_count: 1,
            cached_tunnel_url: None,
        }
    }

    /// Process a key event
    pub async fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
        match (key.modifiers, key.code) {
            (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
                return Some("__QUIT__".to_string());
            }
            (_, KeyCode::Enter) => {
                if !self.input.trim().is_empty() {
                    let msg = self.input.clone();
                    self.input.clear();
                    self.cursor_pos = 0;
                    return Some(msg);
                }
            }
            (_, KeyCode::Char(c)) => {
                self.input.insert(self.cursor_pos, c);
                self.cursor_pos += 1;
            }
            (_, KeyCode::Backspace) => {
                if self.cursor_pos > 0 {
                    self.cursor_pos -= 1;
                    self.input.remove(self.cursor_pos);
                }
            }
            (_, KeyCode::Delete) => {
                if self.cursor_pos < self.input.len() {
                    self.input.remove(self.cursor_pos);
                }
            }
            (_, KeyCode::Left) => {
                if self.cursor_pos > 0 {
                    self.cursor_pos -= 1;
                }
            }
            (_, KeyCode::Right) => {
                if self.cursor_pos < self.input.len() {
                    self.cursor_pos += 1;
                }
            }
            (_, KeyCode::Home) => {
                self.cursor_pos = 0;
            }
            (_, KeyCode::End) => {
                self.cursor_pos = self.input.len();
            }
            (_, KeyCode::PageUp) => {
                self.scroll_offset = self.scroll_offset.saturating_add(5);
            }
            (_, KeyCode::PageDown) => {
                self.scroll_offset = self.scroll_offset.saturating_sub(5);
            }
            _ => {}
        }
        None
    }

    /// Refresh cached data from room
    pub async fn refresh_cache(&mut self) {
        self.cached_messages = self.room.get_messages().await;
        self.cached_user_count = self.room.user_count().await;
        self.cached_tunnel_url = self.room.tunnel_url.read().await.clone();
    }

    /// Render the TUI to a string buffer (ANSI escape codes)
    pub fn render(&self) -> String {
        let (cols, rows) = self.size;
        let mut output = String::new();

        // Layout:
        // Row 0: Header
        // Row 1 to (rows-3): Messages
        // Row (rows-2): Input separator
        // Row (rows-1): Input line

        let header_row = 0u16;
        let messages_start = 1u16;
        let messages_end = rows.saturating_sub(3);
        let input_sep_row = rows.saturating_sub(2);
        let input_row = rows.saturating_sub(1);
        let messages_height = messages_end.saturating_sub(messages_start) as usize;

        // Clear screen
        output.push_str("\x1b[2J\x1b[H");

        // ===== HEADER =====
        let room_name = &self.room.name;
        let user_count = self.cached_user_count;
        
        let header_left = format!(" 📜 {} ", room_name);
        let header_right = format!(" {} online ", user_count);
        let tunnel_info = self.cached_tunnel_url
            .as_ref()
            .map(|u| format!(" 🔗 {} ", u))
            .unwrap_or_default();

        // Header background
        output.push_str(&format!("\x1b[{};{}H", header_row + 1, 1));
        output.push_str("\x1b[48;2;40;42;54m\x1b[38;2;255;255;255m"); // Dark bg, white fg
        output.push_str(&" ".repeat(cols as usize));
        output.push_str(&format!("\x1b[{};{}H", header_row + 1, 1));
        output.push_str(&header_left);
        
        // Tunnel URL in middle (if available)
        if !tunnel_info.is_empty() {
            let tunnel_pos = (cols as usize / 2).saturating_sub(tunnel_info.len() / 2);
            output.push_str(&format!("\x1b[{};{}H", header_row + 1, tunnel_pos + 1));
            output.push_str("\x1b[38;2;139;233;253m"); // Cyan
            output.push_str(&tunnel_info);
        }
        
        // User count on right
        let right_pos = (cols as usize).saturating_sub(header_right.len());
        output.push_str(&format!("\x1b[{};{}H", header_row + 1, right_pos + 1));
        output.push_str("\x1b[38;2;80;250;123m"); // Green
        output.push_str(&header_right);
        output.push_str("\x1b[0m");

        // ===== MESSAGES =====
        // Build a list of all message lines (with wrapping) in chronological order
        let mut all_lines: Vec<(String, bool)> = Vec::new(); // (line_content, is_first_line_of_message)
        
        // Process messages in chronological order (oldest first)
        for msg in self.cached_messages.iter() {
            let wrapped_lines = self.format_message(msg, cols as usize);
            for (i, line) in wrapped_lines.iter().enumerate() {
                all_lines.push((line.clone(), i == 0));
            }
        }
        
        // Calculate which lines to show
        // scroll_offset = 0 means show newest messages (at bottom)
        // Higher scroll_offset means scroll up to see older messages
        let total_lines = all_lines.len();
        let skip_lines = if total_lines > messages_height {
            total_lines.saturating_sub(messages_height + self.scroll_offset)
        } else {
            0
        };
        
        let visible_lines: Vec<_> = all_lines
            .iter()
            .skip(skip_lines)
            .take(messages_height)
            .collect();
        
        // Always render from messages_start (top of message area)
        let start_row = messages_start;

        for (i, (line, _)) in visible_lines.iter().enumerate() {
            let row = start_row + i as u16;
            if row >= messages_end {
                break;
            }
            output.push_str(&format!("\x1b[{};{}H", row + 1, 1));
            output.push_str(line);
        }

        // ===== INPUT SEPARATOR =====
        output.push_str(&format!("\x1b[{};{}H", input_sep_row + 1, 1));
        output.push_str("\x1b[38;2;68;71;90m"); // Gray
        output.push_str(&"─".repeat(cols as usize));
        output.push_str("\x1b[0m");

        // ===== INPUT LINE =====
        output.push_str(&format!("\x1b[{};{}H", input_row + 1, 1));
        output.push_str("\x1b[38;2;189;147;249m"); // Purple
        output.push_str(&format!("{} > ", self.username));
        output.push_str("\x1b[0m");
        
        let prompt_len = self.username.len() + 3;
        let input_width = (cols as usize).saturating_sub(prompt_len);
        let display_input: String = self.input.chars().take(input_width).collect();
        output.push_str(&display_input);

        // Position cursor
        let cursor_x = prompt_len + self.cursor_pos.min(input_width);
        output.push_str(&format!("\x1b[{};{}H", input_row + 1, cursor_x + 1));

        output
    }

    /// Format a single message for display, returning wrapped lines
    fn format_message(&self, msg: &Message, width: usize) -> Vec<String> {
        let timestamp = msg.timestamp.with_timezone(&Local).format("%H:%M");
        
        match &msg.content {
            MessageContent::Text(text) => {
                let color = username_color(&msg.username);
                let (r, g, b) = match color {
                    Color::Rgb { r, g, b } => (r, g, b),
                    _ => (255, 255, 255),
                };
                
                // Format: [HH:MM] username: message
                let prefix = format!(
                    "\x1b[38;2;98;114;164m[{}]\x1b[0m \x1b[38;2;{};{};{}m{}\x1b[0m: ",
                    timestamp, r, g, b, msg.username
                );
                let prefix_display_len = 8 + msg.username.len() + 2; // [HH:MM] + username + ": "
                
                let available = width.saturating_sub(prefix_display_len);
                
                // Wrap text to multiple lines
                let wrapped_lines = self.wrap_text(text, available);
                let mut result = Vec::new();
                
                for (i, line) in wrapped_lines.iter().enumerate() {
                    if i == 0 {
                        // First line includes prefix
                        result.push(format!("{}{}", prefix, line));
                    } else {
                        // Subsequent lines are indented to align with message content
                        let indent = " ".repeat(prefix_display_len);
                        result.push(format!("{}{}", indent, line));
                    }
                }
                
                result
            }
            MessageContent::System(text) => {
                // System messages in gray/italic
                // Format: [HH:MM] *** text ***
                // Display lengths: [HH:MM] = 8, " *** " = 5, " ***" = 4
                let prefix = format!(
                    "\x1b[38;2;98;114;164m[{}] ***\x1b[0m ",
                    timestamp
                );
                let suffix = "\x1b[38;2;98;114;164m ***\x1b[0m";
                let prefix_display_len = 8 + 5; // [HH:MM] + " *** "
                let suffix_display_len = 4; // " ***"
                let available = width.saturating_sub(prefix_display_len + suffix_display_len);
                
                let wrapped_lines = self.wrap_text(text, available);
                let mut result = Vec::new();
                
                for (i, line) in wrapped_lines.iter().enumerate() {
                    if i == 0 {
                        result.push(format!("{}{}{}", prefix, line, suffix));
                    } else {
                        let indent = " ".repeat(prefix_display_len);
                        result.push(format!("{}{}{}", indent, line, suffix));
                    }
                }
                
                result
            }
        }
    }
    
    /// Wrap text to fit within a given width, breaking on word boundaries when possible
    fn wrap_text(&self, text: &str, width: usize) -> Vec<String> {
        if width == 0 {
            return vec![String::new()];
        }
        
        let mut lines = Vec::new();
        let mut current_line = String::new();
        
        // Split by whitespace to preserve word boundaries
        let words: Vec<&str> = text.split_whitespace().collect();
        
        if words.is_empty() {
            return vec![String::new()];
        }
        
        for word in words {
            let word_len = word.chars().count();
            let current_len = current_line.chars().count();
            
            if current_line.is_empty() {
                // First word on line
                if word_len <= width {
                    current_line.push_str(word);
                } else {
                    // Word is longer than width, break it character by character
                    for ch in word.chars() {
                        if current_line.chars().count() >= width {
                            lines.push(current_line.clone());
                            current_line.clear();
                        }
                        current_line.push(ch);
                    }
                }
            } else {
                // Check if adding this word would exceed width
                if current_len + 1 + word_len <= width {
                    // Add space and word
                    current_line.push(' ');
                    current_line.push_str(word);
                } else {
                    // Current line is full, start new line
                    lines.push(current_line.clone());
                    current_line.clear();
                    
                    // Handle word that's longer than width
                    if word_len <= width {
                        current_line.push_str(word);
                    } else {
                        // Break long word character by character
                        for ch in word.chars() {
                            if current_line.chars().count() >= width {
                                lines.push(current_line.clone());
                                current_line.clear();
                            }
                            current_line.push(ch);
                        }
                    }
                }
            }
        }
        
        // Don't forget the last line
        if !current_line.is_empty() {
            lines.push(current_line);
        }
        
        // If no lines were created (empty input), return at least one empty line
        if lines.is_empty() {
            lines.push(String::new());
        }
        
        lines
    }
}

/// Render TUI to a byte buffer for sending over SSH
pub fn render_frame(tui: &ChatTui) -> Vec<u8> {
    tui.render().into_bytes()
}