feat: implement photo albums (media groups) and persist account selection
Group photos with shared media_album_id into single album bubbles with grid layout (up to 3x cols). Album navigation treats grouped photos as one unit (j/k skip entire album). Persist selected account to accounts.toml so it survives app restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -524,10 +524,197 @@ pub struct DeferredImageRender {
|
||||
pub photo_path: String,
|
||||
/// Смещение в строках от начала всего списка сообщений
|
||||
pub line_offset: usize,
|
||||
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
|
||||
pub x_offset: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
|
||||
///
|
||||
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
|
||||
#[cfg(feature = "images")]
|
||||
pub fn render_album_bubble(
|
||||
messages: &[MessageInfo],
|
||||
config: &Config,
|
||||
content_width: usize,
|
||||
selected_msg_id: Option<MessageId>,
|
||||
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
|
||||
use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut deferred: Vec<DeferredImageRender> = Vec::new();
|
||||
|
||||
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
|
||||
let is_outgoing = messages.first().map_or(false, |m| m.is_outgoing());
|
||||
|
||||
// Selection marker
|
||||
let selection_marker = if is_selected { "▶ " } else { "" };
|
||||
|
||||
// Фильтруем фото
|
||||
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
|
||||
let photo_count = photos.len();
|
||||
|
||||
if photo_count == 0 {
|
||||
// Нет фото — рендерим как обычные сообщения
|
||||
for msg in messages {
|
||||
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
|
||||
}
|
||||
return (lines, deferred);
|
||||
}
|
||||
|
||||
// Grid layout
|
||||
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
|
||||
let rows = (photo_count + cols - 1) / cols;
|
||||
|
||||
// Добавляем маркер выбора на первую строку
|
||||
if is_selected {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
selection_marker,
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let grid_start_line = lines.len();
|
||||
|
||||
// Генерируем placeholder-строки для сетки
|
||||
for row in 0..rows {
|
||||
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Для исходящих — добавляем отступ справа
|
||||
if is_outgoing {
|
||||
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||
let padding = content_width.saturating_sub(grid_width as usize + 1);
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
}
|
||||
|
||||
// Для каждого столбца в этом ряду
|
||||
for col in 0..cols {
|
||||
let photo_idx = row * cols + col;
|
||||
if photo_idx >= photo_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let msg = photos[photo_idx];
|
||||
if let Some(photo) = msg.photo_info() {
|
||||
match &photo.download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
if line_in_row == 0 {
|
||||
// Регистрируем deferred render для этого фото
|
||||
let x_off = if is_outgoing {
|
||||
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||
let padding = content_width.saturating_sub(grid_width as usize + 1) as u16;
|
||||
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||
} else {
|
||||
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||
};
|
||||
|
||||
deferred.push(DeferredImageRender {
|
||||
message_id: msg.id(),
|
||||
photo_path: path.clone(),
|
||||
line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize,
|
||||
x_offset: x_off,
|
||||
width: ALBUM_PHOTO_WIDTH,
|
||||
height: ALBUM_PHOTO_HEIGHT,
|
||||
});
|
||||
}
|
||||
// Пустая строка — placeholder для изображения
|
||||
}
|
||||
PhotoDownloadState::Downloading => {
|
||||
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||
spans.push(Span::styled(
|
||||
"⏳ Загрузка...",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(e) => {
|
||||
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||
let err_text: String = e.chars().take(14).collect();
|
||||
spans.push(Span::styled(
|
||||
format!("❌ {}", err_text),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded => {
|
||||
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||
spans.push(Span::styled(
|
||||
"📷",
|
||||
Style::default().fg(Color::Gray),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
|
||||
let captions: Vec<&str> = messages
|
||||
.iter()
|
||||
.map(|m| m.text())
|
||||
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
|
||||
.collect();
|
||||
|
||||
let msg_color = if is_selected {
|
||||
config.parse_color(&config.colors.selected_message)
|
||||
} else if is_outgoing {
|
||||
config.parse_color(&config.colors.outgoing_message)
|
||||
} else {
|
||||
config.parse_color(&config.colors.incoming_message)
|
||||
};
|
||||
|
||||
// Timestamp из последнего сообщения
|
||||
let last_msg = messages.last().unwrap();
|
||||
let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone);
|
||||
|
||||
if !captions.is_empty() {
|
||||
let caption_text = captions.join(" ");
|
||||
let time_suffix = format!(" ({})", time);
|
||||
|
||||
if is_outgoing {
|
||||
let total_len = caption_text.chars().count() + time_suffix.chars().count();
|
||||
let padding = content_width.saturating_sub(total_len + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
// Без подписи — только timestamp
|
||||
let time_text = format!("({})", time);
|
||||
if is_outgoing {
|
||||
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(time_text, Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
(lines, deferred)
|
||||
}
|
||||
|
||||
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
||||
#[cfg(feature = "images")]
|
||||
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
|
||||
|
||||
Reference in New Issue
Block a user