Split core and TUI crates
This commit is contained in:
137
crates/tele-tui/src/ui/auth.rs
Normal file
137
crates/tele-tui/src/ui/auth.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::AuthState;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
107
crates/tele-tui/src/ui/chat_list.rs
Normal file
107
crates/tele-tui/src/ui/chat_list.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Chat list panel: search box, chat items, and user online status.
|
||||
|
||||
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::tdlib::UserOnlineStatus;
|
||||
use crate::ui::components;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
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_text = if app.is_searching {
|
||||
if app.search_query.is_empty() {
|
||||
"🔍 Введите для поиска...".to_string()
|
||||
} else {
|
||||
format!("🔍 {}", app.search_query)
|
||||
}
|
||||
} else {
|
||||
"🔍 Ctrl+S для поиска".to_string()
|
||||
};
|
||||
let search_style = if app.is_searching {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Rgb(160, 160, 160))
|
||||
};
|
||||
let search = Paragraph::new(search_text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(search_style);
|
||||
f.render_widget(search, chat_chunks[0]);
|
||||
|
||||
// Chat list (filtered if searching)
|
||||
let filtered_chats = app.get_filtered_chats();
|
||||
let items: Vec<ListItem> = filtered_chats
|
||||
.iter()
|
||||
.map(|chat| {
|
||||
let is_selected = app.selected_chat_id == Some(chat.id);
|
||||
let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
|
||||
components::render_chat_list_item(chat, is_selected, user_status)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Заголовок блока: обычный или режим пересылки
|
||||
let block = if app.is_forwarding() {
|
||||
Block::default()
|
||||
.title(" ↪ Выберите чат ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
} else {
|
||||
Block::default().borders(Borders::ALL)
|
||||
};
|
||||
|
||||
let chats_list = List::new(items).block(block).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_chat_id = if app.selected_chat_id.is_some() {
|
||||
app.selected_chat_id
|
||||
} else {
|
||||
let filtered = app.get_filtered_chats();
|
||||
app.chat_list_state
|
||||
.selected()
|
||||
.and_then(|i| filtered.get(i).map(|c| c.id))
|
||||
};
|
||||
let (status_text, status_color) = match status_chat_id {
|
||||
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
|
||||
None => ("".to_string(), Color::DarkGray),
|
||||
};
|
||||
|
||||
let status = Paragraph::new(status_text)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(Style::default().fg(status_color));
|
||||
f.render_widget(status, chat_chunks[2]);
|
||||
}
|
||||
|
||||
/// Форматирует статус пользователя для отображения в статус-баре
|
||||
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
|
||||
match status {
|
||||
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
||||
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
|
||||
Some(UserOnlineStatus::Offline(was_online)) => {
|
||||
(crate::utils::format_was_online(*was_online), Color::Gray)
|
||||
}
|
||||
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
|
||||
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
|
||||
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
|
||||
None => ("".to_string(), Color::DarkGray),
|
||||
}
|
||||
}
|
||||
78
crates/tele-tui/src/ui/components/chat_list_item.rs
Normal file
78
crates/tele-tui/src/ui/components/chat_list_item.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use crate::tdlib::{ChatInfo, UserOnlineStatus};
|
||||
use ratatui::{
|
||||
style::{Color, Style},
|
||||
widgets::ListItem,
|
||||
};
|
||||
|
||||
/// Рендерит элемент списка чатов
|
||||
///
|
||||
/// # Параметры
|
||||
/// - `chat`: Информация о чате
|
||||
/// - `is_selected`: Выбран ли этот чат
|
||||
/// - `user_status`: Онлайн-статус пользователя (если доступен)
|
||||
///
|
||||
/// # Возвращает
|
||||
/// ListItem с форматированным отображением чата
|
||||
pub fn render_chat_list_item(
|
||||
chat: &ChatInfo,
|
||||
is_selected: bool,
|
||||
user_status: Option<&UserOnlineStatus>,
|
||||
) -> ListItem<'static> {
|
||||
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
|
||||
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
|
||||
|
||||
// Онлайн-статус (зелёная точка для онлайн)
|
||||
let status_icon = match user_status {
|
||||
Some(UserOnlineStatus::Online) => "● ",
|
||||
_ => " ",
|
||||
};
|
||||
|
||||
let prefix = if is_selected { "▌" } else { " " };
|
||||
|
||||
let username_text = chat
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| format!(" {}", u))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Индикатор упоминаний @
|
||||
let mention_badge = if chat.unread_mention_count > 0 {
|
||||
" @".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Индикатор черновика ✎
|
||||
let draft_badge = if chat.draft_text.is_some() {
|
||||
" ✎".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let unread_badge = if chat.unread_count > 0 {
|
||||
format!(" ({})", chat.unread_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let content = format!(
|
||||
"{}{}{}{}{}{}{}{}{}",
|
||||
prefix,
|
||||
status_icon,
|
||||
pin_icon,
|
||||
mute_icon,
|
||||
chat.title,
|
||||
username_text,
|
||||
mention_badge,
|
||||
draft_badge,
|
||||
unread_badge
|
||||
);
|
||||
|
||||
// Цвет: онлайн — зелёные, остальные — белые
|
||||
let style = match user_status {
|
||||
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
|
||||
_ => Style::default().fg(Color::White),
|
||||
};
|
||||
|
||||
ListItem::new(content).style(style)
|
||||
}
|
||||
104
crates/tele-tui/src/ui/components/emoji_picker.rs
Normal file
104
crates/tele-tui/src/ui/components/emoji_picker.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Рендерит модалку выбора реакций (emoji picker)
|
||||
///
|
||||
/// # Параметры
|
||||
/// - `f`: Frame для рендеринга
|
||||
/// - `area`: Область экрана
|
||||
/// - `available_reactions`: Список доступных эмодзи
|
||||
/// - `selected_index`: Индекс выбранного эмодзи
|
||||
pub fn render_emoji_picker(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
available_reactions: &[String],
|
||||
selected_index: usize,
|
||||
) {
|
||||
// Размеры модалки (зависят от количества реакций)
|
||||
let emojis_per_row = 8;
|
||||
let rows = available_reactions.len().div_ceil(emojis_per_row);
|
||||
let modal_width = 50u16;
|
||||
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
|
||||
|
||||
// Центрируем модалку
|
||||
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
||||
|
||||
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
|
||||
|
||||
// Очищаем область под модалкой
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
// Формируем содержимое - сетка эмодзи
|
||||
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
|
||||
|
||||
for row in 0..rows {
|
||||
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
|
||||
|
||||
for col in 0..emojis_per_row {
|
||||
let idx = row * emojis_per_row + col;
|
||||
if idx >= available_reactions.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let emoji = &available_reactions[idx];
|
||||
let is_selected = idx == selected_index;
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
row_spans.push(Span::styled(format!(" {} ", emoji), style));
|
||||
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(row_spans));
|
||||
}
|
||||
|
||||
// Добавляем пустую строку и подсказку
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
" [←/→/↑/↓] ",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("Выбор "),
|
||||
Span::styled(
|
||||
" [Enter] ",
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("Добавить "),
|
||||
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Отмена"),
|
||||
]));
|
||||
|
||||
let modal = Paragraph::new(text_lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.title(" Выбери реакцию ")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
50
crates/tele-tui/src/ui/components/input_field.rs
Normal file
50
crates/tele-tui/src/ui/components/input_field.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use ratatui::{
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
};
|
||||
|
||||
/// Рендерит текст с курсором в виде Line
|
||||
///
|
||||
/// # Параметры
|
||||
/// - `prefix`: Префикс перед текстом (например, "Сообщение: ")
|
||||
/// - `text`: Текст в поле ввода
|
||||
/// - `cursor_pos`: Позиция курсора (индекс символа)
|
||||
/// - `color`: Цвет текста и курсора
|
||||
///
|
||||
/// # Возвращает
|
||||
/// Line с текстом и блочным курсором на указанной позиции
|
||||
pub fn render_input_field(
|
||||
prefix: &str,
|
||||
text: &str,
|
||||
cursor_pos: usize,
|
||||
color: Color,
|
||||
) -> Line<'static> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
|
||||
|
||||
// Ограничиваем cursor_pos границами текста
|
||||
let safe_cursor_pos = cursor_pos.min(chars.len());
|
||||
|
||||
// Текст до курсора
|
||||
if safe_cursor_pos > 0 {
|
||||
let before: String = chars[..safe_cursor_pos].iter().collect();
|
||||
spans.push(Span::styled(before, Style::default().fg(color)));
|
||||
}
|
||||
|
||||
// Символ под курсором (или █ если курсор в конце)
|
||||
if safe_cursor_pos < chars.len() {
|
||||
let cursor_char = chars[safe_cursor_pos].to_string();
|
||||
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
|
||||
} else {
|
||||
// Курсор в конце - показываем блок
|
||||
spans.push(Span::styled("█", Style::default().fg(color)));
|
||||
}
|
||||
|
||||
// Текст после курсора
|
||||
if safe_cursor_pos + 1 < chars.len() {
|
||||
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
|
||||
spans.push(Span::styled(after, Style::default().fg(color)));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
763
crates/tele-tui/src/ui/components/message_bubble.rs
Normal file
763
crates/tele-tui/src/ui/components/message_bubble.rs
Normal file
@@ -0,0 +1,763 @@
|
||||
//! Message bubble component
|
||||
//!
|
||||
//! Отвечает за рендеринг отдельных элементов списка сообщений:
|
||||
//! - Разделители дат
|
||||
//! - Заголовки отправителей
|
||||
//! - Сами сообщения (с forward, reply, reactions)
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::formatting;
|
||||
#[cfg(feature = "images")]
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
|
||||
use crate::types::MessageId;
|
||||
use crate::utils::{format_date, format_timestamp};
|
||||
use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
};
|
||||
|
||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||
struct WrappedLine {
|
||||
text: String,
|
||||
/// Начальная позиция в символах от начала оригинального текста
|
||||
start_offset: usize,
|
||||
}
|
||||
|
||||
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
|
||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
let mut all_lines = Vec::new();
|
||||
let mut char_offset = 0;
|
||||
|
||||
for segment in text.split('\n') {
|
||||
let wrapped = wrap_paragraph(segment, max_width, char_offset);
|
||||
all_lines.extend(wrapped);
|
||||
char_offset += segment.chars().count() + 1; // +1 за '\n'
|
||||
}
|
||||
|
||||
if all_lines.is_empty() {
|
||||
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
|
||||
}
|
||||
|
||||
all_lines
|
||||
}
|
||||
|
||||
/// Разбивает один абзац (без `\n`) на строки по ширине
|
||||
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
|
||||
if max_width == 0 {
|
||||
return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_width = 0;
|
||||
let mut line_start_offset = base_offset;
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut word_start = 0;
|
||||
let mut in_word = false;
|
||||
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if ch.is_whitespace() {
|
||||
if in_word {
|
||||
let word: String = chars[word_start..i].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
line_start_offset = base_offset + word_start;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
current_width += 1 + word_width;
|
||||
} else {
|
||||
result.push(WrappedLine {
|
||||
text: current_line,
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
line_start_offset = base_offset + word_start;
|
||||
}
|
||||
in_word = false;
|
||||
}
|
||||
} else if !in_word {
|
||||
word_start = i;
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
|
||||
if in_word {
|
||||
let word: String = chars[word_start..].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
line_start_offset = base_offset + word_start;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
} else {
|
||||
result.push(WrappedLine {
|
||||
text: current_line,
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
current_line = word;
|
||||
line_start_offset = base_offset + word_start;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
result.push(WrappedLine {
|
||||
text: current_line,
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(WrappedLine { text: String::new(), start_offset: base_offset });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Рендерит разделитель даты
|
||||
///
|
||||
/// # Аргументы
|
||||
///
|
||||
/// * `date` - timestamp сообщения
|
||||
/// * `content_width` - ширина области для центрирования
|
||||
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
|
||||
pub fn render_date_separator(
|
||||
date: i32,
|
||||
content_width: usize,
|
||||
is_first: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if !is_first {
|
||||
lines.push(Line::from("")); // Пустая строка перед разделителем
|
||||
}
|
||||
|
||||
let date_str = format_date(date);
|
||||
let date_line = format!("──────── {} ────────", date_str);
|
||||
let padding = content_width.saturating_sub(date_line.chars().count()) / 2;
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(date_line, Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Рендерит заголовок отправителя
|
||||
///
|
||||
/// # Аргументы
|
||||
///
|
||||
/// * `is_outgoing` - исходящее ли сообщение
|
||||
/// * `sender_name` - имя отправителя
|
||||
/// * `content_width` - ширина области для выравнивания
|
||||
/// * `is_first` - первый ли это заголовок в группе (если нет, добавляется пустая строка сверху)
|
||||
pub fn render_sender_header(
|
||||
is_outgoing: bool,
|
||||
sender_name: &str,
|
||||
content_width: usize,
|
||||
is_first: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if !is_first {
|
||||
lines.push(Line::from("")); // Пустая строка между группами
|
||||
}
|
||||
|
||||
let sender_style = if is_outgoing {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
};
|
||||
|
||||
if is_outgoing {
|
||||
// Заголовок "Вы" справа
|
||||
let header_text = format!("{} ────────────────", sender_name);
|
||||
let header_len = header_text.chars().count();
|
||||
let padding = content_width.saturating_sub(header_len + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(format!("{} ", sender_name), sender_style),
|
||||
Span::styled("────────────────", Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
} else {
|
||||
// Заголовок входящих слева
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{} ", sender_name), sender_style),
|
||||
Span::styled("────────────────", Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Рендерит bubble одного сообщения
|
||||
///
|
||||
/// # Аргументы
|
||||
///
|
||||
/// * `msg` - сообщение для рендеринга
|
||||
/// * `config` - конфигурация (цвета)
|
||||
/// * `content_width` - ширина области для рендеринга
|
||||
/// * `selected_msg_id` - ID выбранного сообщения (для подсветки)
|
||||
pub fn render_message_bubble(
|
||||
msg: &MessageInfo,
|
||||
config: &Config,
|
||||
content_width: usize,
|
||||
selected_msg_id: Option<MessageId>,
|
||||
playback_state: Option<&PlaybackState>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
|
||||
// Маркер выбора (всегда резервируем место для ▶, чтобы текст не сдвигался)
|
||||
let selection_marker = if is_selected { "▶ " } else { " " };
|
||||
let marker_len = 2;
|
||||
|
||||
// Цвет сообщения
|
||||
let msg_color = if is_selected {
|
||||
config.parse_color(&config.colors.selected_message)
|
||||
} else if msg.is_outgoing() {
|
||||
config.parse_color(&config.colors.outgoing_message)
|
||||
} else {
|
||||
config.parse_color(&config.colors.incoming_message)
|
||||
};
|
||||
|
||||
// Отображаем forward если есть
|
||||
if let Some(forward) = msg.forward_from() {
|
||||
let forward_line = format!("↪ Переслано от {}", forward.sender_name);
|
||||
let forward_len = forward_line.chars().count();
|
||||
|
||||
if msg.is_outgoing() {
|
||||
let padding = content_width.saturating_sub(forward_len + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
forward_line,
|
||||
Style::default().fg(Color::Magenta),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
// Отображаем reply если есть
|
||||
if let Some(reply) = msg.reply_to() {
|
||||
let reply_text: String = reply.text.chars().take(40).collect();
|
||||
let ellipsis = if reply.text.chars().count() > 40 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let reply_line = format!("┌ {}: {}{}", reply.sender_name, reply_text, ellipsis);
|
||||
let reply_len = reply_line.chars().count();
|
||||
|
||||
if msg.is_outgoing() {
|
||||
let padding = content_width.saturating_sub(reply_len + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
|
||||
]));
|
||||
} else {
|
||||
lines
|
||||
.push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
|
||||
}
|
||||
}
|
||||
|
||||
// Форматируем время
|
||||
let time = format_timestamp(msg.date());
|
||||
|
||||
if msg.is_outgoing() {
|
||||
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
|
||||
let read_mark = if msg.is_read() { "✓✓" } else { "✓" };
|
||||
let edit_mark = if msg.is_edited() { "✎ " } else { "" };
|
||||
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
|
||||
let time_mark_len = time_mark.chars().count() + 1;
|
||||
|
||||
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
|
||||
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
|
||||
let total_wrapped = wrapped_lines.len();
|
||||
|
||||
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
|
||||
let is_last_line = i == total_wrapped - 1;
|
||||
let line_len = wrapped.text.chars().count();
|
||||
|
||||
let line_entities = formatting::adjust_entities_for_substring(
|
||||
msg.entities(),
|
||||
wrapped.start_offset,
|
||||
line_len,
|
||||
);
|
||||
let formatted_spans =
|
||||
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
||||
|
||||
if is_last_line {
|
||||
let full_len = line_len + time_mark_len + marker_len;
|
||||
let padding = content_width.saturating_sub(full_len + 1);
|
||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||
if i == 0 {
|
||||
// Первая (или единственная) строка — маркер
|
||||
line_spans.push(Span::styled(
|
||||
selection_marker,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
// Остальные строки multi-line — пробелы вместо маркера
|
||||
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||
}
|
||||
line_spans.extend(formatted_spans);
|
||||
line_spans.push(Span::styled(
|
||||
format!(" {}", time_mark),
|
||||
Style::default().fg(Color::Gray),
|
||||
));
|
||||
lines.push(Line::from(line_spans));
|
||||
} else {
|
||||
let padding = content_width.saturating_sub(line_len + marker_len + 1);
|
||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||
if i == 0 {
|
||||
line_spans.push(Span::styled(
|
||||
selection_marker,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
// Средние строки multi-line — пробелы вместо маркера
|
||||
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||
}
|
||||
line_spans.extend(formatted_spans);
|
||||
lines.push(Line::from(line_spans));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Входящие: слева, формат "(HH:MM ✎) текст"
|
||||
let edit_mark = if msg.is_edited() { " ✎" } else { "" };
|
||||
let time_str = format!("({}{})", time, edit_mark);
|
||||
let time_prefix_len = time_str.chars().count() + 2;
|
||||
|
||||
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
|
||||
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
|
||||
|
||||
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
|
||||
let line_len = wrapped.text.chars().count();
|
||||
|
||||
let line_entities = formatting::adjust_entities_for_substring(
|
||||
msg.entities(),
|
||||
wrapped.start_offset,
|
||||
line_len,
|
||||
);
|
||||
let formatted_spans =
|
||||
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
||||
|
||||
if i == 0 {
|
||||
let mut line_spans = vec![];
|
||||
line_spans.push(Span::styled(
|
||||
selection_marker,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
line_spans
|
||||
.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
|
||||
line_spans.push(Span::raw(" "));
|
||||
line_spans.extend(formatted_spans);
|
||||
lines.push(Line::from(line_spans));
|
||||
} else {
|
||||
let indent = " ".repeat(time_prefix_len + marker_len);
|
||||
let mut line_spans = vec![Span::raw(indent)];
|
||||
line_spans.extend(formatted_spans);
|
||||
lines.push(Line::from(line_spans));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отображаем реакции под сообщением
|
||||
if !msg.reactions().is_empty() {
|
||||
let mut reaction_spans = vec![];
|
||||
|
||||
for reaction in msg.reactions() {
|
||||
if !reaction_spans.is_empty() {
|
||||
reaction_spans.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
let reaction_text = if reaction.is_chosen {
|
||||
if reaction.count > 1 {
|
||||
format!("[{}] {}", reaction.emoji, reaction.count)
|
||||
} else {
|
||||
format!("[{}]", reaction.emoji)
|
||||
}
|
||||
} else if reaction.count > 1 {
|
||||
format!("{} {}", reaction.emoji, reaction.count)
|
||||
} else {
|
||||
reaction.emoji.clone()
|
||||
};
|
||||
|
||||
let style = if reaction.is_chosen {
|
||||
Style::default().fg(config.parse_color(&config.colors.reaction_chosen))
|
||||
} else {
|
||||
Style::default().fg(config.parse_color(&config.colors.reaction_other))
|
||||
};
|
||||
|
||||
reaction_spans.push(Span::styled(reaction_text, style));
|
||||
}
|
||||
|
||||
if msg.is_outgoing() {
|
||||
let reactions_text: String = reaction_spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let reactions_len = reactions_text.chars().count();
|
||||
let padding = content_width.saturating_sub(reactions_len + 1);
|
||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||
line_spans.extend(reaction_spans);
|
||||
lines.push(Line::from(line_spans));
|
||||
} else {
|
||||
lines.push(Line::from(reaction_spans));
|
||||
}
|
||||
}
|
||||
|
||||
// Отображаем индикатор воспроизведения голосового
|
||||
if msg.has_voice() {
|
||||
if let Some(voice) = msg.voice_info() {
|
||||
let status_line =
|
||||
if let Some(ps) = playback_state.filter(|ps| ps.message_id == msg.id()) {
|
||||
let icon = match ps.status {
|
||||
PlaybackStatus::Playing => "▶",
|
||||
PlaybackStatus::Paused => "⏸",
|
||||
PlaybackStatus::Loading => "⏳",
|
||||
_ => "⏹",
|
||||
};
|
||||
let bar = render_progress_bar(ps.position, ps.duration, 20);
|
||||
format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration)
|
||||
} else {
|
||||
let waveform = render_waveform(&voice.waveform, 20);
|
||||
format!(" {} {:.0}s", waveform, voice.duration)
|
||||
};
|
||||
|
||||
let status_len = status_line.chars().count();
|
||||
if msg.is_outgoing() {
|
||||
let padding = content_width.saturating_sub(status_len + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(status_line, Style::default().fg(Color::Cyan)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отображаем статус фото (если есть)
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(photo) = msg.photo_info() {
|
||||
match &photo.download_state {
|
||||
PhotoDownloadState::Downloading => {
|
||||
let status = "📷 ⏳ Загрузка...";
|
||||
if msg.is_outgoing() {
|
||||
let padding = content_width.saturating_sub(status.chars().count() + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(status, Style::default().fg(Color::Yellow)),
|
||||
]));
|
||||
} else {
|
||||
lines
|
||||
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(e) => {
|
||||
let status = format!("📷 [Ошибка: {}]", e);
|
||||
if msg.is_outgoing() {
|
||||
let padding = content_width.saturating_sub(status.chars().count() + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(status, Style::default().fg(Color::Red)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Downloaded(_) => {
|
||||
// Всегда показываем inline превью для загруженных фото
|
||||
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
|
||||
for _ in 0..img_height {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded => {
|
||||
// Для незагруженных фото ничего не рендерим,
|
||||
// текст сообщения уже содержит 📷 prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Информация для отложенного рендеринга изображения поверх placeholder
|
||||
#[cfg(feature = "images")]
|
||||
pub struct DeferredImageRender {
|
||||
pub message_id: MessageId,
|
||||
/// Путь к файлу изображения
|
||||
pub photo_path: String,
|
||||
/// Смещение в строках от начала всего списка сообщений
|
||||
pub line_offset: usize,
|
||||
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
|
||||
pub x_offset: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
|
||||
///
|
||||
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
|
||||
#[cfg(feature = "images")]
|
||||
pub fn render_album_bubble(
|
||||
messages: &[&MessageInfo],
|
||||
config: &Config,
|
||||
content_width: usize,
|
||||
selected_msg_id: Option<MessageId>,
|
||||
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
|
||||
use crate::constants::{
|
||||
ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH,
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut deferred: Vec<DeferredImageRender> = Vec::new();
|
||||
|
||||
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
|
||||
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing());
|
||||
|
||||
// Selection marker (всегда резервируем место)
|
||||
let selection_marker = if is_selected { "▶ " } else { " " };
|
||||
|
||||
// Фильтруем фото
|
||||
let photos: Vec<&MessageInfo> = messages.iter().copied().filter(|m| m.has_photo()).collect();
|
||||
let photo_count = photos.len();
|
||||
|
||||
if photo_count == 0 {
|
||||
// Нет фото — рендерим как обычные сообщения
|
||||
for msg in messages {
|
||||
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
|
||||
}
|
||||
return (lines, deferred);
|
||||
}
|
||||
|
||||
// Grid layout
|
||||
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
|
||||
let rows = photo_count.div_ceil(cols);
|
||||
|
||||
// Добавляем маркер выбора на первую строку (всегда — для постоянного отступа)
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
selection_marker,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
|
||||
let grid_start_line = lines.len();
|
||||
|
||||
// Генерируем placeholder-строки для сетки
|
||||
for row in 0..rows {
|
||||
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Для исходящих — добавляем отступ справа
|
||||
if is_outgoing {
|
||||
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||
let padding = content_width.saturating_sub(grid_width as usize + 1);
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
}
|
||||
|
||||
// Для каждого столбца в этом ряду
|
||||
for col in 0..cols {
|
||||
let photo_idx = row * cols + col;
|
||||
if photo_idx >= photo_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let msg = photos[photo_idx];
|
||||
if let Some(photo) = msg.photo_info() {
|
||||
match &photo.download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
if line_in_row == 0 {
|
||||
// Регистрируем deferred render для этого фото
|
||||
let x_off = if is_outgoing {
|
||||
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||
let padding = content_width
|
||||
.saturating_sub(grid_width as usize + 1)
|
||||
as u16;
|
||||
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||
} else {
|
||||
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||
};
|
||||
|
||||
deferred.push(DeferredImageRender {
|
||||
message_id: msg.id(),
|
||||
photo_path: path.clone(),
|
||||
line_offset: grid_start_line
|
||||
+ row * ALBUM_PHOTO_HEIGHT as usize,
|
||||
x_offset: x_off,
|
||||
width: ALBUM_PHOTO_WIDTH,
|
||||
height: ALBUM_PHOTO_HEIGHT,
|
||||
});
|
||||
}
|
||||
// Пустая строка — placeholder для изображения
|
||||
}
|
||||
PhotoDownloadState::Downloading => {
|
||||
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||
spans.push(Span::styled(
|
||||
"⏳ Загрузка...",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(e) => {
|
||||
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||
let err_text: String = e.chars().take(14).collect();
|
||||
spans.push(Span::styled(
|
||||
format!("❌ {}", err_text),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded => {
|
||||
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
|
||||
let captions: Vec<&str> = messages
|
||||
.iter()
|
||||
.map(|m| m.text())
|
||||
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
|
||||
.collect();
|
||||
|
||||
let msg_color = if is_selected {
|
||||
config.parse_color(&config.colors.selected_message)
|
||||
} else if is_outgoing {
|
||||
config.parse_color(&config.colors.outgoing_message)
|
||||
} else {
|
||||
config.parse_color(&config.colors.incoming_message)
|
||||
};
|
||||
|
||||
// Timestamp из последнего сообщения
|
||||
let Some(last_msg) = messages.last() else {
|
||||
return (lines, deferred);
|
||||
};
|
||||
let time = format_timestamp(last_msg.date());
|
||||
|
||||
if !captions.is_empty() {
|
||||
let caption_text = captions.join(" ");
|
||||
let time_suffix = format!(" ({})", time);
|
||||
|
||||
if is_outgoing {
|
||||
let total_len = caption_text.chars().count() + time_suffix.chars().count();
|
||||
let padding = content_width.saturating_sub(total_len + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
// Без подписи — только timestamp
|
||||
let time_text = format!("({})", time);
|
||||
if is_outgoing {
|
||||
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(time_text, Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" {}", time_text),
|
||||
Style::default().fg(Color::Gray),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
(lines, deferred)
|
||||
}
|
||||
|
||||
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
||||
#[cfg(feature = "images")]
|
||||
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
|
||||
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
|
||||
|
||||
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
|
||||
let aspect = img_height as f64 / img_width as f64;
|
||||
// Терминальные символы ~2:1 по высоте, компенсируем
|
||||
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
|
||||
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
|
||||
}
|
||||
|
||||
/// Рендерит progress bar для воспроизведения
|
||||
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
|
||||
if duration <= 0.0 {
|
||||
return "─".repeat(width);
|
||||
}
|
||||
let ratio = (position / duration).clamp(0.0, 1.0);
|
||||
let filled = (ratio * width as f32) as usize;
|
||||
let empty = width.saturating_sub(filled + 1);
|
||||
format!("{}●{}", "━".repeat(filled), "─".repeat(empty))
|
||||
}
|
||||
|
||||
/// Рендерит waveform из base64-encoded данных TDLib
|
||||
fn render_waveform(waveform_b64: &str, width: usize) -> String {
|
||||
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
if waveform_b64.is_empty() {
|
||||
return "▁".repeat(width);
|
||||
}
|
||||
|
||||
// Декодируем waveform (каждый байт = амплитуда 0-255)
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(waveform_b64)
|
||||
.unwrap_or_default();
|
||||
|
||||
if bytes.is_empty() {
|
||||
return "▁".repeat(width);
|
||||
}
|
||||
|
||||
// Сэмплируем до нужной ширины
|
||||
let mut result = String::with_capacity(width * 4);
|
||||
for i in 0..width {
|
||||
let byte_idx = i * bytes.len() / width;
|
||||
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
|
||||
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
|
||||
result.push(BARS[bar_idx]);
|
||||
}
|
||||
result
|
||||
}
|
||||
117
crates/tele-tui/src/ui/components/message_list.rs
Normal file
117
crates/tele-tui/src/ui/components/message_list.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Shared message list rendering for search and pinned modals
|
||||
|
||||
use crate::tdlib::MessageInfo;
|
||||
use ratatui::{
|
||||
layout::Alignment,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
|
||||
/// Renders a single message item with marker, sender, date, and wrapped text
|
||||
pub fn render_message_item(
|
||||
msg: &MessageInfo,
|
||||
is_selected: bool,
|
||||
content_width: usize,
|
||||
max_preview_lines: usize,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Marker, sender name, and date
|
||||
let marker = if is_selected { "▶ " } else { " " };
|
||||
let sender_color = if msg.is_outgoing() {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::Cyan
|
||||
};
|
||||
let sender_name = if msg.is_outgoing() {
|
||||
"Вы".to_string()
|
||||
} else {
|
||||
msg.sender_name().to_string()
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
marker.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{} ", sender_name),
|
||||
Style::default()
|
||||
.fg(sender_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("({})", crate::utils::format_datetime(msg.date())),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]));
|
||||
|
||||
// Wrapped message text
|
||||
let msg_color = if is_selected {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::White
|
||||
};
|
||||
let max_width = content_width.saturating_sub(4);
|
||||
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
|
||||
let wrapped_count = wrapped.len();
|
||||
|
||||
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".to_string()),
|
||||
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
||||
]));
|
||||
}
|
||||
if wrapped_count > max_preview_lines {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".to_string()),
|
||||
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Calculates scroll offset to keep selected item visible
|
||||
pub fn calculate_scroll_offset(
|
||||
selected_index: usize,
|
||||
lines_per_item: usize,
|
||||
visible_height: u16,
|
||||
) -> u16 {
|
||||
let visible = visible_height.saturating_sub(2) as usize;
|
||||
let selected_line = selected_index * lines_per_item;
|
||||
if selected_line > visible / 2 {
|
||||
(selected_line - visible / 2) as u16
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a help bar with keyboard shortcuts
|
||||
pub fn render_help_bar(
|
||||
shortcuts: &[(&str, &str, Color)],
|
||||
border_color: Color,
|
||||
) -> Paragraph<'static> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::raw(" ".to_string()));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", key),
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(label.to_string()));
|
||||
}
|
||||
|
||||
Paragraph::new(Line::from(spans))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color)),
|
||||
)
|
||||
.alignment(Alignment::Center)
|
||||
}
|
||||
17
crates/tele-tui/src/ui/components/mod.rs
Normal file
17
crates/tele-tui/src/ui/components/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! Reusable UI components: message bubbles, input fields, modals, lists.
|
||||
|
||||
pub mod chat_list_item;
|
||||
pub mod emoji_picker;
|
||||
pub mod input_field;
|
||||
pub mod message_bubble;
|
||||
pub mod message_list;
|
||||
pub mod modal;
|
||||
|
||||
// Экспорт основных функций
|
||||
pub use chat_list_item::render_chat_list_item;
|
||||
pub use emoji_picker::render_emoji_picker;
|
||||
pub use input_field::render_input_field;
|
||||
#[cfg(feature = "images")]
|
||||
pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
|
||||
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
||||
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item};
|
||||
83
crates/tele-tui/src/ui/components/modal.rs
Normal file
83
crates/tele-tui/src/ui/components/modal.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Рендерит центрированную модалку с заданным содержимым
|
||||
///
|
||||
/// # Параметры
|
||||
/// - `f`: Frame для рендеринга
|
||||
/// - `area`: Область экрана
|
||||
/// - `title`: Заголовок модалки
|
||||
/// - `content`: Содержимое модалки (строки текста)
|
||||
/// - `width`: Ширина модалки
|
||||
/// - `height`: Высота модалки
|
||||
/// - `border_color`: Цвет рамки
|
||||
pub fn render_modal(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
content: Vec<Line>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
border_color: Color,
|
||||
) {
|
||||
// Центрируем модалку
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
|
||||
let modal_area = Rect::new(x, y, width.min(area.width), height.min(area.height));
|
||||
|
||||
// Очищаем область под модалкой
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
// Рендерим модалку
|
||||
let modal = Paragraph::new(content)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.title(format!(" {} ", title))
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(border_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
|
||||
/// Рендерит модалку подтверждения удаления
|
||||
pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
||||
use ratatui::text::Span;
|
||||
|
||||
let content = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Удалить сообщение?",
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
" [y/Enter] ",
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("Да"),
|
||||
Span::raw(" "),
|
||||
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("Нет"),
|
||||
]),
|
||||
];
|
||||
|
||||
render_modal(f, area, "Подтверждение", content, 40, 7, Color::Red);
|
||||
}
|
||||
194
crates/tele-tui/src/ui/compose_bar.rs
Normal file
194
crates/tele-tui/src/ui/compose_bar.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Compose bar / input box rendering
|
||||
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::components;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders input field with cursor at the specified position
|
||||
fn render_input_with_cursor(
|
||||
prefix: &str,
|
||||
text: &str,
|
||||
cursor_pos: usize,
|
||||
color: Color,
|
||||
) -> Line<'static> {
|
||||
components::render_input_field(prefix, text, cursor_pos, color)
|
||||
}
|
||||
|
||||
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
|
||||
// Режим пересылки - показываем превью сообщения
|
||||
let forward_preview = app
|
||||
.get_forwarding_message()
|
||||
.map(|m| {
|
||||
let text_preview: String = m.text().chars().take(40).collect();
|
||||
let ellipsis = if m.text().chars().count() > 40 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("↪ {}{}", text_preview, ellipsis)
|
||||
})
|
||||
.unwrap_or_else(|| "↪ ...".to_string());
|
||||
|
||||
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
|
||||
(line, " Выберите чат ← ")
|
||||
} else if app.is_selecting_message() {
|
||||
// Режим выбора сообщения - подсказка зависит от возможностей
|
||||
let selected_msg = app.get_selected_message();
|
||||
let can_edit = selected_msg
|
||||
.as_ref()
|
||||
.map(|m| m.can_be_edited() && m.is_outgoing())
|
||||
.unwrap_or(false);
|
||||
let can_delete = selected_msg
|
||||
.as_ref()
|
||||
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
|
||||
.unwrap_or(false);
|
||||
|
||||
let hint = match (can_edit, can_delete) {
|
||||
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
|
||||
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
|
||||
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
|
||||
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
|
||||
};
|
||||
(
|
||||
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
|
||||
" Выбор сообщения ",
|
||||
)
|
||||
} else if app.is_editing() {
|
||||
// Режим редактирования
|
||||
if app.message_input.is_empty() {
|
||||
let line = Line::from(vec![
|
||||
Span::raw("✏ "),
|
||||
Span::styled("█", Style::default().fg(Color::Magenta)),
|
||||
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
|
||||
]);
|
||||
(line, " Редактирование (Esc отмена) ")
|
||||
} else {
|
||||
let line = render_input_with_cursor(
|
||||
"✏ ",
|
||||
&app.message_input,
|
||||
app.cursor_position,
|
||||
Color::Magenta,
|
||||
);
|
||||
(line, " Редактирование (Esc отмена) ")
|
||||
}
|
||||
} else if app.is_replying() {
|
||||
// Режим ответа на сообщение
|
||||
let reply_preview = app
|
||||
.get_replying_to_message()
|
||||
.map(|m| {
|
||||
let sender = if m.is_outgoing() {
|
||||
"Вы"
|
||||
} else {
|
||||
m.sender_name()
|
||||
};
|
||||
let text_preview: String = m.text().chars().take(30).collect();
|
||||
let ellipsis = if m.text().chars().count() > 30 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{}: {}{}", sender, text_preview, ellipsis)
|
||||
})
|
||||
.unwrap_or_else(|| "...".to_string());
|
||||
|
||||
if app.message_input.is_empty() {
|
||||
let line = Line::from(vec![
|
||||
Span::styled("↪ ", Style::default().fg(Color::Cyan)),
|
||||
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
|
||||
Span::raw(" "),
|
||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||
]);
|
||||
(line, " Ответ (Esc отмена) ")
|
||||
} else {
|
||||
let short_preview: String = reply_preview.chars().take(15).collect();
|
||||
let prefix = format!("↪ {} > ", short_preview);
|
||||
let line = render_input_with_cursor(
|
||||
&prefix,
|
||||
&app.message_input,
|
||||
app.cursor_position,
|
||||
Color::Yellow,
|
||||
);
|
||||
(line, " Ответ (Esc отмена) ")
|
||||
}
|
||||
} else if app.input_mode == InputMode::Normal {
|
||||
// Normal mode — dim, no cursor
|
||||
if app.message_input.is_empty() {
|
||||
let line = Line::from(vec![Span::styled(
|
||||
"> Press i to type...",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)]);
|
||||
(line, "")
|
||||
} else {
|
||||
let draft_preview: String = app.message_input.chars().take(60).collect();
|
||||
let ellipsis = if app.message_input.chars().count() > 60 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let line = Line::from(Span::styled(
|
||||
format!("> {}{}", draft_preview, ellipsis),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
(line, "")
|
||||
}
|
||||
} else {
|
||||
// Insert mode — active, with cursor
|
||||
if app.message_input.is_empty() {
|
||||
let line = Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
|
||||
]);
|
||||
(line, "")
|
||||
} else {
|
||||
let line = render_input_with_cursor(
|
||||
"> ",
|
||||
&app.message_input,
|
||||
app.cursor_position,
|
||||
Color::Yellow,
|
||||
);
|
||||
(line, "")
|
||||
}
|
||||
};
|
||||
|
||||
let input_block = if input_title.is_empty() {
|
||||
let border_style = if app.input_mode == InputMode::Insert {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
} else {
|
||||
let title_color = if app.is_replying() || app.is_forwarding() {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::Magenta
|
||||
};
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(input_title)
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(title_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
};
|
||||
|
||||
let input = Paragraph::new(input_line)
|
||||
.block(input_block)
|
||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||
f.render_widget(input, area);
|
||||
}
|
||||
60
crates/tele-tui/src/ui/footer.rs
Normal file
60
crates/tele-tui/src/ui/footer.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::tdlib::NetworkState;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
// Индикатор состояния сети
|
||||
let network_indicator = match app.td_client.network_state() {
|
||||
NetworkState::Ready => "",
|
||||
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
|
||||
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
|
||||
NetworkState::Connecting => "⏳ Подключение... | ",
|
||||
NetworkState::Updating => "⏳ Обновление... | ",
|
||||
};
|
||||
|
||||
let account_indicator = format!("[{}] ", app.current_account_name);
|
||||
|
||||
let status = if let Some(msg) = &app.status_message {
|
||||
format!(" {}{}{} ", account_indicator, network_indicator, msg)
|
||||
} else if let Some(err) = &app.error_message {
|
||||
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
|
||||
} else if app.is_searching {
|
||||
format!(
|
||||
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
|
||||
account_indicator, network_indicator
|
||||
)
|
||||
} else if app.selected_chat_id.is_some() {
|
||||
let mode_str = match app.input_mode {
|
||||
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
||||
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
|
||||
};
|
||||
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
|
||||
} else {
|
||||
format!(
|
||||
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||
account_indicator, network_indicator
|
||||
)
|
||||
};
|
||||
|
||||
let style = if matches!(app.td_client.network_state(), NetworkState::WaitingForNetwork) {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if !matches!(app.td_client.network_state(), NetworkState::Ready) {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else 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::Rgb(160, 160, 160))
|
||||
};
|
||||
|
||||
let footer = Paragraph::new(status).style(style);
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
34
crates/tele-tui/src/ui/loading.rs
Normal file
34
crates/tele-tui/src/ui/loading.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
|
||||
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]);
|
||||
}
|
||||
89
crates/tele-tui/src/ui/main_screen.rs
Normal file
89
crates/tele-tui/src/ui/main_screen.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use super::{chat_list, footer, messages};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Порог ширины для компактного режима (одна панель)
|
||||
const COMPACT_WIDTH: u16 = 80;
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
||||
let area = f.area();
|
||||
let is_compact = area.width < COMPACT_WIDTH;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Folders/tabs
|
||||
Constraint::Min(0), // Main content
|
||||
Constraint::Length(1), // Commands footer
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_folders(f, chunks[0], app);
|
||||
|
||||
if is_compact {
|
||||
// Компактный режим: показываем либо список чатов, либо открытый чат
|
||||
if app.selected_chat_id.is_some() {
|
||||
// Чат открыт — показываем только сообщения
|
||||
messages::render(f, chunks[1], app);
|
||||
} else {
|
||||
// Чат не открыт — показываем только список чатов
|
||||
chat_list::render(f, chunks[1], app);
|
||||
}
|
||||
} else {
|
||||
// Обычный режим: две панели
|
||||
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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let mut spans = vec![];
|
||||
|
||||
// "All" всегда первая (клавиша 1)
|
||||
let all_style = if app.selected_folder_id.is_none() {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
spans.push(Span::styled(" 1:All ", all_style));
|
||||
|
||||
// Папки из TDLib (клавиши 2, 3, 4...)
|
||||
for (i, folder) in app.td_client.folders().iter().enumerate() {
|
||||
spans.push(Span::raw("│"));
|
||||
|
||||
let style = if app.selected_folder_id == Some(folder.id) {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
spans.push(Span::styled(format!(" {}:{} ", i + 2, folder.name), style));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
102
crates/tele-tui/src/ui/messages.rs
Normal file
102
crates/tele-tui/src/ui/messages.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Chat message area rendering.
|
||||
|
||||
mod header;
|
||||
mod list;
|
||||
mod pinned;
|
||||
|
||||
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::{compose_bar, modals};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use header::render_chat_header;
|
||||
use list::render_message_list;
|
||||
use pinned::render_pinned_bar;
|
||||
|
||||
pub(crate) use list::wrap_text_with_offsets;
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(modal_state) = app.image_modal.clone() {
|
||||
modals::render_image_viewer(f, app, &modal_state);
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_profile_mode() {
|
||||
if let Some(profile) = app.get_profile_info() {
|
||||
crate::ui::profile::render(f, area, app, profile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_message_search_mode() {
|
||||
modals::render_search(f, area, app);
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_pinned_mode() {
|
||||
modals::render_pinned(f, area, app);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(chat) = app.get_selected_chat().cloned() {
|
||||
let input_width = area.width.saturating_sub(4) as usize;
|
||||
let input_lines: u16 = if input_width > 0 {
|
||||
let len = app.message_input.chars().count() + 2;
|
||||
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let input_height = (input_lines + 2).clamp(3, 10);
|
||||
|
||||
let has_pinned = app.td_client.current_pinned_message().is_some();
|
||||
let message_chunks = if has_pinned {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(input_height),
|
||||
])
|
||||
.split(area)
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(0),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(input_height),
|
||||
])
|
||||
.split(area)
|
||||
};
|
||||
|
||||
render_chat_header(f, message_chunks[0], app, &chat);
|
||||
render_pinned_bar(f, message_chunks[1], app);
|
||||
render_message_list(f, message_chunks[2], app);
|
||||
compose_bar::render(f, message_chunks[3], app);
|
||||
} else {
|
||||
let empty = Paragraph::new("Выберите чат")
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(empty, area);
|
||||
}
|
||||
|
||||
if app.is_confirm_delete_shown() {
|
||||
modals::render_delete_confirm(f, area);
|
||||
}
|
||||
|
||||
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
|
||||
&app.chat_state
|
||||
{
|
||||
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
|
||||
}
|
||||
}
|
||||
55
crates/tele-tui/src/ui/messages/header.rs
Normal file
55
crates/tele-tui/src/ui/messages/header.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::{ChatInfo, TdClientTrait};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub(super) fn render_chat_header<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &App<T>,
|
||||
chat: &ChatInfo,
|
||||
) {
|
||||
let typing_action = app
|
||||
.td_client
|
||||
.typing_status()
|
||||
.as_ref()
|
||||
.map(|(_, action, _)| action.clone());
|
||||
|
||||
let header_line = if let Some(action) = typing_action {
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("👤 {}", chat.title),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if let Some(username) = &chat.username {
|
||||
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
format!(" {}", action),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
Line::from(spans)
|
||||
} else {
|
||||
let header_text = match &chat.username {
|
||||
Some(username) => format!("👤 {} {}", chat.title, username),
|
||||
None => format!("👤 {}", chat.title),
|
||||
};
|
||||
Line::from(Span::styled(
|
||||
header_text,
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
};
|
||||
|
||||
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
|
||||
f.render_widget(header, area);
|
||||
}
|
||||
286
crates/tele-tui/src/ui/messages/list.rs
Normal file
286
crates/tele-tui/src/ui/messages/list.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use crate::app::methods::messages::MessageMethods;
|
||||
use crate::app::App;
|
||||
use crate::message_grouping::{group_messages, MessageGroup};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::components;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Информация о строке после переноса: текст и позиция в оригинале.
|
||||
pub(crate) struct WrappedLine {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Разбивает текст на строки с учётом максимальной ширины.
|
||||
pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if max_width == 0 {
|
||||
return vec![WrappedLine { text: text.to_string() }];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_width = 0;
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut word_start = 0;
|
||||
let mut in_word = false;
|
||||
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if ch.is_whitespace() {
|
||||
if in_word {
|
||||
let word: String = chars[word_start..i].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
current_width += 1 + word_width;
|
||||
} else {
|
||||
result.push(WrappedLine { text: current_line });
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
}
|
||||
in_word = false;
|
||||
}
|
||||
} else if !in_word {
|
||||
word_start = i;
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
|
||||
if in_word {
|
||||
let word: String = chars[word_start..].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
} else {
|
||||
result.push(WrappedLine { text: current_line });
|
||||
current_line = word;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
result.push(WrappedLine { text: current_line });
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(WrappedLine { text: String::new() });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом.
|
||||
pub(super) fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
let content_width = area.width.saturating_sub(2) as usize;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||||
let mut selected_msg_line: Option<usize> = None;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||||
|
||||
let current_messages = app.td_client.current_chat_messages();
|
||||
let grouped = group_messages(¤t_messages);
|
||||
let mut is_first_date = true;
|
||||
let mut is_first_sender = true;
|
||||
|
||||
for group in grouped {
|
||||
match group {
|
||||
MessageGroup::DateSeparator(date) => {
|
||||
lines.extend(components::render_date_separator(date, content_width, is_first_date));
|
||||
is_first_date = false;
|
||||
is_first_sender = true;
|
||||
}
|
||||
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
|
||||
lines.extend(components::render_sender_header(
|
||||
is_outgoing,
|
||||
&sender_name,
|
||||
content_width,
|
||||
is_first_sender,
|
||||
));
|
||||
is_first_sender = false;
|
||||
}
|
||||
MessageGroup::Message(msg) => {
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
|
||||
let bubble_lines = components::render_message_bubble(
|
||||
msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
app.playback_state.as_ref(),
|
||||
);
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(photo) = msg.photo_info() {
|
||||
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
|
||||
&photo.download_state
|
||||
{
|
||||
let inline_width =
|
||||
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||
let img_height = components::calculate_image_height(
|
||||
photo.width,
|
||||
photo.height,
|
||||
inline_width,
|
||||
);
|
||||
let img_width = inline_width as u16;
|
||||
let bubble_len = bubble_lines.len();
|
||||
let placeholder_start = lines.len() + bubble_len - img_height as usize;
|
||||
|
||||
deferred_images.push(components::DeferredImageRender {
|
||||
message_id: msg.id(),
|
||||
photo_path: path.clone(),
|
||||
line_offset: placeholder_start,
|
||||
x_offset: 0,
|
||||
width: img_width,
|
||||
height: img_height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lines.extend(bubble_lines);
|
||||
}
|
||||
MessageGroup::Album(album_messages) => {
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
let is_selected = album_messages
|
||||
.iter()
|
||||
.any(|m| selected_msg_id == Some(m.id()));
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
|
||||
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
||||
&album_messages,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
);
|
||||
|
||||
for mut d in album_deferred {
|
||||
d.line_offset += lines.len();
|
||||
deferred_images.push(d);
|
||||
}
|
||||
|
||||
lines.extend(bubble_lines);
|
||||
}
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
for msg in &album_messages {
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
lines.extend(components::render_message_bubble(
|
||||
msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
app.playback_state.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
|
||||
}
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let base_scroll = total_lines.saturating_sub(visible_height);
|
||||
|
||||
let scroll_offset = if app.is_selecting_message() {
|
||||
if let Some(selected_line) = selected_msg_line {
|
||||
if selected_line < visible_height / 2 {
|
||||
0
|
||||
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
|
||||
base_scroll
|
||||
} else {
|
||||
selected_line.saturating_sub(visible_height / 2)
|
||||
}
|
||||
} else {
|
||||
base_scroll.saturating_sub(app.message_scroll_offset)
|
||||
}
|
||||
} else {
|
||||
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, area);
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset);
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
fn render_deferred_images<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &mut App<T>,
|
||||
deferred_images: &[components::DeferredImageRender],
|
||||
visible_height: usize,
|
||||
scroll_offset: u16,
|
||||
) {
|
||||
use ratatui_image::StatefulImage;
|
||||
|
||||
let should_render_images = app
|
||||
.last_image_render_time
|
||||
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
|
||||
.unwrap_or(true);
|
||||
|
||||
if deferred_images.is_empty() || !should_render_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let content_x = area.x + 1;
|
||||
let content_y = area.y + 1;
|
||||
|
||||
for d in deferred_images {
|
||||
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
||||
|
||||
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
||||
continue;
|
||||
}
|
||||
|
||||
let img_y = content_y + y_in_content as u16;
|
||||
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
||||
|
||||
if d.height > remaining_height {
|
||||
continue;
|
||||
}
|
||||
|
||||
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
||||
|
||||
if let Some(renderer) = &mut app.inline_image_renderer {
|
||||
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
||||
|
||||
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.last_image_render_time = Some(std::time::Instant::now());
|
||||
}
|
||||
38
crates/tele-tui/src/ui/messages/pinned.rs
Normal file
38
crates/tele-tui/src/ui/messages/pinned.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub(super) fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let Some(pinned_msg) = app.td_client.current_pinned_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
|
||||
let ellipsis = if pinned_msg.text().chars().count() > 40 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date());
|
||||
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
|
||||
let pinned_hint = "Ctrl+P";
|
||||
|
||||
let pinned_bar_width = area.width as usize;
|
||||
let text_len = pinned_text.chars().count();
|
||||
let hint_len = pinned_hint.chars().count();
|
||||
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
|
||||
|
||||
let pinned_line = Line::from(vec![
|
||||
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
||||
]);
|
||||
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
||||
f.render_widget(pinned_bar, area);
|
||||
}
|
||||
59
crates/tele-tui/src/ui/mod.rs
Normal file
59
crates/tele-tui/src/ui/mod.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! UI rendering module.
|
||||
//!
|
||||
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
|
||||
|
||||
mod auth;
|
||||
pub mod chat_list;
|
||||
pub mod components;
|
||||
mod compose_bar;
|
||||
pub mod footer;
|
||||
mod loading;
|
||||
mod main_screen;
|
||||
pub mod messages;
|
||||
mod modals;
|
||||
pub mod profile;
|
||||
|
||||
use crate::app::{App, AppScreen};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Минимальная высота терминала
|
||||
const MIN_HEIGHT: u16 = 10;
|
||||
/// Минимальная ширина терминала
|
||||
const MIN_WIDTH: u16 = 40;
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
||||
let area = f.area();
|
||||
|
||||
// Проверяем минимальный размер терминала
|
||||
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||
render_size_warning(f, area.width, area.height);
|
||||
return;
|
||||
}
|
||||
|
||||
match app.screen {
|
||||
AppScreen::Loading => loading::render(f, app),
|
||||
AppScreen::Auth => auth::render(f, app),
|
||||
AppScreen::Main => main_screen::render(f, app),
|
||||
}
|
||||
|
||||
// Global overlay: account switcher (renders on top of ANY screen)
|
||||
if app.account_switcher.is_some() {
|
||||
modals::render_account_switcher(f, area, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||
let message = format!("{}x{}\nМинимум: {}x{}", width, height, MIN_WIDTH, MIN_HEIGHT);
|
||||
let warning = Paragraph::new(message)
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(warning, f.area());
|
||||
}
|
||||
190
crates/tele-tui/src/ui/modals/account_switcher.rs
Normal file
190
crates/tele-tui/src/ui/modals/account_switcher.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Account switcher modal
|
||||
//!
|
||||
//! Renders a centered popup with account list (SelectAccount) or
|
||||
//! new account name input (AddAccount).
|
||||
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the account switcher modal overlay.
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let Some(state) = &app.account_switcher else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
|
||||
render_select_account(f, area, accounts, *selected_index, current_account);
|
||||
}
|
||||
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
|
||||
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_select_account(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
accounts: &[crate::accounts::AccountProfile],
|
||||
selected_index: usize,
|
||||
current_account: &str,
|
||||
) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (idx, account) in accounts.iter().enumerate() {
|
||||
let is_selected = idx == selected_index;
|
||||
let is_current = account.name == current_account;
|
||||
|
||||
let marker = if is_current { "● " } else { " " };
|
||||
let suffix = if is_current { " (текущий)" } else { "" };
|
||||
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_current {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
|
||||
}
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ──────────────────────",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
// Add account item
|
||||
let add_selected = selected_index == accounts.len();
|
||||
let add_style = if add_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Help bar
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||
Span::styled("Select", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" a ", Style::default().fg(Color::Cyan)),
|
||||
Span::styled("Add", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||
Span::styled("Close", Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
|
||||
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
|
||||
let content_height = (accounts.len() as u16) + 7;
|
||||
let height = content_height.min(area.height.saturating_sub(4));
|
||||
let width = 40u16.min(area.width.saturating_sub(4));
|
||||
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
let modal_area = Rect::new(x, y, width, height);
|
||||
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
let modal = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(" АККАУНТЫ ")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
|
||||
fn render_add_account(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
name_input: &str,
|
||||
_cursor_position: usize,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Input field
|
||||
let input_display = if name_input.is_empty() {
|
||||
Span::styled("_", Style::default().fg(Color::DarkGray))
|
||||
} else {
|
||||
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
|
||||
input_display,
|
||||
]));
|
||||
|
||||
// Hint
|
||||
lines.push(Line::from(Span::styled(
|
||||
" (a-z, 0-9, -, _)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Error
|
||||
if let Some(err) = error {
|
||||
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Help bar
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||
Span::styled("Create", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||
Span::styled("Back", Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
|
||||
let height = if error.is_some() { 10 } else { 8 };
|
||||
let height = (height as u16).min(area.height.saturating_sub(4));
|
||||
let width = 40u16.min(area.width.saturating_sub(4));
|
||||
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
let modal_area = Rect::new(x, y, width, height);
|
||||
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
let modal = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(" НОВЫЙ АККАУНТ ")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
8
crates/tele-tui/src/ui/modals/delete_confirm.rs
Normal file
8
crates/tele-tui/src/ui/modals/delete_confirm.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Delete confirmation modal
|
||||
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
|
||||
/// Renders delete confirmation modal
|
||||
pub fn render(f: &mut Frame, area: Rect) {
|
||||
crate::ui::components::modal::render_delete_confirm_modal(f, area);
|
||||
}
|
||||
178
crates/tele-tui/src/ui/modals/image_viewer.rs
Normal file
178
crates/tele-tui/src/ui/modals/image_viewer.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! Модальное окно для полноэкранного просмотра изображений.
|
||||
//!
|
||||
//! Поддерживает:
|
||||
//! - Автоматическое масштабирование с сохранением aspect ratio
|
||||
//! - Максимизация по ширине/высоте терминала
|
||||
//! - Затемнение фона
|
||||
//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tdlib::r#trait::TdClientTrait;
|
||||
use crate::tdlib::ImageModalState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use ratatui_image::StatefulImage;
|
||||
|
||||
/// Рендерит модальное окно с полноэкранным изображением
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>, modal_state: &ImageModalState) {
|
||||
let area = f.area();
|
||||
|
||||
// Затемняем весь фон
|
||||
f.render_widget(Clear, area);
|
||||
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
|
||||
|
||||
// Резервируем место для подсказок (2 строки внизу)
|
||||
let image_area_height = area.height.saturating_sub(2);
|
||||
|
||||
// Вычисляем размер изображения с сохранением aspect ratio
|
||||
let (img_width, img_height) = calculate_modal_size(
|
||||
modal_state.photo_width,
|
||||
modal_state.photo_height,
|
||||
area.width,
|
||||
image_area_height,
|
||||
);
|
||||
|
||||
// Центрируем изображение
|
||||
let img_x = (area.width.saturating_sub(img_width)) / 2;
|
||||
let img_y = (image_area_height.saturating_sub(img_height)) / 2;
|
||||
let img_rect = Rect::new(img_x, img_y, img_width, img_height);
|
||||
|
||||
// Рендерим изображение (используем modal_renderer для высокого качества)
|
||||
if let Some(renderer) = &mut app.modal_image_renderer {
|
||||
// Проверяем есть ли протокол уже в кеше
|
||||
if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) {
|
||||
// Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество)
|
||||
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||
} else {
|
||||
// Протокола нет - показываем индикатор загрузки
|
||||
let loading_text = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"⏳ Загрузка изображения...",
|
||||
Style::default().fg(Color::Gray),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"(декодирование в высоком качестве)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
];
|
||||
let loading = Paragraph::new(loading_text)
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default());
|
||||
f.render_widget(loading, img_rect);
|
||||
|
||||
// Загружаем изображение (может занять время для iTerm2/Sixel)
|
||||
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
|
||||
|
||||
// Триггерим перерисовку для показа загруженного изображения
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Подсказки внизу
|
||||
let hint = "[Esc/q] Закрыть [←/→] Пред/След фото";
|
||||
let hint_y = area.height.saturating_sub(1);
|
||||
let hint_rect = Rect::new(0, hint_y, area.width, 1);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray)))
|
||||
.alignment(Alignment::Center),
|
||||
hint_rect,
|
||||
);
|
||||
|
||||
// Информация о размере (опционально)
|
||||
let info = format!(
|
||||
"{}x{} | {:.1}%",
|
||||
modal_state.photo_width,
|
||||
modal_state.photo_height,
|
||||
(img_width as f64 / modal_state.photo_width as f64) * 100.0
|
||||
);
|
||||
let info_y = area.height.saturating_sub(2);
|
||||
let info_rect = Rect::new(0, info_y, area.width, 1);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray)))
|
||||
.alignment(Alignment::Center),
|
||||
info_rect,
|
||||
);
|
||||
}
|
||||
|
||||
/// Вычисляет размер изображения для модалки с сохранением aspect ratio.
|
||||
///
|
||||
/// # Логика масштабирования:
|
||||
/// - Если изображение меньше терминала → показываем как есть
|
||||
/// - Если ширина больше → масштабируем по ширине
|
||||
/// - Если высота больше → масштабируем по высоте
|
||||
/// - Сохраняем aspect ratio
|
||||
fn calculate_modal_size(
|
||||
img_width: i32,
|
||||
img_height: i32,
|
||||
term_width: u16,
|
||||
term_height: u16,
|
||||
) -> (u16, u16) {
|
||||
let aspect_ratio = img_width as f64 / img_height as f64;
|
||||
|
||||
// Если изображение помещается целиком
|
||||
if img_width <= term_width as i32 && img_height <= term_height as i32 {
|
||||
return (img_width as u16, img_height as u16);
|
||||
}
|
||||
|
||||
// Начинаем с максимального размера терминала
|
||||
let mut width = term_width as f64;
|
||||
let mut height = term_height as f64;
|
||||
|
||||
// Подгоняем по aspect ratio
|
||||
let term_aspect = width / height;
|
||||
|
||||
if term_aspect > aspect_ratio {
|
||||
// Терминал шире → ограничены по высоте
|
||||
width = height * aspect_ratio;
|
||||
} else {
|
||||
// Терминал выше → ограничены по ширине
|
||||
height = width / aspect_ratio;
|
||||
}
|
||||
|
||||
(width as u16, height as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calculate_modal_size_fits() {
|
||||
// Изображение помещается целиком
|
||||
let (w, h) = calculate_modal_size(50, 30, 100, 50);
|
||||
assert_eq!(w, 50);
|
||||
assert_eq!(h, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_modal_size_scale_width() {
|
||||
// Ограничены по ширине (изображение шире терминала)
|
||||
let (w, h) = calculate_modal_size(200, 100, 100, 100);
|
||||
assert_eq!(w, 100);
|
||||
assert_eq!(h, 50); // aspect ratio 2:1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_modal_size_scale_height() {
|
||||
// Ограничены по высоте (изображение выше терминала)
|
||||
let (w, h) = calculate_modal_size(100, 200, 100, 100);
|
||||
assert_eq!(w, 50); // aspect ratio 1:2
|
||||
assert_eq!(h, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_modal_size_aspect_ratio() {
|
||||
// Проверка сохранения aspect ratio
|
||||
let (w, h) = calculate_modal_size(1920, 1080, 100, 100);
|
||||
let aspect = w as f64 / h as f64;
|
||||
let expected_aspect = 1920.0 / 1080.0;
|
||||
assert!((aspect - expected_aspect).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
27
crates/tele-tui/src/ui/modals/mod.rs
Normal file
27
crates/tele-tui/src/ui/modals/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Modal dialog rendering modules
|
||||
//!
|
||||
//! Contains UI rendering for various modal dialogs:
|
||||
//! - account_switcher: Account switcher modal (global overlay)
|
||||
//! - delete_confirm: Delete confirmation modal
|
||||
//! - reaction_picker: Emoji reaction picker modal
|
||||
//! - search: Message search modal
|
||||
//! - pinned: Pinned messages viewer modal
|
||||
//! - image_viewer: Full-screen image viewer modal (images feature)
|
||||
|
||||
pub mod account_switcher;
|
||||
pub mod delete_confirm;
|
||||
pub mod pinned;
|
||||
pub mod reaction_picker;
|
||||
pub mod search;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub mod image_viewer;
|
||||
|
||||
pub use account_switcher::render as render_account_switcher;
|
||||
pub use delete_confirm::render as render_delete_confirm;
|
||||
pub use pinned::render as render_pinned;
|
||||
pub use reaction_picker::render as render_reaction_picker;
|
||||
pub use search::render as render_search;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub use image_viewer::render as render_image_viewer;
|
||||
91
crates/tele-tui/src/ui/modals/pinned.rs
Normal file
91
crates/tele-tui/src/ui/modals/pinned.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! Pinned messages viewer modal
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders pinned messages mode
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
// Извлекаем данные из ChatState
|
||||
let (messages, selected_index) =
|
||||
if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state
|
||||
{
|
||||
(messages.as_slice(), *selected_index)
|
||||
} else {
|
||||
return; // Некорректное состояние
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(0), // Pinned messages list
|
||||
Constraint::Length(3), // Help bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let total = messages.len();
|
||||
let current = selected_index + 1;
|
||||
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
|
||||
let header = Paragraph::new(header_text)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Magenta)),
|
||||
)
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Pinned messages list
|
||||
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
for (idx, msg) in messages.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Нет закреплённых сообщений",
|
||||
Style::default().fg(Color::Gray),
|
||||
)));
|
||||
}
|
||||
|
||||
// Скролл к выбранному сообщению
|
||||
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
|
||||
|
||||
let messages_widget = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Magenta)),
|
||||
)
|
||||
.scroll((scroll_offset, 0));
|
||||
f.render_widget(messages_widget, chunks[1]);
|
||||
|
||||
// Help bar
|
||||
let help = render_help_bar(
|
||||
&[
|
||||
("↑↓", "навигация", Color::Yellow),
|
||||
("Enter", "перейти", Color::Green),
|
||||
("Esc", "выход", Color::Red),
|
||||
],
|
||||
Color::Magenta,
|
||||
);
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
8
crates/tele-tui/src/ui/modals/reaction_picker.rs
Normal file
8
crates/tele-tui/src/ui/modals/reaction_picker.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Reaction picker modal
|
||||
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
|
||||
/// Renders emoji reaction picker modal
|
||||
pub fn render(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) {
|
||||
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
|
||||
}
|
||||
110
crates/tele-tui/src/ui/modals/search.rs
Normal file
110
crates/tele-tui/src/ui/modals/search.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! Message search modal
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders message search mode
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
// Извлекаем данные из ChatState
|
||||
let (query, results, selected_index) =
|
||||
if let crate::app::ChatState::SearchInChat { query, results, selected_index } =
|
||||
&app.chat_state
|
||||
{
|
||||
(query.as_str(), results.as_slice(), *selected_index)
|
||||
} else {
|
||||
return; // Некорректное состояние, не рендерим
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Search input
|
||||
Constraint::Min(0), // Search results
|
||||
Constraint::Length(3), // Help bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Search input
|
||||
let total = results.len();
|
||||
let current = if total > 0 { selected_index + 1 } else { 0 };
|
||||
|
||||
let input_line = if query.is_empty() {
|
||||
Line::from(vec![
|
||||
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(query, Style::default().fg(Color::White)),
|
||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
|
||||
])
|
||||
};
|
||||
|
||||
let search_input = Paragraph::new(input_line).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.title(" Поиск по сообщениям ")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
);
|
||||
f.render_widget(search_input, chunks[0]);
|
||||
|
||||
// Search results
|
||||
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if results.is_empty() {
|
||||
if !query.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Ничего не найдено",
|
||||
Style::default().fg(Color::Gray),
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
for (idx, msg) in results.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Скролл к выбранному результату
|
||||
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
|
||||
|
||||
let results_widget = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow)),
|
||||
)
|
||||
.scroll((scroll_offset, 0));
|
||||
f.render_widget(results_widget, chunks[1]);
|
||||
|
||||
// Help bar
|
||||
let help = render_help_bar(
|
||||
&[
|
||||
("↑↓", "навигация", Color::Yellow),
|
||||
("n/N", "след./пред.", Color::Yellow),
|
||||
("Enter", "перейти", Color::Green),
|
||||
("Esc", "выход", Color::Red),
|
||||
],
|
||||
Color::Yellow,
|
||||
);
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
287
crates/tele-tui/src/ui/profile.rs
Normal file
287
crates/tele-tui/src/ui/profile.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::ProfileInfo;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Рендерит режим просмотра профиля
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, profile: &ProfileInfo) {
|
||||
// Проверяем, показывать ли модалку подтверждения
|
||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||
if confirmation_step > 0 {
|
||||
render_leave_confirmation_modal(f, area, confirmation_step);
|
||||
return;
|
||||
}
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(0), // Profile info
|
||||
Constraint::Length(3), // Actions help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let header_text = format!("👤 ПРОФИЛЬ: {}", profile.title);
|
||||
let header = Paragraph::new(header_text)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Profile info
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Тип чата
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Тип: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(&profile.chat_type, Style::default().fg(Color::White)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// ID
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("ID: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Username
|
||||
if let Some(username) = &profile.username {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Username: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(username, Style::default().fg(Color::Cyan)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Phone number (только для личных чатов)
|
||||
if let Some(phone) = &profile.phone_number {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Телефон: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(phone, Style::default().fg(Color::White)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Online status (только для личных чатов)
|
||||
if let Some(status) = &profile.online_status {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Статус: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(status, Style::default().fg(Color::Green)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Bio (только для личных чатов)
|
||||
if let Some(bio) = &profile.bio {
|
||||
lines.push(Line::from(vec![Span::styled("О себе: ", Style::default().fg(Color::Gray))]));
|
||||
// Разбиваем bio на строки если длинное
|
||||
let bio_lines: Vec<&str> = bio.lines().collect();
|
||||
for bio_line in bio_lines {
|
||||
lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White))));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Member count (для групп/каналов)
|
||||
if let Some(count) = profile.member_count {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Участников: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(format!("{}", count), Style::default().fg(Color::White)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Description (для групп/каналов)
|
||||
if let Some(desc) = &profile.description {
|
||||
lines.push(Line::from(vec![Span::styled("Описание: ", Style::default().fg(Color::Gray))]));
|
||||
let desc_lines: Vec<&str> = desc.lines().collect();
|
||||
for desc_line in desc_lines {
|
||||
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Invite link (для групп/каналов)
|
||||
if let Some(link) = &profile.invite_link {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
link,
|
||||
Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Разделитель
|
||||
lines.push(Line::from("────────────────────────────────"));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Действия
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Действия:",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let actions = get_available_actions(profile);
|
||||
for (idx, action) in actions.iter().enumerate() {
|
||||
let is_selected = idx == app.get_selected_profile_action().unwrap_or(0);
|
||||
let marker = if is_selected { "▶ " } else { " " };
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(marker, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(*action, style),
|
||||
]));
|
||||
}
|
||||
|
||||
let info_widget = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.scroll((0, 0));
|
||||
f.render_widget(info_widget, chunks[1]);
|
||||
|
||||
// Help bar
|
||||
let help_line = Line::from(vec![
|
||||
Span::styled(
|
||||
" ↑↓ ",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("навигация"),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
" Enter ",
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("выбрать"),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::raw("выход"),
|
||||
]);
|
||||
let help = Paragraph::new(help_line)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
/// Получить список доступных действий
|
||||
fn get_available_actions(profile: &ProfileInfo) -> Vec<&'static str> {
|
||||
let mut actions = vec![];
|
||||
|
||||
if profile.username.is_some() {
|
||||
actions.push("Открыть в браузере");
|
||||
}
|
||||
|
||||
actions.push("Скопировать ID");
|
||||
|
||||
if profile.is_group {
|
||||
actions.push("Покинуть группу");
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
/// Рендерит модалку подтверждения выхода из группы
|
||||
fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
|
||||
// Затемняем фон
|
||||
let modal_area = centered_rect(60, 30, area);
|
||||
|
||||
let text = if step == 1 {
|
||||
"Вы хотите выйти из группы?"
|
||||
} else {
|
||||
"Вы ТОЧНО хотите выйти из группы?!?!?"
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"y/н/Enter",
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" — да "),
|
||||
Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" — нет"),
|
||||
]),
|
||||
];
|
||||
|
||||
let modal = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Red))
|
||||
.title(" ⚠ ВНИМАНИЕ ")
|
||||
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
|
||||
/// Вспомогательная функция для центрирования прямоугольника
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(r);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
Reference in New Issue
Block a user