feat: use thumbnails in the media sidebar

This commit is contained in:
2026-04-05 14:58:48 +02:00
parent 83ad6a2bf7
commit 118633de81
4 changed files with 126 additions and 13 deletions

View File

@@ -912,6 +912,7 @@ impl BdsApp {
self.ui_locale,
&self.toasts,
self.active_modal.as_ref(),
self.data_dir.as_deref(),
)
}

View File

@@ -1,7 +1,11 @@
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use std::path::{Path, PathBuf};
use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space};
use iced::widget::text::Shaping;
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::util::paths::thumbnail_path;
use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Script, Template};
@@ -47,6 +51,18 @@ fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
}
}
/// 40×40 thumbnail container: rounded corners, dark background.
fn thumbnail_container_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))),
border: Border {
radius: 4.0.into(),
..Border::default()
},
..container::Style::default()
}
}
/// Get the appropriate empty-state message key for each sidebar view.
fn placeholder_key(view: SidebarView) -> &'static str {
match view {
@@ -85,6 +101,29 @@ fn truncate_to_fit(s: &str, available_px: f32) -> String {
}
}
/// Format a file size in bytes to a human-readable string (B / KB / MB).
fn format_file_size(size: i64) -> String {
if size < 1024 {
format!("{size} B")
} else if size < 1024 * 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else {
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
}
}
/// Resolve the small thumbnail path for a media item on disk.
/// Returns Some(path) if the media is an image and the thumbnail file exists.
fn resolve_thumbnail_path(data_dir: Option<&Path>, media: &Media) -> Option<PathBuf> {
let data_dir = data_dir?;
if !media.mime_type.starts_with("image/") {
return None;
}
let rel = thumbnail_path(&media.id, "small", "webp");
let full = data_dir.join(&rel);
if full.exists() { Some(full) } else { None }
}
/// Truncate a media title to the max length, appending "..." if over limit.
/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "...").
fn truncate_media_title(title: &str) -> String {
@@ -482,6 +521,7 @@ pub fn view(
width: f32,
active_tab: Option<&str>,
locale: UiLocale,
data_dir: Option<&Path>,
) -> Element<'static, Message> {
let header_text = t(locale, sidebar_view.i18n_key());
let muted = Color::from_rgb(0.50, 0.50, 0.55);
@@ -702,16 +742,60 @@ pub fn view(
Some(title) => truncate_media_title(title),
None => m.original_name.clone(),
};
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 16.0;
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
let display_name = truncate_to_fit(&display_name, text_px);
let label = format!("\u{1F5BC} {display_name}");
let label_text = text(label)
let style_fn = if is_active { item_active_style } else { item_style };
// Left: 40x40 thumbnail or file icon
let thumb_path = resolve_thumbnail_path(data_dir, m);
let left: Element<'static, Message> = if let Some(path) = thumb_path {
container(
image(path.to_string_lossy().to_string())
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.content_fit(iced::ContentFit::Cover)
)
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.clip(true)
.style(thumbnail_container_style)
.into()
} else {
container(
text("\u{1F4C4}") // 📄 generic file icon
.size(20)
.shaping(Shaping::Advanced)
)
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.style(thumbnail_container_style)
.into()
};
// Right column: name + metadata line
let name_text = text(display_name.clone())
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
let file_size = format_file_size(m.size);
let meta_label = match (m.width, m.height) {
(Some(w), Some(h)) => format!("{file_size} · {w}×{h}"),
_ => file_size,
};
let meta_text = text(meta_label)
.size(10)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.50, 0.50, 0.55));
let right = column![name_text, meta_text].spacing(1);
let content = row![left, right].spacing(8).align_y(iced::Alignment::Center);
button(
container(label_text)
container(content)
.width(Length::Fill)
.clip(true)
)
@@ -722,7 +806,7 @@ pub fn view(
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.padding([4, 6])
.width(Length::Fill)
.style(style_fn)
.into()
@@ -971,4 +1055,24 @@ mod tests {
assert!(result.ends_with('\u{2026}'));
assert!(result.chars().count() >= 2);
}
#[test]
fn format_file_size_bytes() {
assert_eq!(format_file_size(0), "0 B");
assert_eq!(format_file_size(512), "512 B");
assert_eq!(format_file_size(1023), "1023 B");
}
#[test]
fn format_file_size_kilobytes() {
assert_eq!(format_file_size(1024), "1.0 KB");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(1024 * 1024 - 1), "1024.0 KB");
}
#[test]
fn format_file_size_megabytes() {
assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
}
}

View File

@@ -1,3 +1,5 @@
use std::path::Path;
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
use iced::widget::text::Shaping;
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
@@ -77,6 +79,8 @@ pub fn view(
toasts: &[Toast],
// Modal
active_modal: Option<&modal::ModalState>,
// Data directory (for thumbnail paths)
data_dir: Option<&Path>,
) -> Element<'static, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
@@ -114,7 +118,7 @@ pub fn view(
let mut main_row = row![activity];
if sidebar_visible {
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_width, active_tab, locale));
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_width, active_tab, locale, data_dir));
// Resize drag handle: 4px wide strip between sidebar and content
let handle = container(Space::new(0, 0))

View File

@@ -177,9 +177,10 @@ value MediaView {
value MediaGridItem {
media_id: String
has_thumbnail: Boolean -- image uses bds-thumb://{id} protocol; non-image uses file icon SVG
thumbnail_path: String? -- small (150px) thumbnail on disk when image; null for non-image
name: String -- title truncated to config.media_title_max_length + "..."; fallback originalName (no truncation)
file_size: String -- formatted (B / KB / MB)
dimensions: String? -- "WxH" when width and height known; null otherwise
tooltip: String -- caption ?? originalName
active: Boolean -- true when activeTabId = media.id
}
@@ -188,9 +189,10 @@ surface MediaGridItemEntry {
context item: MediaGridItem
exposes:
item.has_thumbnail
item.thumbnail_path when item.thumbnail_path != null
item.name
item.file_size
item.dimensions when item.dimensions != null
item.tooltip
item.active
@@ -200,10 +202,12 @@ surface MediaGridItemEntry {
@guarantee CellLayout
-- Grid cell, row layout.
-- Left: 40x40 thumbnail (rounded, object-fit cover) when has_thumbnail is true;
-- otherwise generic file SVG icon of same dimensions.
-- Left: 40x40 thumbnail (rounded, object-fit cover) loaded from
-- thumbnail_path (small 150px WebP) when present;
-- otherwise generic file icon of same dimensions.
-- Right column, line 1: name (truncated with ellipsis).
-- Right column, line 2: file_size (smaller, muted).
-- Right column, line 2: file_size (smaller, muted) followed by
-- dimensions when present, separated by " · ".
@guarantee NameTruncation
-- Title is hard-truncated at config.media_title_max_length characters