use crate::room::{ChatRoom, Message, MessageContent};
use chrono::Local;
use crossterm::{
event::{KeyCode, KeyEvent, KeyModifiers},
style::Color,
};
use std::sync::Arc;
const USERNAME_COLORS: [Color; 12] = [
Color::Rgb { r: 255, g: 107, b: 107 }, Color::Rgb { r: 78, g: 205, b: 196 }, Color::Rgb { r: 255, g: 217, b: 61 }, Color::Rgb { r: 199, g: 125, b: 255 }, Color::Rgb { r: 107, g: 185, b: 240 }, Color::Rgb { r: 255, g: 159, b: 67 }, Color::Rgb { r: 46, g: 213, b: 115 }, Color::Rgb { r: 255, g: 121, b: 198 }, Color::Rgb { r: 189, g: 147, b: 249 }, Color::Rgb { r: 139, g: 233, b: 253 }, Color::Rgb { r: 80, g: 250, b: 123 }, Color::Rgb { r: 255, g: 184, b: 108 }, ];
fn username_color(username: &str) -> Color {
let hash: usize = username.bytes().map(|b| b as usize).sum();
USERNAME_COLORS[hash % USERNAME_COLORS.len()]
}
pub struct ChatTui {
size: (u16, u16),
input: String,
cursor_pos: usize,
scroll_offset: usize,
room: Arc<ChatRoom>,
session_id: u64,
username: String,
}
impl ChatTui {
pub fn new(room: Arc<ChatRoom>, session_id: u64, username: String, cols: u16, rows: u16) -> Self {
Self {
size: (cols, rows),
input: String::new(),
cursor_pos: 0,
scroll_offset: 0,
room,
session_id,
username,
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.size = (cols, rows);
}
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
}
pub async fn render(&self) -> String {
let (cols, rows) = self.size;
let mut output = String::new();
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;
output.push_str(&format!("\x1b[2J\x1b[H"));
let room_name = &self.room.name;
let user_count = self.room.user_count().await;
let tunnel_url = self.room.tunnel_url.read().await;
let header_left = format!(" 📜 {} ", room_name);
let header_right = format!(" {} online ", user_count);
let tunnel_info = tunnel_url
.as_ref()
.map(|u| format!(" 🔗 {} ", u))
.unwrap_or_default();
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"); output.push_str(&" ".repeat(cols as usize));
output.push_str(&format!("\x1b[{};{}H", header_row + 1, 1));
output.push_str(&header_left);
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"); output.push_str(&tunnel_info);
}
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"); output.push_str(&header_right);
output.push_str("\x1b[0m");
let messages = self.room.get_messages().await;
let visible_messages: Vec<_> = messages
.iter()
.rev()
.skip(self.scroll_offset)
.take(messages_height)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let start_row = if visible_messages.len() < messages_height {
messages_end - visible_messages.len() as u16
} else {
messages_start
};
for (i, msg) in visible_messages.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(&self.format_message(msg, cols as usize));
}
output.push_str(&format!("\x1b[{};{}H", input_sep_row + 1, 1));
output.push_str("\x1b[38;2;68;71;90m"); output.push_str(&"─".repeat(cols as usize));
output.push_str("\x1b[0m");
output.push_str(&format!("\x1b[{};{}H", input_row + 1, 1));
output.push_str("\x1b[38;2;189;147;249m"); 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);
let cursor_x = prompt_len + self.cursor_pos.min(input_width);
output.push_str(&format!("\x1b[{};{}H", input_row + 1, cursor_x + 1));
output
}
fn format_message(&self, msg: &Message, width: usize) -> 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),
};
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;
let available = width.saturating_sub(prefix_display_len);
let truncated_text: String = text.chars().take(available).collect();
format!("{}{}", prefix, truncated_text)
}
MessageContent::System(text) => {
let available = width.saturating_sub(10);
let truncated_text: String = text.chars().take(available).collect();
format!(
"\x1b[38;2;98;114;164m[{}] *** {} ***\x1b[0m",
timestamp, truncated_text
)
}
}
}
}
pub async fn render_frame(tui: &ChatTui) -> Vec<u8> {
tui.render().await.into_bytes()
}