This commit is contained in:
Mikhail Kilin
2026-01-18 14:49:31 +03:00
parent d701464fde
commit b6d9291864
29 changed files with 3920 additions and 833 deletions

136
src/ui/auth.rs Normal file
View File

@@ -0,0 +1,136 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::tdlib::client::AuthState;
pub fn render(f: &mut Frame, app: &App) {
let area = f.area();
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30),
Constraint::Length(15),
Constraint::Percentage(30),
])
.split(area);
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(vertical_chunks[1]);
let auth_area = horizontal_chunks[1];
let auth_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title
Constraint::Length(4), // Instructions
Constraint::Length(3), // Input
Constraint::Length(2), // Error/Status message
Constraint::Min(0), // Spacer
])
.split(auth_area);
// Title
let title = Paragraph::new("TTUI - Telegram Authentication")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, auth_chunks[0]);
// Instructions and Input based on auth state
match &app.td_client.auth_state {
AuthState::WaitPhoneNumber => {
let instructions = vec![
Line::from("Введите номер телефона в международном формате"),
Line::from("Пример: +79991111111"),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let input_text = format!("📱 {}", app.phone_input);
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Phone Number "),
);
f.render_widget(input, auth_chunks[2]);
}
AuthState::WaitCode => {
let instructions = vec![
Line::from("Введите код подтверждения из Telegram"),
Line::from("Код был отправлен на ваш номер"),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let input_text = format!("🔐 {}", app.code_input);
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Verification Code "),
);
f.render_widget(input, auth_chunks[2]);
}
AuthState::WaitPassword => {
let instructions = vec![
Line::from("Введите пароль двухфакторной аутентификации"),
Line::from(""),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let masked_password = "*".repeat(app.password_input.len());
let input_text = format!("🔒 {}", masked_password);
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Password "));
f.render_widget(input, auth_chunks[2]);
}
_ => {}
}
// Error or status message
if let Some(error) = &app.error_message {
let error_widget = Paragraph::new(error.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center);
f.render_widget(error_widget, auth_chunks[3]);
} else if let Some(status) = &app.status_message {
let status_widget = Paragraph::new(status.as_str())
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center);
f.render_widget(status_widget, auth_chunks[3]);
}
}

61
src/ui/chat_list.rs Normal file
View File

@@ -0,0 +1,61 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use crate::app::App;
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search box
Constraint::Min(0), // Chat list
Constraint::Length(3), // User status
])
.split(area);
// Search box
let search = Paragraph::new("🔍 Search...")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray));
f.render_widget(search, chat_chunks[0]);
// Chat list
let items: Vec<ListItem> = app
.chats
.iter()
.map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id);
let prefix = if is_selected { "" } else { " " };
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!("{}{}{}", prefix, chat.title, unread_badge);
let style = Style::default().fg(Color::White);
ListItem::new(content).style(style)
})
.collect();
let chats_list = List::new(items)
.block(Block::default().borders(Borders::ALL))
.highlight_style(
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(Color::Yellow),
);
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status
let status = Paragraph::new("[User: Online]")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Green));
f.render_widget(status, chat_chunks[2]);
}

30
src/ui/footer.rs Normal file
View File

@@ -0,0 +1,30 @@
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::Paragraph,
Frame,
};
use crate::app::App;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let status = if let Some(msg) = &app.status_message {
format!(" {} ", msg)
} else if let Some(err) = &app.error_message {
format!(" Error: {} ", err)
} else if app.selected_chat_id.is_some() {
" Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
} else {
" Cmd+j/k: Navigate | Ctrl+k: First | Enter: Open | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
};
let style = if app.error_message.is_some() {
Style::default().fg(Color::Red)
} else if app.status_message.is_some() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let footer = Paragraph::new(status).style(style);
f.render_widget(footer, area);
}

40
src/ui/loading.rs Normal file
View File

@@ -0,0 +1,40 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
pub fn render(f: &mut Frame, app: &App) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(5),
Constraint::Percentage(40),
])
.split(area);
let message = app
.status_message
.as_deref()
.unwrap_or("Загрузка...");
let loading = Paragraph::new(message)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" TTUI "),
);
f.render_widget(loading, chunks[1]);
}

62
src/ui/main_screen.rs Normal file
View File

@@ -0,0 +1,62 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use super::{chat_list, messages, footer};
pub fn render(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Folders/tabs
Constraint::Min(0), // Main content
Constraint::Length(1), // Commands footer
])
.split(f.area());
render_folders(f, chunks[0], app);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Chat list
Constraint::Percentage(70), // Messages area
])
.split(chunks[1]);
chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], app);
footer::render(f, chunks[2], app);
}
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![];
for (i, folder) in app.folders.iter().enumerate() {
let style = if i == app.selected_folder {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(format!(" {}:{} ", i + 1, folder), style));
if i < app.folders.len() - 1 {
spans.push(Span::raw(""));
}
}
let folders_line = Line::from(spans);
let folders_widget = Paragraph::new(folders_line).block(
Block::default()
.title(" TTUI ")
.borders(Borders::ALL),
);
f.render_widget(folders_widget, area);
}

