add pinned messages

This commit is contained in:
Mikhail Kilin
2026-01-27 04:38:29 +03:00
parent 4d5625f950
commit 81dc5b9007
5 changed files with 395 additions and 13 deletions

View File

@@ -307,6 +307,12 @@ fn adjust_entities_for_substring(
}
pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Режим просмотра закреплённых сообщений
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 для "> "
@@ -319,14 +325,30 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
let input_height = (input_lines + 2).min(10).max(3);
let message_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Chat header
Constraint::Min(0), // Messages
Constraint::Length(input_height), // Input box (динамическая высота)
])
.split(area);
// Проверяем, есть ли закреплённое сообщение
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());
@@ -364,8 +386,31 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
.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.content.chars().take(40).collect();
let ellipsis = if pinned_msg.content.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[1].width.saturating_sub(2) as usize;
let content_width = message_chunks[2].width.saturating_sub(2) as usize;
// Messages с группировкой по дате и отправителю
let mut lines: Vec<Line> = Vec::new();
@@ -616,7 +661,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
// Вычисляем скролл с учётом пользовательского offset
let visible_height = message_chunks[1].height.saturating_sub(2) as usize;
let visible_height = message_chunks[2].height.saturating_sub(2) as usize;
let total_lines = lines.len();
// Базовый скролл (показываем последние сообщения)
@@ -650,7 +695,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let messages_widget = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, message_chunks[1]);
f.render_widget(messages_widget, message_chunks[2]);
// Input box с wrap для длинного текста и блочным курсором
let (input_line, input_title) = if app.is_forwarding() {
@@ -752,7 +797,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, message_chunks[2]);
f.render_widget(input, message_chunks[3]);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
@@ -767,6 +812,126 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
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 = app.pinned_messages.len();
let current = app.selected_pinned_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<Line> = Vec::new();
for (idx, msg) in app.pinned_messages.iter().enumerate() {
let is_selected = idx == app.selected_pinned_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.clone() };
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.content, 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 = app.selected_pinned_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) {
use ratatui::widgets::Clear;