This commit is contained in:
Mikhail Kilin
2026-01-24 02:22:47 +03:00
parent c18f43664e
commit 22c4e17377
9 changed files with 1199 additions and 226 deletions

View File

@@ -8,7 +8,13 @@ use ratatui::{
use crate::app::App;
use super::{chat_list, messages, footer};
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
pub fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
let is_compact = area.width < COMPACT_WIDTH;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -16,20 +22,33 @@ pub fn render(f: &mut Frame, app: &mut App) {
Constraint::Min(0), // Main content
Constraint::Length(1), // Commands footer
])
.split(f.area());
.split(area);
render_folders(f, chunks[0], app);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Chat list
Constraint::Percentage(70), // Messages area
])
.split(chunks[1]);
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);
}
chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], app);
footer::render(f, chunks[2], app);
}

View File

@@ -7,48 +7,305 @@ use ratatui::{
};
use crate::app::App;
use crate::utils::{format_timestamp, format_date, get_day};
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
/// Структура для хранения стиля символа
#[derive(Clone, Default)]
struct CharStyle {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
code: bool,
spoiler: bool,
url: bool,
mention: bool,
}
impl CharStyle {
fn to_style(&self, base_color: Color) -> Style {
let mut style = Style::default();
if self.code {
// Код отображается cyan на тёмном фоне
style = style.fg(Color::Cyan).bg(Color::DarkGray);
} else if self.spoiler {
// Спойлер — серый текст (скрытый)
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
} else if self.url || self.mention {
// Ссылки и упоминания — синий с подчёркиванием
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
} else {
style = style.fg(base_color);
}
if self.bold {
style = style.add_modifier(Modifier::BOLD);
}
if self.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if self.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if self.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
style
}
}
/// Преобразует текст с entities в вектор стилизованных Span (owned)
fn format_text_with_entities(text: &str, entities: &[TextEntity], base_color: Color) -> Vec<Span<'static>> {
if entities.is_empty() {
return vec![Span::styled(text.to_string(), Style::default().fg(base_color))];
}
// Создаём массив стилей для каждого символа
let chars: Vec<char> = text.chars().collect();
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
// Применяем entities к символам
for entity in entities {
let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize;
for i in start..end.min(chars.len()) {
match &entity.r#type {
TextEntityType::Bold => char_styles[i].bold = true,
TextEntityType::Italic => char_styles[i].italic = true,
TextEntityType::Underline => char_styles[i].underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
char_styles[i].code = true
}
TextEntityType::Spoiler => char_styles[i].spoiler = true,
TextEntityType::Url | TextEntityType::TextUrl(_) | TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => char_styles[i].mention = true,
_ => {}
}
}
}
// Группируем последовательные символы с одинаковым стилем
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CharStyle> = None;
for (i, ch) in chars.iter().enumerate() {
let style = &char_styles[i];
match &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_style {
spans.push(Span::styled(
current_text.clone(),
prev_style.to_style(base_color),
));
}
}
current_text = ch.to_string();
current_style = Some(style.clone());
}
}
}
// Добавляем последний span
if !current_text.is_empty() {
if let Some(style) = current_style {
spans.push(Span::styled(current_text, style.to_style(base_color)));
}
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
}
spans
}
/// Проверяет равенство двух стилей
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
a.bold == b.bold
&& a.italic == b.italic
&& a.underline == b.underline
&& a.strikethrough == b.strikethrough
&& a.code == b.code
&& a.spoiler == b.spoiler
&& a.url == b.url
&& a.mention == b.mention
}
/// Рендерит текст инпута с блочным курсором
fn render_input_with_cursor<'a>(prefix: &'a str, text: &str, cursor_pos: usize, color: Color) -> Line<'a> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
// Текст до курсора
if cursor_pos > 0 {
let before: String = chars[..cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if cursor_pos < chars.len() {
let cursor_char = chars[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 cursor_pos + 1 < chars.len() {
let after: String = chars[cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
}
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
/// Начальная позиция в символах от начала оригинального текста
start_offset: usize,
}
/// Разбивает текст на строки с учётом максимальной ширины
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
/// Возвращает строки с информацией о позициях для корректного применения entities
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![text.to_string()];
return vec![WrappedLine {
text: text.to_string(),
start_offset: 0,
}];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_start_offset = 0;
for word in text.split_whitespace() {
// Разбиваем текст на слова, сохраняя позиции
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 = 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 = 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.to_string();
current_width = word_width;
current_line = word;
line_start_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;
current_line.push_str(&word);
} else {
// Слово не помещается, начинаем новую строку
result.push(current_line);
current_line = word.to_string();
current_width = word_width;
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
line_start_offset = word_start;
}
}
if !current_line.is_empty() {
result.push(current_line);
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
}
if result.is_empty() {
result.push(String::new());
result.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
}
result
}
/// Фильтрует и корректирует entities для подстроки
fn adjust_entities_for_substring(
entities: &[TextEntity],
start: usize,
length: usize,
) -> Vec<TextEntity> {
let start = start as i32;
let end = start + length as i32;
entities
.iter()
.filter_map(|e| {
let e_start = e.offset;
let e_end = e.offset + e.length;
// Проверяем пересечение с нашей подстрокой
if e_end <= start || e_start >= end {
return None;
}
// Вычисляем пересечение
let new_start = (e_start - start).max(0);
let new_end = (e_end - start).min(length as i32);
if new_end > new_start {
Some(TextEntity {
offset: new_start,
length: new_end - new_start,
r#type: e.r#type.clone(),
})
} else {
None
}
})
.collect()
}
pub fn render(f: &mut Frame, area: Rect, app: &App) {
if let Some(chat) = app.get_selected_chat() {
// Вычисляем динамическую высоту инпута на основе длины текста
@@ -93,7 +350,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let mut last_day: Option<i64> = None;
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
for msg in &app.current_messages {
// ID выбранного сообщения для подсветки
let selected_msg_id = app.get_selected_message().map(|m| m.id);
for msg in &app.td_client.current_chat_messages {
// Проверяем, выбрано ли это сообщение
let is_selected = selected_msg_id == Some(msg.id);
// Проверяем, нужно ли добавить разделитель даты
let msg_day = get_day(msg.date);
if last_day != Some(msg_day) {
@@ -160,64 +422,116 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Форматируем время (HH:MM)
let time = format_timestamp(msg.date);
// Цвет сообщения (жёлтый если выбрано)
let msg_color = if is_selected {
Color::Yellow
} else if msg.is_outgoing {
Color::Green
} else {
Color::White
};
// Маркер выбора
let selection_marker = if is_selected { "" } else { "" };
let marker_len = selection_marker.chars().count();
if msg.is_outgoing {
// Исходящие: справа, формат "текст (HH:MM ✓✓)"
// Исходящие: справа, формат "текст (HH:MM ✓✓)"
let read_mark = if msg.is_read { "✓✓" } else { "" };
let time_mark = format!("({} {})", time, read_mark);
let edit_mark = if msg.edit_date > 0 { "" } else { "" };
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела
// Максимальная ширина для текста сообщения (оставляем место для time_mark)
let max_msg_width = content_width.saturating_sub(time_mark_len + 2);
// Максимальная ширина для текста сообщения (оставляем место для time_mark и маркера)
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
let wrapped_lines = wrap_text(&msg.content, max_msg_width);
let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width);
let total_wrapped = wrapped_lines.len();
for (i, line_text) in wrapped_lines.into_iter().enumerate() {
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let is_last_line = i == total_wrapped - 1;
let line_len = line_text.chars().count();
let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки
let line_entities = adjust_entities_for_substring(
&msg.entities,
wrapped.start_offset,
line_len,
);
// Форматируем текст с entities
let formatted_spans = format_text_with_entities(
&wrapped.text,
&line_entities,
msg_color,
);
if is_last_line {
// Последняя строка — добавляем time_mark
let full_len = line_len + time_mark_len;
let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(line_text, Style::default().fg(Color::Green)),
Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)),
]));
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
}
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 + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(line_text, Style::default().fg(Color::Green)),
]));
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 && is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
}
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
} else {
// Входящие: слева, формат "(HH:MM) текст"
let time_str = format!("({})", time);
// Входящие: слева, формат "(HH:MM) текст"
let edit_mark = if msg.edit_date > 0 { "" } else { "" };
let time_str = format!("({}{})", time, edit_mark);
let time_prefix_len = time_str.chars().count() + 2; // " (HH:MM) "
// Максимальная ширина для текста
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
let wrapped_lines = wrap_text(&msg.content, max_msg_width);
let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width);
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки
let line_entities = adjust_entities_for_substring(
&msg.entities,
wrapped.start_offset,
line_len,
);
// Форматируем текст с entities
let formatted_spans = format_text_with_entities(
&wrapped.text,
&line_entities,
msg_color,
);
for (i, line_text) in wrapped_lines.into_iter().enumerate() {
if i == 0 {
// Первая строка — с временем
lines.push(Line::from(vec![
Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)),
Span::raw(format!(" {}", line_text)),
]));
// Первая строка — с временем и маркером выбора
let mut line_spans = vec![];
if is_selected {
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);
lines.push(Line::from(vec![
Span::raw(indent),
Span::raw(line_text),
]));
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));
}
}
}
@@ -247,20 +561,63 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, message_chunks[1]);
// Input box с wrap для длинного текста
let input_text = if app.message_input.is_empty() {
"> Введите сообщение...".to_string()
// Input box с wrap для длинного текста и блочным курсором
let (input_line, input_title) = if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false);
let can_delete = selected_msg.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 редакт. · d удалить · Esc отмена",
(true, false) => "↑↓ выбрать · Enter редакт. · Esc отмена",
(false, true) => "↑↓ выбрать · d удалить · Esc отмена",
(false, false) => "↑↓ выбрать · Esc отмена",
};
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
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 {
format!("> {}", app.message_input)
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
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_style = if app.message_input.is_empty() {
Style::default().fg(Color::Gray)
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
Style::default().fg(Color::Yellow)
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
};
let input = Paragraph::new(input_text)
.block(Block::default().borders(Borders::ALL))
.style(input_style)
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, message_chunks[2]);
} else {
@@ -270,4 +627,56 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
.alignment(Alignment::Center);
f.render_widget(empty, area);
}
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area);
}
}
/// Рендерит модалку подтверждения удаления
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
use ratatui::widgets::Clear;
// Размеры модалки
let modal_width = 40u16;
let modal_height = 7u16;
// Центрируем модалку
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 text = 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("Нет"),
]),
];
let modal = Paragraph::new(text)
.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);
}

View File

@@ -11,10 +11,10 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
use crate::app::{App, AppScreen};
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 80;
/// Минимальная высота терминала
const MIN_HEIGHT: u16 = 20;
const MIN_HEIGHT: u16 = 10;
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 40;
pub fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
@@ -34,7 +34,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
let message = format!(
"Терминал слишком мал: {}x{}\n\nМинимум: {}x{}",
"{}x{}\nМинимум: {}x{}",
width, height, MIN_WIDTH, MIN_HEIGHT
);
let warning = Paragraph::new(message)