refactor: complete UI components - implement message_bubble.rs
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
Finalize Priority 3 UI components refactoring (5/5 complete): - Create message_bubble.rs (437 lines) with 3 rendering functions: * render_date_separator() - centered date separators * render_sender_header() - sender headers (incoming/outgoing) * render_message_bubble() - messages with forward/reply/reactions - Simplify messages.rs by removing ~300 lines: * Use message_grouping::group_messages() for logic * Use UI components for rendering * Cleaner separation of concerns - Update module exports and main.rs All 196 tests passing (188 tests + 8 benchmarks). No regressions. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
use crate::app::App;
|
||||
use crate::formatting;
|
||||
use crate::message_grouping::{group_messages, MessageGroup};
|
||||
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},
|
||||
@@ -28,10 +27,13 @@ struct WrappedLine {
|
||||
}
|
||||
|
||||
/// Разбивает текст на строки с учётом максимальной ширины
|
||||
/// Возвращает строки с информацией о позициях для корректного применения entities
|
||||
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if max_width == 0 {
|
||||
return vec![WrappedLine { text: text.to_string(), start_offset: 0 }];
|
||||
return vec![WrappedLine {
|
||||
text: text.to_string(),
|
||||
start_offset: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
@@ -39,7 +41,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
let mut current_width = 0;
|
||||
let mut line_start_offset = 0;
|
||||
|
||||
// Разбиваем текст на слова, сохраняя позиции
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut word_start = 0;
|
||||
let mut in_word = false;
|
||||
@@ -47,7 +48,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
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();
|
||||
|
||||
@@ -76,7 +76,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
}
|
||||
}
|
||||
|
||||
// Обрабатываем последнее слово
|
||||
if in_word {
|
||||
let word: String = chars[word_start..].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
@@ -105,7 +104,10 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(WrappedLine { text: String::new(), start_offset: 0 });
|
||||
result.push(WrappedLine {
|
||||
text: String::new(),
|
||||
start_offset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
@@ -242,320 +244,52 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
// Messages с группировкой по дате и отправителю
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut last_day: Option<i64> = 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<usize> = None;
|
||||
|
||||
for msg in app.td_client.current_chat_messages() {
|
||||
// Проверяем, выбрано ли это сообщение
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
// Используем message_grouping для группировки сообщений
|
||||
let grouped = group_messages(app.td_client.current_chat_messages());
|
||||
let mut is_first_date = true;
|
||||
let mut is_first_sender = true;
|
||||
|
||||
// Запоминаем строку начала выбранного сообщения
|
||||
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("")); // Пустая строка перед разделителем
|
||||
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; // Сбрасываем счётчик заголовков после даты
|
||||
}
|
||||
// Добавляем разделитель даты по центру
|
||||
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(""));
|
||||
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;
|
||||
}
|
||||
|
||||
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(" "));
|
||||
MessageGroup::Message(msg) => {
|
||||
// Запоминаем строку начала выбранного сообщения
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
|
||||
// Свои реакции в рамках [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::<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));
|
||||
// Рендерим сообщение
|
||||
lines.extend(components::render_message_bubble(
|
||||
&msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user