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>,
username: String,
cached_messages: Vec<Message>,
cached_user_count: usize,
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,
}
}
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 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();
}
pub 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("\x1b[2J\x1b[H");
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();
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 mut all_lines: Vec<(String, bool)> = Vec::new();
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));
}
}
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();
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);
}
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) -> 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),
};
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 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));
} else {
let indent = " ".repeat(prefix_display_len);
result.push(format!("{}{}", indent, line));
}
}
result
}
MessageContent::System(text) => {
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; 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
}
}
}
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();
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() {
if word_len <= width {
current_line.push_str(word);
} else {
for ch in word.chars() {
if current_line.chars().count() >= width {
lines.push(current_line.clone());
current_line.clear();
}
current_line.push(ch);
}
}
} else {
if current_len + 1 + word_len <= width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line.clone());
current_line.clear();
if word_len <= width {
current_line.push_str(word);
} else {
for ch in word.chars() {
if current_line.chars().count() >= width {
lines.push(current_line.clone());
current_line.clear();
}
current_line.push(ch);
}
}
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
}
pub fn render_frame(tui: &ChatTui) -> Vec<u8> {
tui.render().into_bytes()
}