use crate::app::App; use crate::formatting; use crate::ui::components; use crate::utils::{format_date, format_timestamp_with_tz, get_day}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; fn render_input_with_cursor( prefix: &str, text: &str, cursor_pos: usize, color: Color, ) -> Line<'static> { // Используем компонент input_field components::render_input_field(prefix, text, cursor_pos, color) } /// Информация о строке после переноса: текст и позиция в оригинале struct WrappedLine { text: String, /// Начальная позиция в символах от начала оригинального текста start_offset: usize, } /// Разбивает текст на строки с учётом максимальной ширины /// Возвращает строки с информацией о позициях для корректного применения entities fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if max_width == 0 { 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; // Разбиваем текст на слова, сохраняя позиции let chars: Vec = 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; line_start_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 = 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: 0 }); } result } pub fn render(f: &mut Frame, area: Rect, app: &App) { // Режим профиля 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() { render_search_mode(f, area, app); return; } // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { render_pinned_mode(f, area, app); return; } if let Some(chat) = app.get_selected_chat() { // Вычисляем динамическую высоту инпута на основе длины текста let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " let input_text_len = app.message_input.chars().count() + 2; // +2 для "> " let input_lines = if input_width > 0 { ((input_text_len as f32 / input_width as f32).ceil() as u16).max(1) } else { 1 }; // Минимум 3 строки (1 контент + 2 рамки), максимум 10 let input_height = (input_lines + 2).min(10).max(3); // Проверяем, есть ли закреплённое сообщение 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), // Chat header Constraint::Length(1), // Pinned bar Constraint::Min(0), // Messages Constraint::Length(input_height), // Input box (динамическая высота) ]) .split(area) } else { Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Chat header Constraint::Length(0), // Pinned bar (hidden) Constraint::Min(0), // Messages Constraint::Length(input_height), // Input box (динамическая высота) ]) .split(area) }; // Chat header с typing status let typing_action = app .td_client .typing_status() .as_ref() .map(|(_, action, _)| action.clone()); let header_line = if let Some(action) = typing_action { // Показываем typing status: "👤 Имя @username печатает..." 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 { // Показываем username 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, message_chunks[0]); // Pinned bar (если есть закреплённое сообщение) if let Some(pinned_msg) = &app.td_client.current_pinned_message() { 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 = message_chunks[1].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, message_chunks[1]); } // Ширина области сообщений (без рамок) let content_width = message_chunks[2].width.saturating_sub(2) as usize; // Messages с группировкой по дате и отправителю let mut lines: Vec = Vec::new(); let mut last_day: Option = None; let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) // ID выбранного сообщения для подсветки let selected_msg_id = app.get_selected_message().map(|m| m.id()); // Номер строки, где начинается выбранное сообщение (для автоскролла) let mut selected_msg_line: Option = None; for msg in app.td_client.current_chat_messages() { // Проверяем, выбрано ли это сообщение let is_selected = selected_msg_id == Some(msg.id()); // Запоминаем строку начала выбранного сообщения if is_selected { selected_msg_line = Some(lines.len()); } // Проверяем, нужно ли добавить разделитель даты let msg_day = get_day(msg.date()); if last_day != Some(msg_day) { if last_day.is_some() { lines.push(Line::from("")); // Пустая строка перед разделителем } // Добавляем разделитель даты по центру let date_str = format_date(msg.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("")); last_day = Some(msg_day); last_sender = None; // Сбрасываем отправителя при смене дня } let sender_name = if msg.is_outgoing() { "Вы".to_string() } else { msg.sender_name().to_string() }; let current_sender = (msg.is_outgoing(), sender_name.clone()); // Проверяем, нужно ли показать заголовок отправителя let show_sender_header = last_sender.as_ref() != Some(¤t_sender); if show_sender_header { // Пустая строка между группами сообщений (кроме первой) if last_sender.is_some() { lines.push(Line::from("")); } let sender_style = if msg.is_outgoing() { Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD) } else { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) }; if msg.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)), ])); } last_sender = Some(current_sender); } // Форматируем время (HH:MM) с учётом timezone из config let time = format_timestamp_with_tz(msg.date(), &app.config.general.timezone); // Цвет сообщения (из config или жёлтый если выбрано) let msg_color = if is_selected { app.config.parse_color(&app.config.colors.selected_message) } else if msg.is_outgoing() { app.config.parse_color(&app.config.colors.outgoing_message) } else { app.config.parse_color(&app.config.colors.incoming_message) }; // Маркер выбора let selection_marker = if is_selected { "▶ " } else { "" }; let marker_len = selection_marker.chars().count(); // Отображаем 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() { // Forward справа для исходящих 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 { // Forward слева для входящих 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() { // Reply справа для исходящих 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 { // Reply слева для входящих lines.push(Line::from(vec![Span::styled( reply_line, Style::default().fg(Color::Cyan), )])); } } 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; // +1 для пробела // Максимальная ширина для текста сообщения (оставляем место для time_mark и маркера) 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(); // Получаем entities для этой строки let line_entities = formatting::adjust_entities_for_substring( msg.entities(), wrapped.start_offset, line_len, ); // Форматируем текст с entities let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); if is_last_line { // Последняя строка — добавляем time_mark 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 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 + 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 edit_mark = if msg.is_edited() { " ✎" } 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_with_offsets(msg.text(), max_msg_width); for (i, wrapped) in wrapped_lines.into_iter().enumerate() { let line_len = wrapped.text.chars().count(); // Получаем entities для этой строки let line_entities = formatting::adjust_entities_for_substring( msg.entities(), wrapped.start_offset, line_len, ); // Форматируем текст с entities let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); if i == 0 { // Первая строка — с временем и маркером выбора 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 + 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(" ")); } // Свои реакции в рамках [emoji], чужие просто emoji 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(app.config.parse_color(&app.config.colors.reaction_chosen)) } else { Style::default() .fg(app.config.parse_color(&app.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::>() .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 lines.is_empty() { lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); } // Вычисляем скролл с учётом пользовательского offset let visible_height = message_chunks[2].height.saturating_sub(2) as usize; let total_lines = lines.len(); // Базовый скролл (показываем последние сообщения) let base_scroll = if total_lines > visible_height { total_lines - visible_height } else { 0 }; // Если выбрано сообщение, автоскроллим к нему let scroll_offset = 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, message_chunks[2]); // Input box с wrap для длинного текста и блочным курсором let (input_line, input_title) = 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 .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 ред. · 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() { // Пустой инпут - показываем курсор и 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 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.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_block = if input_title.is_empty() { Block::default().borders(Borders::ALL) } 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, message_chunks[3]); } 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() { render_delete_confirm_modal(f, area); } // Модалка выбора реакции if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } = &app.chat_state { render_reaction_picker_modal(f, area, available_reactions, *selected_index); } } /// Рендерит режим поиска по сообщениям fn render_search_mode(f: &mut Frame, area: Rect, app: &App) { // Извлекаем данные из 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 = 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() { let is_selected = idx == selected_index; // Пустая строка между результатами if idx > 0 { lines.push(Line::from("")); } // Маркер выбора, имя и дата 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, 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), ), ])); // Текст сообщения (с переносом) let msg_color = if is_selected { Color::Yellow } else { Color::White }; let max_width = content_width.saturating_sub(4); let wrapped = wrap_text_with_offsets(msg.text(), max_width); let wrapped_count = wrapped.len(); for wrapped_line in wrapped.into_iter().take(2) { lines.push(Line::from(vec![ Span::raw(" "), Span::styled(wrapped_line.text, Style::default().fg(msg_color)), ])); } if wrapped_count > 2 { lines.push(Line::from(vec![ Span::raw(" "), Span::styled("...", Style::default().fg(Color::Gray)), ])); } } } // Скролл к выбранному результату let visible_height = chunks[1].height.saturating_sub(2) as usize; let lines_per_result = 4; let selected_line = selected_index * lines_per_result; let scroll_offset = if selected_line > visible_height / 2 { (selected_line - visible_height / 2) as u16 } else { 0 }; 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_line = Line::from(vec![ Span::styled( " ↑↓ ", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), Span::raw("навигация"), Span::raw(" "), Span::styled( " n/N ", 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::Yellow)), ) .alignment(Alignment::Center); f.render_widget(help, chunks[2]); } /// Рендерит режим просмотра закреплённых сообщений fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) { // Извлекаем данные из 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 = Vec::new(); for (idx, msg) in messages.iter().enumerate() { let is_selected = idx == selected_index; // Пустая строка между сообщениями if idx > 0 { lines.push(Line::from("")); } // Маркер выбора и имя отправителя 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, 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), ), ])); // Текст сообщения (с переносом) let msg_color = if is_selected { Color::Yellow } else { Color::White }; let max_width = content_width.saturating_sub(4); let wrapped = wrap_text_with_offsets(msg.text(), max_width); let wrapped_count = wrapped.len(); for wrapped_line in wrapped.into_iter().take(3) { // Максимум 3 строки на сообщение lines.push(Line::from(vec![ Span::raw(" "), // Отступ Span::styled(wrapped_line.text, Style::default().fg(msg_color)), ])); } if wrapped_count > 3 { lines.push(Line::from(vec![ Span::raw(" "), Span::styled("...", Style::default().fg(Color::Gray)), ])); } } if lines.is_empty() { lines.push(Line::from(Span::styled( "Нет закреплённых сообщений", Style::default().fg(Color::Gray), ))); } // Скролл к выбранному сообщению let visible_height = chunks[1].height.saturating_sub(2) as usize; let lines_per_msg = 5; // Примерно строк на сообщение let selected_line = selected_index * lines_per_msg; let scroll_offset = if selected_line > visible_height / 2 { (selected_line - visible_height / 2) as u16 } else { 0 }; 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_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::Magenta)), ) .alignment(Alignment::Center); f.render_widget(help, chunks[2]); } /// Рендерит модалку подтверждения удаления fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { components::modal::render_delete_confirm_modal(f, area); } /// Рендерит модалку выбора реакции fn render_reaction_picker_modal( f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize, ) { components::render_emoji_picker(f, area, available_reactions, selected_index); }