refactor: extract message list and input box rendering (ui/messages.rs)

Завершена Phase 5 - полная декомпозиция render():
- render_message_list() - список сообщений с автоскроллом (~100 строк)
- render_input_box() - input с режимами forward/select/edit/reply (~145 строк)

Результат:
- render() сокращена с ~390 до ~92 строк (76% ✂️)
- 4 извлечённые функции: header, pinned, message_list, input_box
- Каждая функция имеет чёткую ответственность

Файл: 879 → 905 строк (+26 doc-комментариев)
Phase 5 завершена: ui/messages.rs рефакторинг выполнен! 🎉

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-03 21:30:20 +03:00
parent 315395f1f2
commit 2dbbf1cb5b

View File

@@ -191,6 +191,250 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
result
}
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let content_width = area.width.saturating_sub(2) as usize;
// Messages с группировкой по дате и отправителю
let mut lines: Vec<Line> = Vec::new();
// ID выбранного сообщения для подсветки
let selected_msg_id = app.get_selected_message().map(|m| m.id());
// Номер строки, где начинается выбранное сообщение (для автоскролла)
let mut selected_msg_line: Option<usize> = 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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля
@@ -256,245 +500,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// 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<Line> = Vec::new();
// ID выбранного сообщения для подсветки
let selected_msg_id = app.get_selected_message().map(|m| m.id());
// Номер строки, где начинается выбранное сообщение (для автоскролла)
let mut selected_msg_line: Option<usize> = 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))