Split core and TUI crates

This commit is contained in:
Mikhail Kilin
2026-05-20 00:31:18 +03:00
parent 91a8700b8e
commit eefac431e5
238 changed files with 624 additions and 191 deletions

View File

@@ -0,0 +1,286 @@
use crate::app::methods::messages::MessageMethods;
use crate::app::App;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Информация о строке после переноса: текст и позиция в оригинале.
pub(crate) struct WrappedLine {
pub text: String,
}
/// Разбивает текст на строки с учётом максимальной ширины.
pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine { text: text.to_string() }];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let chars: Vec<char> = 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;
} 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 });
current_line = word;
current_width = word_width;
}
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;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine { text: current_line });
current_line = word;
}
}
if !current_line.is_empty() {
result.push(WrappedLine { text: current_line });
}
if result.is_empty() {
result.push(WrappedLine { text: String::new() });
}
result
}
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом.
pub(super) fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let content_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
let selected_msg_id = app.get_selected_message().map(|m| m.id());
let mut selected_msg_line: Option<usize> = None;
#[cfg(feature = "images")]
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
let current_messages = app.td_client.current_chat_messages();
let grouped = group_messages(&current_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());
}
let bubble_lines = components::render_message_bubble(
msg,
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
);
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
&photo.download_state
{
let inline_width =
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = components::calculate_image_height(
photo.width,
photo.height,
inline_width,
);
let img_width = inline_width as u16;
let bubble_len = bubble_lines.len();
let placeholder_start = lines.len() + bubble_len - img_height as usize;
deferred_images.push(components::DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: placeholder_start,
x_offset: 0,
width: img_width,
height: img_height,
});
}
}
lines.extend(bubble_lines);
}
MessageGroup::Album(album_messages) => {
#[cfg(feature = "images")]
{
let is_selected = album_messages
.iter()
.any(|m| selected_msg_id == Some(m.id()));
if is_selected {
selected_msg_line = Some(lines.len());
}
let (bubble_lines, album_deferred) = components::render_album_bubble(
&album_messages,
app.config(),
content_width,
selected_msg_id,
);
for mut d in album_deferred {
d.line_offset += lines.len();
deferred_images.push(d);
}
lines.extend(bubble_lines);
}
#[cfg(not(feature = "images"))]
{
for msg in &album_messages {
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,
app.playback_state.as_ref(),
));
}
}
}
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
}
let visible_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let base_scroll = total_lines.saturating_sub(visible_height);
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);
#[cfg(feature = "images")]
render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset);
}
#[cfg(feature = "images")]
fn render_deferred_images<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &mut App<T>,
deferred_images: &[components::DeferredImageRender],
visible_height: usize,
scroll_offset: u16,
) {
use ratatui_image::StatefulImage;
let should_render_images = app
.last_image_render_time
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true);
if deferred_images.is_empty() || !should_render_images {
return;
}
let content_x = area.x + 1;
let content_y = area.y + 1;
for d in deferred_images {
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
if y_in_content < 0 || y_in_content as usize >= visible_height {
continue;
}
let img_y = content_y + y_in_content as u16;
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
if d.height > remaining_height {
continue;
}
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
if let Some(renderer) = &mut app.inline_image_renderer {
let _ = renderer.load_image(d.message_id, &d.photo_path);
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
}
}
}
app.last_image_render_time = Some(std::time::Instant::now());
}