feat: use thumbnails in the media sidebar
This commit is contained in:
@@ -912,6 +912,7 @@ impl BdsApp {
|
||||
self.ui_locale,
|
||||
&self.toasts,
|
||||
self.active_modal.as_ref(),
|
||||
self.data_dir.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user