//! Terminal User Interface using ratatui use crate::demo::DemoClient; use crate::telegram::TelegramClient; use anyhow::Result; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, Terminal, }; use std::io; /// Application mode #[derive(Debug, Clone, Copy, PartialEq)] enum AppMode { Normal, // Navigation mode Input, // Message input mode Quitting, // Confirming quit } /// Chat message for display #[derive(Debug, Clone)] struct Message { text: String, is_outgoing: bool, timestamp: String, } /// Chat item for the list #[derive(Debug, Clone)] struct ChatItem { name: String, last_message: Option, unread_count: u32, chat_type: String, } pub struct App { chats: Vec, messages: Vec, chat_list_state: ListState, message_input: String, current_chat: Option, current_chat_index: Option, input_cursor: usize, mode: AppMode, status: String, is_demo: bool, scroll_offset: usize, } impl App { fn new(is_demo: bool) -> Self { Self { chats: Vec::new(), messages: Vec::new(), chat_list_state: ListState::default(), message_input: String::new(), current_chat: None, current_chat_index: None, input_cursor: 0, mode: AppMode::Normal, status: String::from("Ready"), is_demo, scroll_offset: 0, } } fn selected_chat_index(&self) -> Option { self.chat_list_state.selected() } fn select_chat(&mut self, index: usize) { if index < self.chats.len() { self.chat_list_state.select(Some(index)); self.current_chat_index = Some(index); self.current_chat = Some(self.chats[index].name.clone()); self.messages.clear(); self.scroll_offset = 0; } } fn next_chat(&mut self) { if self.chats.is_empty() { return; } let i = match self.chat_list_state.selected() { Some(i) => { if i >= self.chats.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.select_chat(i); } fn previous_chat(&mut self) { if self.chats.is_empty() { return; } let i = match self.chat_list_state.selected() { Some(i) => { if i == 0 { self.chats.len() - 1 } else { i - 1 } } None => 0, }; self.select_chat(i); } fn scroll_messages(&mut self, delta: i32) { if delta > 0 { self.scroll_offset = self.scroll_offset.saturating_add(delta as usize); } else { self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize); } } fn start_input_mode(&mut self) { if self.current_chat.is_some() { self.mode = AppMode::Input; self.message_input.clear(); self.input_cursor = 0; } } fn stop_input_mode(&mut self) { self.mode = AppMode::Normal; } fn send_message(&mut self) -> Option { if !self.message_input.is_empty() { let msg = self.message_input.clone(); self.message_input.clear(); self.input_cursor = 0; self.stop_input_mode(); return Some(msg); } None } fn insert_char(&mut self, c: char) { self.message_input.insert(self.input_cursor, c); self.input_cursor += 1; } fn delete_char(&mut self) { if self.input_cursor > 0 { self.input_cursor -= 1; self.message_input.remove(self.input_cursor); } } fn move_cursor_left(&mut self) { if self.input_cursor > 0 { self.input_cursor -= 1; } } fn move_cursor_right(&mut self) { if self.input_cursor < self.message_input.len() { self.input_cursor += 1; } } } pub async fn run_tui(client: &mut TelegramClient) -> Result<()> { run_tui_internal(Some(client), None).await } pub async fn run_tui_demo(demo_client: &DemoClient) -> Result<()> { run_tui_internal(None, Some(demo_client)).await } async fn run_tui_internal( telegram_client: Option<&mut TelegramClient>, demo_client: Option<&DemoClient>, ) -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let is_demo = demo_client.is_some(); let mut app = App::new(is_demo); // Load initial data if is_demo { if let Some(demo) = demo_client { app.status = String::from("Demo Mode"); app.chats = demo .list_chats_display() .into_iter() .map(|(name, last, unread, chat_type)| ChatItem { name, last_message: last, unread_count: unread, chat_type, }) .collect(); if !app.chats.is_empty() { app.select_chat(0); // Load demo messages for first chat app.messages = demo .get_messages_display() .into_iter() .map(|(text, is_outgoing, timestamp)| Message { text, is_outgoing, timestamp, }) .collect(); } } } else if let Some(ref client) = telegram_client { match client.is_authorized().await { Ok(true) => { app.status = String::from("Authorized"); match client.list_chats(50).await { Ok(chats) => { app.chats = chats .iter() .map(|peer| { let name = get_peer_name(peer); ChatItem { name: name.clone(), last_message: Some(name), unread_count: 0, chat_type: get_chat_type(peer), } }) .collect(); if !app.chats.is_empty() { app.select_chat(0); } } Err(e) => { app.status = format!("Error: {}", e); } } } Ok(false) => { app.status = String::from("Not authorized"); app.messages.push(Message { text: String::from("Not logged in. Press 'q' to exit and use 'telecli login'."), is_outgoing: false, timestamp: String::new(), }); } Err(e) => { app.status = String::from("Error"); app.messages.push(Message { text: format!("Error checking auth: {}", e), is_outgoing: false, timestamp: String::new(), }); } } } // Run UI loop let res = run_app(&mut terminal, &mut app, telegram_client, demo_client).await; // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; if let Err(err) = res { eprintln!("Error: {:?}", err); } Ok(()) } async fn run_app( terminal: &mut Terminal, app: &mut App, mut telegram_client: Option<&mut TelegramClient>, demo_client: Option<&DemoClient>, ) -> Result<()> { loop { terminal.draw(|f| ui(f, app))?; if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { match app.mode { AppMode::Normal => match key.code { KeyCode::Char('q') => { app.mode = AppMode::Quitting; } KeyCode::Char('Q') => { // Force quit break; } KeyCode::Down | KeyCode::Char('j') => { app.next_chat(); // Load messages for new chat in demo mode if let Some(demo) = demo_client { app.messages = demo .get_messages_display() .into_iter() .map(|(text, is_outgoing, timestamp)| Message { text, is_outgoing, timestamp, }) .collect(); } } KeyCode::Up | KeyCode::Char('k') => { app.previous_chat(); // Load messages for new chat in demo mode if let Some(demo) = demo_client { app.messages = demo .get_messages_display() .into_iter() .map(|(text, is_outgoing, timestamp)| Message { text, is_outgoing, timestamp, }) .collect(); } } KeyCode::Char('i') | KeyCode::Enter => { app.start_input_mode(); } KeyCode::Char('r') => { // Refresh app.status = String::from("Refreshing..."); if let Some(ref mut client) = telegram_client { match client.list_chats(50).await { Ok(chats) => { app.chats = chats .iter() .map(|peer| { let name = get_peer_name(peer); ChatItem { name: name.clone(), last_message: Some(name), unread_count: 0, chat_type: get_chat_type(peer), } }) .collect(); app.status = String::from("Refreshed"); } Err(e) => { app.status = format!("Refresh failed: {}", e); } } } else if let Some(demo) = demo_client { app.chats = demo .list_chats_display() .into_iter() .map(|(name, last, unread, chat_type)| ChatItem { name, last_message: last, unread_count: unread, chat_type, }) .collect(); app.status = String::from("Refreshed (Demo)"); } } KeyCode::Char('s') | KeyCode::PageDown => { app.scroll_messages(10); } KeyCode::Char('w') | KeyCode::PageUp => { app.scroll_messages(-10); } KeyCode::Char('?') => { app.status = String::from("j/k:nav | i:input | q:quit | r:refresh | s/w:scroll"); } _ => {} }, AppMode::Input => match key.code { KeyCode::Esc => { app.stop_input_mode(); } KeyCode::Enter => { if let Some(msg) = app.send_message() { // Send the message if let Some(idx) = app.current_chat_index { let chat_name = app.chats[idx].name.clone(); if let Some(ref mut _client) = telegram_client { // For real Telegram, we'd need the Peer object // For now, just add to local display app.messages.push(Message { text: msg.clone(), is_outgoing: true, timestamp: chrono::Local::now() .format("%H:%M") .to_string(), }); app.status = format!("Sent to {}", chat_name); } else if let Some(_demo) = demo_client { // Demo mode - just add to display app.messages.push(Message { text: msg, is_outgoing: true, timestamp: chrono::Local::now() .format("%H:%M") .to_string(), }); app.status = String::from("Sent (Demo)"); } } } } KeyCode::Char(c) => { app.insert_char(c); } KeyCode::Backspace => { app.delete_char(); } KeyCode::Left => { app.move_cursor_left(); } KeyCode::Right => { app.move_cursor_right(); } KeyCode::Home => { app.input_cursor = 0; } KeyCode::End => { app.input_cursor = app.message_input.len(); } _ => {} }, AppMode::Quitting => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { break; } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { app.mode = AppMode::Normal; app.status = String::from("Ready"); } _ => {} }, } } } } Ok(()) } fn ui(f: &mut Frame, app: &App) { let area = f.area(); // Main layout: chat list + messages + input let chunks = Layout::default() .direction(Direction::Horizontal) .margin(0) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(area); // Left panel: Chat list let chat_list_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(chunks[0]); // Chat list let chat_items: Vec = app .chats .iter() .enumerate() .map(|(i, chat)| { let icon = match chat.chat_type.as_str() { "User" => "👤", "Group" => "👥", "Channel" => "📢", _ => "💬", }; let unread_badge = if chat.unread_count > 0 { format!(" 🔴({})", chat.unread_count) } else { String::new() }; let last_msg = chat .last_message .as_ref() .map(|s| { if s.len() > 25 { format!("{}...", &s[..22]) } else { s.clone() } }) .unwrap_or_default(); let content = if i == app.selected_chat_index().unwrap_or(usize::MAX) { format!( "{} {}{}{}", icon, chat.name, unread_badge, if !last_msg.is_empty() { format!("\n └─ {}", last_msg) } else { String::new() } ) } else { format!(" {} {}{}", icon, chat.name, unread_badge) }; ListItem::new(Line::from(content)) }) .collect(); let chat_list = List::new(chat_items) .block( Block::default() .borders(Borders::ALL) .title("📱 Chats") .border_style(Style::default().fg(Color::Cyan)), ) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol("▶ "); f.render_stateful_widget( chat_list, chat_list_layout[0], &mut app.chat_list_state.clone(), ); // Help bar let help_text = if app.is_demo { "Demo Mode | j/k:nav | i:input | q:quit | r:refresh" } else { "j/k:nav | i:input | q:quit | r:refresh | s/w:scroll" }; let help = Paragraph::new(help_text) .style(Style::default().fg(Color::DarkGray)) .block( Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray)), ); f.render_widget(help, chat_list_layout[1]); // Right panel: Messages + Input let right_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(chunks[1]); // Messages area let chat_title = app .current_chat .as_ref() .map(|s| format!("💬 {}", s)) .unwrap_or_else(|| String::from("Select a chat")); let messages: Vec = app .messages .iter() .skip(app.scroll_offset) .map(|msg| { let style = if msg.is_outgoing { Style::default().fg(Color::Green) } else { Style::default().fg(Color::White) }; let prefix = if msg.is_outgoing { "→ " } else { "← " }; let timestamp = if !msg.timestamp.is_empty() { format!(" [{}]", msg.timestamp) } else { String::new() }; Line::from(Span::styled( format!("{}{}{}", prefix, msg.text, timestamp), style, )) }) .collect(); let messages_widget = Paragraph::new(messages) .block( Block::default() .borders(Borders::ALL) .title(chat_title) .border_style(Style::default().fg(Color::Yellow)), ) .wrap(Wrap { trim: true }); f.render_widget(messages_widget, right_layout[0]); // Input area let input_title = match app.mode { AppMode::Input => "✏️ Message (Enter to send, Esc to cancel)", AppMode::Quitting => "❓ Quit? (y/n)", _ => "💬 Press 'i' to compose message", }; let input_text = if app.mode == AppMode::Quitting { String::from("Are you sure you want to quit?") } else { app.message_input.clone() }; let input_widget = Paragraph::new(input_text) .block( Block::default() .borders(Borders::ALL) .title(input_title) .border_style(if app.mode == AppMode::Input { Style::default().fg(Color::Green) } else { Style::default().fg(Color::DarkGray) }), ) .style(Style::default().fg(if app.mode == AppMode::Input { Color::Yellow } else { Color::DarkGray })); f.render_widget(input_widget, right_layout[1]); // Show cursor in input mode if app.mode == AppMode::Input { let input_area = right_layout[1]; let cursor_x = input_area.x + 1 + app.input_cursor as u16; let cursor_y = input_area.y + 1; if cursor_x < input_area.x + input_area.width - 1 { f.set_cursor_position((cursor_x, cursor_y)); } } // Status bar at bottom let status_bar = Paragraph::new(format!(" {} | Mode: {:?}", app.status, app.mode)) .style(Style::default().fg(Color::White).bg(Color::Blue)); let status_area = Rect { x: 0, y: area.height - 1, width: area.width, height: 1, }; f.render_widget(status_bar, status_area); } fn get_peer_name(peer: &grammers_client::peer::Peer) -> String { match peer { grammers_client::peer::Peer::User(u) => { let mut name = String::new(); if let Some(first) = u.first_name() { name.push_str(first); } if let Some(last) = u.last_name() { if !name.is_empty() { name.push(' '); } name.push_str(last); } if name.is_empty() { format!("User #{}", u.id().bare_id()) } else { name } } grammers_client::peer::Peer::Group(g) => g.title().unwrap_or("Unnamed Group").to_string(), grammers_client::peer::Peer::Channel(c) => c.title().to_string(), } } fn get_chat_type(peer: &grammers_client::peer::Peer) -> String { match peer { grammers_client::peer::Peer::User(_) => String::from("User"), grammers_client::peer::Peer::Group(_) => String::from("Group"), grammers_client::peer::Peer::Channel(_) => String::from("Channel"), } }