116
src/ui/messages.rs Normal file
View File

@@ -0,0 +1,116 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::utils::format_timestamp;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
if let Some(chat) = app.get_selected_chat() {
let message_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Chat header
Constraint::Min(0), // Messages
Constraint::Length(3), // Input box
])
.split(area);
// Chat header
let header = Paragraph::new(format!("👤 {}", chat.title))
.block(Block::default().borders(Borders::ALL))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, message_chunks[0]);
// Messages
let mut lines: Vec<Line> = Vec::new();
for msg in &app.current_messages {
let sender_style = if msg.is_outgoing {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
let sender_name = if msg.is_outgoing {
"You".to_string()
} else {
msg.sender_name.clone()
};
let read_mark = if msg.is_outgoing {
if msg.is_read { " ✓✓" } else { "" }
} else {
""
};
// Форматируем время
let time = format_timestamp(msg.date);
lines.push(Line::from(vec![
Span::styled(format!("{} ", sender_name), sender_style),
Span::raw("── "),
Span::styled(format!("{}{}", time, read_mark), Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(msg.content.clone()));
lines.push(Line::from(""));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет сообщений",
Style::default().fg(Color::DarkGray),
)));
}
// Вычисляем скролл с учётом пользовательского offset
let visible_height = message_chunks[1].height.saturating_sub(2) as usize;
let total_lines = lines.len();
let base_scroll = if total_lines > visible_height {
total_lines - visible_height
} else {
0
};
let scroll_offset = base_scroll.saturating_sub(app.message_scroll_offset) as u16;
let messages_widget = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, message_chunks[1]);
// Input box
let input_text = if app.message_input.is_empty() {
"> Введите сообщение...".to_string()
} else {
format!("> {}", app.message_input)
};
let input_style = if app.message_input.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Yellow)
};
let input = Paragraph::new(input_text)
.block(Block::default().borders(Borders::ALL))
.style(input_style);
f.render_widget(input, message_chunks[2]);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
f.render_widget(empty, area);
}
}

View File

@@ -1,170 +1,17 @@
use crate::app::App;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
mod loading;
mod auth;
mod main_screen;
mod chat_list;
mod messages;
mod footer;
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
use ratatui::Frame;
use crate::app::{App, AppScreen};
draw_tabs(f, app, chunks[0]);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[1]);
draw_chat_list(f, app, main_chunks[0]);
draw_messages(f, app, main_chunks[1]);
draw_status_bar(f, app, chunks[2]);
}
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
let tabs: Vec<Span> = app
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
let style = if i == app.selected_tab {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Span::styled(format!(" {}:{} ", i + 1, t), style)
})
.collect();
let tabs_line = Line::from(tabs);
let tabs_paragraph = Paragraph::new(tabs_line).block(
Block::default()
.borders(Borders::ALL)
.title("Telegram TUI"),
);
f.render_widget(tabs_paragraph, area);
}
fn draw_chat_list(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let search = Paragraph::new(format!("🔍 {}", app.search_query))
.block(Block::default().borders(Borders::ALL));
f.render_widget(search, chunks[0]);
let items: Vec<ListItem> = app
.chats
.iter()
.enumerate()
.map(|(i, chat)| {
let pin_icon = if chat.is_pinned { "📌 " } else { " " };
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!("{}{}{}", pin_icon, chat.name, unread_badge);
let style = if Some(i) == app.selected_chat {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else if chat.unread_count > 0 {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL));
f.render_widget(list, chunks[1]);
}
fn draw_messages(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(area);
let header = Paragraph::new(app.get_current_chat_name()).block(
Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(Color::White)),
);
f.render_widget(header, chunks[0]);
let mut message_lines: Vec<Line> = vec![];
for msg in &app.messages {
message_lines.push(Line::from(""));
let time_and_name = if msg.is_outgoing {
let status = match msg.read_status {
2 => "✓✓",
1 => "",
_ => "",
};
format!("{} ────────────────────────────────────── {} {}",
msg.sender, msg.time, status)
} else {
format!("{} ──────────────────────────────────────── {}",
msg.sender, msg.time)
};
let style = if msg.is_outgoing {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Cyan)
};
message_lines.push(Line::from(Span::styled(time_and_name, style)));
message_lines.push(Line::from(msg.text.clone()));
pub fn render(f: &mut Frame, app: &mut App) {
match app.screen {
AppScreen::Loading => loading::render(f, app),
AppScreen::Auth => auth::render(f, app),
AppScreen::Main => main_screen::render(f, app),
}
let messages = Paragraph::new(message_lines)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::White));
f.render_widget(messages, chunks[1]);
let input = Paragraph::new(format!("> {}_", app.input))
.block(Block::default().borders(Borders::ALL));
f.render_widget(input, chunks[2]);
}
fn draw_status_bar(f: &mut Frame, _app: &App, area: Rect) {
let status_text = " Esc: Back | Enter: Open | ^R: Reply | ^E: Edit | ^D: Delete";
let status = Paragraph::new(status_text)
.style(Style::default().fg(Color::Gray))
.block(
Block::default()
.borders(Borders::TOP)
.title("[User: Online]"),
);
f.render_widget(status, area);
}