diff --git a/src/ui/messages.rs b/src/ui/messages.rs index bad2900..d2d8895 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -191,6 +191,250 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { result } +/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом +fn render_message_list(f: &mut Frame, area: Rect, app: &App) { + let content_width = area.width.saturating_sub(2) as usize; + + // Messages с группировкой по дате и отправителю + let mut lines: Vec = Vec::new(); + + // ID выбранного сообщения для подсветки + let selected_msg_id = app.get_selected_message().map(|m| m.id()); + // Номер строки, где начинается выбранное сообщение (для автоскролла) + let mut selected_msg_line: Option = None; + + // Используем message_grouping для группировки сообщений + let grouped = group_messages(&app.td_client.current_chat_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()); + } + + // Рендерим сообщение + lines.extend(components::render_message_bubble( + &msg, + app.config(), + content_width, + selected_msg_id, + )); + } + } + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); + } + + // Вычисляем скролл с учётом пользовательского offset + let visible_height = area.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, area); +} + +/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal) +fn render_input_box(f: &mut Frame, area: Rect, app: &App) { + 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 + .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() { + // Пустой инпут - показываем курсор и 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, area); +} + pub fn render(f: &mut Frame, area: Rect, app: &App) { // Режим профиля @@ -256,245 +500,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // Pinned bar (если есть закреплённое сообщение) render_pinned_bar(f, message_chunks[1], app); - // Ширина области сообщений (без рамок) - let content_width = message_chunks[2].width.saturating_sub(2) as usize; - // Messages с группировкой по дате и отправителю - let mut lines: Vec = Vec::new(); - - // ID выбранного сообщения для подсветки - let selected_msg_id = app.get_selected_message().map(|m| m.id()); - // Номер строки, где начинается выбранное сообщение (для автоскролла) - let mut selected_msg_line: Option = None; - - // Используем message_grouping для группировки сообщений - let grouped = group_messages(&app.td_client.current_chat_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()); - } - - // Рендерим сообщение - lines.extend(components::render_message_bubble( - &msg, - app.config(), - content_width, - selected_msg_id, - )); - } - } - } - - 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]); + render_message_list(f, message_chunks[2], app); // 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 - .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() { - // Пустой инпут - показываем курсор и 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]); + render_input_box(f, message_chunks[3], app); } else { let empty = Paragraph::new("Выберите чат") .block(Block::default().borders(Borders::ALL))