1350 lines
50 KiB
Rust
1350 lines
50 KiB
Rust
use std::path::{Path, PathBuf};
|
||
|
||
use iced::widget::text::Shaping;
|
||
use iced::widget::{Space, button, column, container, image, row, scrollable, text, text_input};
|
||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||
|
||
use bds_core::i18n::UiLocale;
|
||
use bds_core::model::{ChatConversation, ImportDefinition, Media, Post, Script, Template};
|
||
|
||
use crate::app::Message;
|
||
use crate::components::inputs;
|
||
use crate::i18n::t;
|
||
use crate::state::navigation::SidebarView;
|
||
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
|
||
use crate::state::tabs::{Tab, TabType};
|
||
use crate::views::git::GitUiState;
|
||
|
||
/// Sidebar container style — dark background.
|
||
fn sidebar_style(_theme: &Theme) -> container::Style {
|
||
container::Style {
|
||
background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))),
|
||
..container::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Sidebar item button style.
|
||
fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||
_ => Color::TRANSPARENT,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.80, 0.80, 0.85),
|
||
border: Border {
|
||
radius: 6.0.into(),
|
||
..Border::default()
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Sidebar item button style — active/selected.
|
||
fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.26, 0.26, 0.32),
|
||
_ => Color::from_rgb(0.22, 0.22, 0.28),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::WHITE,
|
||
border: Border {
|
||
radius: 6.0.into(),
|
||
..Border::default()
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// 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: 6.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 {
|
||
SidebarView::Posts => "sidebar.noPostsYet",
|
||
SidebarView::Pages => "sidebar.noPagesYet",
|
||
SidebarView::Media => "sidebar.noMediaYet",
|
||
SidebarView::Scripts => "sidebar.noScriptsYet",
|
||
SidebarView::Templates => "sidebar.noTemplatesYet",
|
||
SidebarView::Tags => "sidebar.tagsHeader",
|
||
SidebarView::Chat => "chat.sidebar.empty",
|
||
SidebarView::Import => "sidebar.importPlaceholder",
|
||
SidebarView::Git => "git.noChanges",
|
||
SidebarView::Settings => "sidebar.settingsHeader",
|
||
}
|
||
}
|
||
|
||
/// sidebar_views.allium media_title_max_length = 60
|
||
const MEDIA_TITLE_MAX_LEN: usize = 60;
|
||
|
||
/// Approximate average character width at font size 12 for proportional fonts.
|
||
/// Used to estimate how many characters fit in a given pixel width.
|
||
const AVG_CHAR_WIDTH_PX: f32 = 6.8;
|
||
|
||
/// Sidebar padding on each side (12px) plus item padding (6px each side).
|
||
const SIDEBAR_TEXT_OVERHEAD_PX: f32 = 36.0;
|
||
|
||
/// Truncate a string to fit approximately within `available_px` pixels,
|
||
/// appending "…" if truncation occurs.
|
||
fn truncate_to_fit(s: &str, available_px: f32) -> String {
|
||
let max_chars = ((available_px / AVG_CHAR_WIDTH_PX) as usize).max(6);
|
||
if s.chars().count() <= max_chars {
|
||
s.to_string()
|
||
} else {
|
||
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
|
||
format!("{truncated}\u{2026}")
|
||
}
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
if title.chars().count() > MEDIA_TITLE_MAX_LEN {
|
||
let truncated: String = title.chars().take(MEDIA_TITLE_MAX_LEN).collect();
|
||
format!("{truncated}...")
|
||
} else {
|
||
title.to_string()
|
||
}
|
||
}
|
||
|
||
/// Per sidebar_views.allium PostTypeIcon: map first category to emoji.
|
||
fn post_type_icon(categories: &[String]) -> &'static str {
|
||
for cat in categories {
|
||
match cat.to_lowercase().as_str() {
|
||
"picture" => return "\u{1F4F7}", // 📷 camera
|
||
"article" => return "\u{1F5D2}", // 🗒 notepad
|
||
"aside" | "blogmark" => return "\u{1F517}", // 🔗 link
|
||
"video" => return "\u{1F3AC}", // 🎬 film
|
||
"podcast" => return "\u{1F4AC}", // 💬 speech bubble
|
||
_ => {}
|
||
}
|
||
}
|
||
"\u{1F4C4}" // 📄 document (default)
|
||
}
|
||
|
||
/// Per sidebar_views.allium PostDateFormat: "Feb 10, 2026".
|
||
fn format_post_date(unix_ms: i64) -> String {
|
||
let secs = unix_ms / 1000;
|
||
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
|
||
dt.format("%b %d, %Y").to_string()
|
||
}
|
||
|
||
/// Search input style.
|
||
fn search_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||
inputs::field_style(theme, status)
|
||
}
|
||
|
||
/// Style for filter toggle / clear buttons in the header.
|
||
fn filter_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30),
|
||
_ => Color::TRANSPARENT,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.60, 0.60, 0.65),
|
||
border: Border {
|
||
radius: 6.0.into(),
|
||
..Border::default()
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Active filter toggle button.
|
||
fn filter_button_active_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.25, 0.30, 0.40),
|
||
_ => Color::from_rgb(0.20, 0.25, 0.35),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.55, 0.70, 0.95),
|
||
border: Border {
|
||
radius: 6.0.into(),
|
||
..Border::default()
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Tag/category chip style (unselected).
|
||
fn chip_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.24, 0.24, 0.30),
|
||
_ => Color::from_rgb(0.18, 0.18, 0.22),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.70, 0.70, 0.75),
|
||
border: Border {
|
||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||
width: 1.0,
|
||
radius: 10.0.into(),
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Tag/category chip style (selected).
|
||
fn chip_selected_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.25, 0.35, 0.55),
|
||
_ => Color::from_rgb(0.20, 0.30, 0.50),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.85, 0.90, 1.0),
|
||
border: Border {
|
||
color: Color::from_rgb(0.40, 0.55, 0.80),
|
||
width: 1.0,
|
||
radius: 10.0.into(),
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Calendar year/month button style.
|
||
fn calendar_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||
_ => Color::TRANSPARENT,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.70, 0.70, 0.75),
|
||
border: Border::default(),
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Calendar year/month button style (selected).
|
||
fn calendar_selected_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.25, 0.30, 0.40),
|
||
_ => Color::from_rgb(0.20, 0.25, 0.35),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: Color::from_rgb(0.55, 0.70, 0.95),
|
||
border: Border::default(),
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// "Clear All Filters" button style.
|
||
fn clear_filters_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let color = match status {
|
||
button::Status::Hovered => Color::from_rgb(0.90, 0.50, 0.50),
|
||
_ => Color::from_rgb(0.75, 0.45, 0.45),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(Color::TRANSPARENT)),
|
||
text_color: color,
|
||
border: Border::default(),
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Row delete button (×) revealed on row hover; turns red on button hover.
|
||
/// Per sidebar_views.allium ScriptListItemEntry RowLayout.
|
||
fn row_delete_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||
let text_color = match status {
|
||
button::Status::Hovered | button::Status::Pressed => Color::from_rgb(0.90, 0.30, 0.30),
|
||
_ => Color::from_rgb(0.60, 0.60, 0.65),
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.25))),
|
||
text_color,
|
||
border: Border {
|
||
radius: 4.0.into(),
|
||
..Border::default()
|
||
},
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Month name abbreviation for calendar display.
|
||
fn month_abbr(month: u32) -> &'static str {
|
||
match month {
|
||
1 => "Jan",
|
||
2 => "Feb",
|
||
3 => "Mar",
|
||
4 => "Apr",
|
||
5 => "May",
|
||
6 => "Jun",
|
||
7 => "Jul",
|
||
8 => "Aug",
|
||
9 => "Sep",
|
||
10 => "Oct",
|
||
11 => "Nov",
|
||
12 => "Dec",
|
||
_ => "???",
|
||
}
|
||
}
|
||
|
||
/// Build the calendar archive tree widget.
|
||
fn calendar_widget(
|
||
years: &[CalendarYear],
|
||
selected_year: Option<i32>,
|
||
selected_month: Option<u32>,
|
||
on_year: impl Fn(Option<i32>) -> Message + 'static + Clone,
|
||
on_month: impl Fn(Option<u32>) -> Message + 'static + Clone,
|
||
locale: UiLocale,
|
||
) -> Element<'static, Message> {
|
||
let muted = Color::from_rgb(0.50, 0.50, 0.55);
|
||
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
let section_label = text(t(locale, "sidebar.filter.calendar"))
|
||
.size(10)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted);
|
||
items.push(section_label.into());
|
||
|
||
for cy in years {
|
||
let year_selected = selected_year == Some(cy.year);
|
||
let total: usize = cy.months.iter().map(|m| m.count).sum();
|
||
let label = format!("{} ({})", cy.year, total);
|
||
let year_val = cy.year;
|
||
let on_year_clone = on_year.clone();
|
||
let style_fn = if year_selected {
|
||
calendar_selected_style
|
||
} else {
|
||
calendar_style
|
||
};
|
||
let year_btn = button(text(label).size(11).shaping(Shaping::Advanced))
|
||
.on_press(if year_selected {
|
||
on_year_clone(None)
|
||
} else {
|
||
on_year_clone(Some(year_val))
|
||
})
|
||
.padding([2, 4])
|
||
.style(style_fn);
|
||
items.push(year_btn.into());
|
||
|
||
// Show months only when this year is selected
|
||
if year_selected {
|
||
for cm in &cy.months {
|
||
let month_selected = selected_month == Some(cm.month);
|
||
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
|
||
let month_val = cm.month;
|
||
let on_month_clone = on_month.clone();
|
||
let style_fn = if month_selected {
|
||
calendar_selected_style
|
||
} else {
|
||
calendar_style
|
||
};
|
||
let month_btn = button(text(label).size(10).shaping(Shaping::Advanced))
|
||
.on_press(if month_selected {
|
||
on_month_clone(None)
|
||
} else {
|
||
on_month_clone(Some(month_val))
|
||
})
|
||
.padding([1, 4])
|
||
.style(style_fn);
|
||
items.push(month_btn.into());
|
||
}
|
||
}
|
||
}
|
||
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
|
||
/// Build chip selector for tags or categories.
|
||
fn chip_selector(
|
||
label: &str,
|
||
available: &[String],
|
||
selected: &[String],
|
||
on_toggle: impl Fn(String) -> Message + 'static + Clone,
|
||
locale: UiLocale,
|
||
) -> Element<'static, Message> {
|
||
let muted = Color::from_rgb(0.50, 0.50, 0.55);
|
||
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
let section_label = text(t(locale, label))
|
||
.size(10)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted);
|
||
items.push(section_label.into());
|
||
|
||
// Wrap chips in rows
|
||
let mut chip_row: Vec<Element<'static, Message>> = Vec::new();
|
||
for tag in available {
|
||
let is_selected = selected.contains(tag);
|
||
let tag_clone = tag.clone();
|
||
let on_toggle_clone = on_toggle.clone();
|
||
let style_fn = if is_selected {
|
||
chip_selected_style
|
||
} else {
|
||
chip_style
|
||
};
|
||
let chip = button(text(tag.clone()).size(10).shaping(Shaping::Advanced))
|
||
.on_press(on_toggle_clone(tag_clone))
|
||
.padding([2, 6])
|
||
.style(style_fn);
|
||
chip_row.push(chip.into());
|
||
}
|
||
|
||
if !chip_row.is_empty() {
|
||
let mut collected: Vec<Element<'static, Message>> = chip_row.into_iter().collect();
|
||
while !collected.is_empty() {
|
||
let chunk_size = 3.min(collected.len());
|
||
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
|
||
items.push(iced::widget::Row::with_children(chunk).spacing(4).into());
|
||
}
|
||
}
|
||
|
||
iced::widget::Column::with_children(items).spacing(2).into()
|
||
}
|
||
|
||
fn single_select_chip_selector(
|
||
label: &str,
|
||
available: &[(String, String)],
|
||
selected: Option<&str>,
|
||
on_toggle: impl Fn(Option<String>) -> Message + 'static + Clone,
|
||
locale: UiLocale,
|
||
) -> Element<'static, Message> {
|
||
let muted = Color::from_rgb(0.50, 0.50, 0.55);
|
||
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
items.push(
|
||
text(t(locale, label))
|
||
.size(10)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into(),
|
||
);
|
||
|
||
let chips = available
|
||
.iter()
|
||
.map(|(value, display)| {
|
||
let is_selected = selected == Some(value.as_str());
|
||
let style_fn = if is_selected {
|
||
chip_selected_style
|
||
} else {
|
||
chip_style
|
||
};
|
||
let value = value.clone();
|
||
let on_toggle = on_toggle.clone();
|
||
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
|
||
.on_press(if is_selected {
|
||
on_toggle(None)
|
||
} else {
|
||
on_toggle(Some(value))
|
||
})
|
||
.padding([2, 6])
|
||
.style(style_fn)
|
||
.into()
|
||
})
|
||
.collect::<Vec<Element<'static, Message>>>();
|
||
|
||
if !chips.is_empty() {
|
||
items.push(row(chips).spacing(4).into());
|
||
}
|
||
|
||
iced::widget::Column::with_children(items).spacing(2).into()
|
||
}
|
||
|
||
/// Build the filter panel for Posts/Pages view.
|
||
fn post_filter_panel(
|
||
filter: &PostFilter,
|
||
is_pages: bool,
|
||
locale: UiLocale,
|
||
) -> Element<'static, Message> {
|
||
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
sections.push(single_select_chip_selector(
|
||
"sidebar.filter.status",
|
||
&[
|
||
("draft".to_string(), t(locale, "sidebar.drafts")),
|
||
("published".to_string(), t(locale, "sidebar.published")),
|
||
("archived".to_string(), t(locale, "sidebar.archived")),
|
||
],
|
||
filter.status_filter.as_deref(),
|
||
Message::SetPostStatusFilter,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
|
||
if !filter.available_languages.is_empty() {
|
||
let languages = filter
|
||
.available_languages
|
||
.iter()
|
||
.map(|lang| (lang.clone(), lang.to_uppercase()))
|
||
.collect::<Vec<_>>();
|
||
sections.push(single_select_chip_selector(
|
||
"sidebar.filter.language",
|
||
&languages,
|
||
filter.language_filter.as_deref(),
|
||
Message::SetPostLanguageFilter,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
// Calendar archive
|
||
if !filter.calendar_years.is_empty() {
|
||
sections.push(calendar_widget(
|
||
&filter.calendar_years,
|
||
filter.calendar.selected_year,
|
||
filter.calendar.selected_month,
|
||
Message::SetPostCalendarYear,
|
||
Message::SetPostCalendarMonth,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
// Tag chips
|
||
if !filter.available_tags.is_empty() {
|
||
sections.push(chip_selector(
|
||
"sidebar.filter.tags",
|
||
&filter.available_tags,
|
||
&filter.tag_filter,
|
||
Message::TogglePostTagFilter,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
// Category chips (not shown for Pages since Pages IS a category filter)
|
||
if !is_pages && !filter.available_categories.is_empty() {
|
||
sections.push(chip_selector(
|
||
"sidebar.filter.categories",
|
||
&filter.available_categories,
|
||
&filter.category_filter,
|
||
Message::TogglePostCategoryFilter,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
sections.push(
|
||
iced::widget::Column::with_children(vec![
|
||
text(t(locale, "sidebar.filter.dateRange"))
|
||
.size(10)
|
||
.shaping(Shaping::Advanced)
|
||
.color(Color::from_rgb(0.50, 0.50, 0.55))
|
||
.into(),
|
||
row![
|
||
text_input(&t(locale, "sidebar.filter.from"), &filter.from_date)
|
||
.on_input(Message::SetPostFromDate)
|
||
.size(10)
|
||
.padding([3, 5])
|
||
.style(search_input_style),
|
||
text_input(&t(locale, "sidebar.filter.to"), &filter.to_date)
|
||
.on_input(Message::SetPostToDate)
|
||
.size(10)
|
||
.padding([3, 5])
|
||
.style(search_input_style),
|
||
]
|
||
.spacing(4)
|
||
.into(),
|
||
])
|
||
.spacing(2)
|
||
.into(),
|
||
);
|
||
sections.push(Space::with_height(4.0).into());
|
||
|
||
// Clear all filters button
|
||
if filter.has_active_filters() {
|
||
sections.push(
|
||
button(
|
||
text(t(locale, "sidebar.filter.clearAll"))
|
||
.size(10)
|
||
.shaping(Shaping::Advanced),
|
||
)
|
||
.on_press(Message::ClearPostFilters)
|
||
.padding([2, 4])
|
||
.style(clear_filters_style)
|
||
.into(),
|
||
);
|
||
}
|
||
|
||
iced::widget::Column::with_children(sections)
|
||
.spacing(2)
|
||
.into()
|
||
}
|
||
|
||
/// Build the filter panel for Media view.
|
||
fn media_filter_panel(filter: &MediaFilter, locale: UiLocale) -> Element<'static, Message> {
|
||
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
if !filter.calendar_years.is_empty() {
|
||
sections.push(calendar_widget(
|
||
&filter.calendar_years,
|
||
filter.calendar.selected_year,
|
||
filter.calendar.selected_month,
|
||
Message::SetMediaCalendarYear,
|
||
Message::SetMediaCalendarMonth,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
if !filter.available_tags.is_empty() {
|
||
sections.push(chip_selector(
|
||
"sidebar.filter.tags",
|
||
&filter.available_tags,
|
||
&filter.tag_filter,
|
||
Message::ToggleMediaTagFilter,
|
||
locale,
|
||
));
|
||
sections.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
if filter.has_active_filters() {
|
||
sections.push(
|
||
button(
|
||
text(t(locale, "sidebar.filter.clearAll"))
|
||
.size(10)
|
||
.shaping(Shaping::Advanced),
|
||
)
|
||
.on_press(Message::ClearMediaFilters)
|
||
.padding([2, 4])
|
||
.style(clear_filters_style)
|
||
.into(),
|
||
);
|
||
}
|
||
|
||
iced::widget::Column::with_children(sections)
|
||
.spacing(2)
|
||
.into()
|
||
}
|
||
|
||
#[expect(
|
||
clippy::too_many_arguments,
|
||
reason = "arguments are independent sidebar state slices"
|
||
)]
|
||
pub fn view(
|
||
sidebar_view: SidebarView,
|
||
posts: &[Post],
|
||
media: &[Media],
|
||
scripts: &[Script],
|
||
templates: &[Template],
|
||
imports: &[ImportDefinition],
|
||
conversations: &[ChatConversation],
|
||
post_filter: &PostFilter,
|
||
media_filter: &MediaFilter,
|
||
media_thumbs: &std::collections::HashMap<String, Option<PathBuf>>,
|
||
posts_has_more: bool,
|
||
media_has_more: bool,
|
||
width: f32,
|
||
active_tab: Option<&str>,
|
||
locale: UiLocale,
|
||
_data_dir: Option<&Path>,
|
||
git_state: &GitUiState,
|
||
offline_mode: bool,
|
||
) -> Element<'static, Message> {
|
||
let header_text = t(locale, sidebar_view.i18n_key());
|
||
let muted = Color::from_rgb(0.50, 0.50, 0.55);
|
||
|
||
let header = text(header_text)
|
||
.size(13)
|
||
.shaping(Shaping::Advanced)
|
||
.color(Color::from_rgb(0.85, 0.85, 0.90));
|
||
|
||
let create_action = match sidebar_view {
|
||
SidebarView::Posts => Some(Message::CreatePost),
|
||
SidebarView::Pages => Some(Message::CreatePage),
|
||
SidebarView::Media => Some(Message::CreateMedia),
|
||
SidebarView::Scripts => Some(Message::CreateScript),
|
||
SidebarView::Templates => Some(Message::CreateTemplate),
|
||
SidebarView::Import => Some(Message::CreateImport),
|
||
SidebarView::Chat => Some(Message::ChatCreate),
|
||
_ => None,
|
||
};
|
||
|
||
let header_row: Element<'static, Message> = if let Some(action) = create_action {
|
||
row![
|
||
header,
|
||
Space::with_width(Length::Fill),
|
||
button(
|
||
text(t(locale, "common.add"))
|
||
.size(11)
|
||
.shaping(Shaping::Advanced)
|
||
)
|
||
.on_press(action)
|
||
.padding([3, 8])
|
||
.style(filter_button_style),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.into()
|
||
} else {
|
||
header.into()
|
||
};
|
||
|
||
let body: Element<'static, Message> = match sidebar_view {
|
||
SidebarView::Posts | SidebarView::Pages => {
|
||
let is_pages = sidebar_view == SidebarView::Pages;
|
||
let filter = post_filter;
|
||
|
||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
// Search input + filter toggle row
|
||
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||
.on_input(Message::PostSearchChanged)
|
||
.size(11)
|
||
.padding([4, 6])
|
||
.width(Length::Fill)
|
||
.style(search_input_style);
|
||
|
||
let has_filters = filter.has_active_filters();
|
||
let toggle_style = if filter.filter_panel_visible || has_filters {
|
||
filter_button_active_style
|
||
} else {
|
||
filter_button_style
|
||
};
|
||
let toggle_label = if has_filters {
|
||
"\u{2B50}" // ⭐ indicates active filters
|
||
} else {
|
||
"\u{25BC}" // ▼ toggle icon
|
||
};
|
||
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||
.on_press(Message::TogglePostFilterPanel)
|
||
.padding([4, 6])
|
||
.style(toggle_style);
|
||
|
||
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||
|
||
// Filter panel (collapsible)
|
||
if filter.filter_panel_visible {
|
||
top_items.push(Space::with_height(4.0).into());
|
||
top_items.push(post_filter_panel(filter, is_pages, locale));
|
||
top_items.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
// Post list
|
||
if posts.is_empty() {
|
||
let key = if has_filters {
|
||
"sidebar.filter.noResults"
|
||
} else {
|
||
placeholder_key(sidebar_view)
|
||
};
|
||
top_items.push(
|
||
text(t(locale, key))
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into(),
|
||
);
|
||
} else {
|
||
let section_header = |label: &str| -> Element<'static, Message> {
|
||
text(label.to_string())
|
||
.size(11)
|
||
.shaping(Shaping::Advanced)
|
||
.color(Color::from_rgb(0.55, 0.55, 0.60))
|
||
.into()
|
||
};
|
||
|
||
let make_post_item = |p: &Post| -> Element<'static, Message> {
|
||
let is_active = active_tab == Some(p.id.as_str());
|
||
let icon = post_type_icon(&p.categories);
|
||
let status_indicator = match p.status {
|
||
bds_core::model::PostStatus::Draft => "\u{25CB}",
|
||
bds_core::model::PostStatus::Published => "\u{25CF}",
|
||
bds_core::model::PostStatus::Archived => "\u{25A1}",
|
||
};
|
||
let date = format_post_date(p.created_at);
|
||
let prefix_chars: usize = 5;
|
||
let text_px = width
|
||
- SIDEBAR_TEXT_OVERHEAD_PX
|
||
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
|
||
let display_title = truncate_to_fit(&p.title, text_px);
|
||
let label = format!("{icon} {status_indicator} {display_title}");
|
||
let label_text = text(label)
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.wrapping(iced::widget::text::Wrapping::None);
|
||
let date_text = text(date)
|
||
.size(10)
|
||
.shaping(Shaping::Advanced)
|
||
.color(Color::from_rgb(0.50, 0.50, 0.55));
|
||
let style_fn = if is_active {
|
||
item_active_style
|
||
} else {
|
||
item_style
|
||
};
|
||
button(
|
||
container(column![label_text, date_text].spacing(1))
|
||
.width(Length::Fill)
|
||
.clip(true),
|
||
)
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: p.id.clone(),
|
||
tab_type: TabType::Post,
|
||
title: p.title.clone(),
|
||
is_transient: true,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(style_fn)
|
||
.into()
|
||
};
|
||
|
||
// Draft section
|
||
let drafts: Vec<&Post> = posts
|
||
.iter()
|
||
.filter(|p| p.status == bds_core::model::PostStatus::Draft)
|
||
.collect();
|
||
if !drafts.is_empty() {
|
||
top_items.push(section_header(&t(locale, "sidebar.drafts")));
|
||
for p in &drafts {
|
||
top_items.push(make_post_item(p));
|
||
}
|
||
top_items.push(Space::with_height(6.0).into());
|
||
}
|
||
|
||
// Published section
|
||
let published: Vec<&Post> = posts
|
||
.iter()
|
||
.filter(|p| p.status == bds_core::model::PostStatus::Published)
|
||
.collect();
|
||
if !published.is_empty() {
|
||
top_items.push(section_header(&t(locale, "sidebar.published")));
|
||
for p in &published {
|
||
top_items.push(make_post_item(p));
|
||
}
|
||
top_items.push(Space::with_height(6.0).into());
|
||
}
|
||
|
||
// Archived section
|
||
let archived: Vec<&Post> = posts
|
||
.iter()
|
||
.filter(|p| p.status == bds_core::model::PostStatus::Archived)
|
||
.collect();
|
||
if !archived.is_empty() {
|
||
top_items.push(section_header(&t(locale, "sidebar.archived")));
|
||
for p in &archived {
|
||
top_items.push(make_post_item(p));
|
||
}
|
||
}
|
||
}
|
||
|
||
iced::widget::Column::with_children(top_items)
|
||
.spacing(1)
|
||
.into()
|
||
}
|
||
SidebarView::Media => {
|
||
let filter = media_filter;
|
||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||
|
||
// Search input + filter toggle row
|
||
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||
.on_input(Message::MediaSearchChanged)
|
||
.size(11)
|
||
.padding([4, 6])
|
||
.width(Length::Fill)
|
||
.style(search_input_style);
|
||
|
||
let has_filters = filter.has_active_filters();
|
||
let toggle_style = if filter.filter_panel_visible || has_filters {
|
||
filter_button_active_style
|
||
} else {
|
||
filter_button_style
|
||
};
|
||
let toggle_label = if has_filters {
|
||
"\u{2B50}" // ⭐ indicates active filters
|
||
} else {
|
||
"\u{25BC}" // ▼ toggle icon
|
||
};
|
||
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||
.on_press(Message::ToggleMediaFilterPanel)
|
||
.padding([4, 6])
|
||
.style(toggle_style);
|
||
|
||
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||
|
||
// Filter panel (collapsible)
|
||
if filter.filter_panel_visible {
|
||
top_items.push(Space::with_height(4.0).into());
|
||
top_items.push(media_filter_panel(filter, locale));
|
||
top_items.push(Space::with_height(4.0).into());
|
||
}
|
||
|
||
if media.is_empty() {
|
||
let key = if has_filters {
|
||
"sidebar.filter.noResults"
|
||
} else {
|
||
placeholder_key(sidebar_view)
|
||
};
|
||
top_items.push(
|
||
text(t(locale, key))
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into(),
|
||
);
|
||
} else {
|
||
let items: Vec<Element<'static, Message>> = media
|
||
.iter()
|
||
.map(|m| {
|
||
let is_active = active_tab == Some(m.id.as_str());
|
||
let display_name = match m.title.as_deref() {
|
||
Some(title) => truncate_media_title(title),
|
||
None => m.original_name.clone(),
|
||
};
|
||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
|
||
let display_name = truncate_to_fit(&display_name, text_px);
|
||
let style_fn = if is_active {
|
||
item_active_style
|
||
} else {
|
||
item_style
|
||
};
|
||
|
||
// Left: 40x40 thumbnail or file icon (pre-resolved, no filesystem I/O)
|
||
let thumb_path = media_thumbs.get(&m.id).and_then(|opt| opt.as_ref());
|
||
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 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(content).width(Length::Fill).clip(true))
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: m.id.clone(),
|
||
tab_type: TabType::Media,
|
||
title: display_name.clone(),
|
||
is_transient: true,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([4, 6])
|
||
.width(Length::Fill)
|
||
.style(style_fn)
|
||
.into()
|
||
})
|
||
.collect();
|
||
top_items.extend(items);
|
||
}
|
||
|
||
iced::widget::Column::with_children(top_items)
|
||
.spacing(1)
|
||
.into()
|
||
}
|
||
SidebarView::Scripts => {
|
||
if scripts.is_empty() {
|
||
text(t(locale, placeholder_key(sidebar_view)))
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into()
|
||
} else {
|
||
let items: Vec<Element<'static, Message>> = scripts
|
||
.iter()
|
||
.map(|s| {
|
||
let is_active = active_tab == Some(s.id.as_str());
|
||
// Reserve room on the right for the hover-revealed delete button.
|
||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 20.0;
|
||
let display_title = truncate_to_fit(&s.title, text_px);
|
||
let label_text = text(display_title.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 open_button =
|
||
button(container(label_text).width(Length::Fill).clip(true))
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: s.id.clone(),
|
||
tab_type: TabType::Scripts,
|
||
title: display_title,
|
||
is_transient: true,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(style_fn);
|
||
// sidebar_views.allium ScriptListItemEntry: delete button (x),
|
||
// visible only on row hover, routed through the confirm modal.
|
||
let delete_button = button(
|
||
text("\u{2715}") // ✕
|
||
.size(11)
|
||
.shaping(Shaping::Advanced),
|
||
)
|
||
.on_press(Message::ScriptDeleteRequested(s.id.clone()))
|
||
.padding([2, 6])
|
||
.style(row_delete_style);
|
||
iced::widget::hover(
|
||
open_button,
|
||
container(delete_button)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.align_x(iced::alignment::Horizontal::Right)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.padding([0, 4]),
|
||
)
|
||
})
|
||
.collect();
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
}
|
||
SidebarView::Templates => {
|
||
if templates.is_empty() {
|
||
text(t(locale, placeholder_key(sidebar_view)))
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into()
|
||
} else {
|
||
let items: Vec<Element<'static, Message>> = templates
|
||
.iter()
|
||
.map(|tmpl| {
|
||
let is_active = active_tab == Some(tmpl.id.as_str());
|
||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
|
||
let display_title = truncate_to_fit(&tmpl.title, text_px);
|
||
let label_text = text(display_title.clone())
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.wrapping(iced::widget::text::Wrapping::None);
|
||
let style_fn = if is_active {
|
||
item_active_style
|
||
} else {
|
||
item_style
|
||
};
|
||
button(container(label_text).width(Length::Fill).clip(true))
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: tmpl.id.clone(),
|
||
tab_type: TabType::Templates,
|
||
title: display_title,
|
||
is_transient: true,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(style_fn)
|
||
.into()
|
||
})
|
||
.collect();
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
}
|
||
SidebarView::Import => {
|
||
if imports.is_empty() {
|
||
text(t(locale, placeholder_key(sidebar_view)))
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into()
|
||
} else {
|
||
let items = imports
|
||
.iter()
|
||
.map(|definition| {
|
||
let definition_name = definition.name.clone();
|
||
let style = if active_tab == Some(definition.id.as_str()) {
|
||
item_active_style
|
||
} else {
|
||
item_style
|
||
};
|
||
let analyzed = if definition.last_analysis_result.is_some() {
|
||
t(locale, "import.sidebar.analyzed")
|
||
} else {
|
||
t(locale, "import.sidebar.pending")
|
||
};
|
||
button(
|
||
container(
|
||
column![
|
||
text(definition_name).size(12).shaping(Shaping::Advanced),
|
||
text(analyzed)
|
||
.size(10)
|
||
.shaping(Shaping::Advanced)
|
||
.color(Color::from_rgb(0.50, 0.50, 0.55)),
|
||
]
|
||
.spacing(1),
|
||
)
|
||
.width(Length::Fill),
|
||
)
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: definition.id.clone(),
|
||
tab_type: TabType::Import,
|
||
title: definition.name.clone(),
|
||
is_transient: true,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(style)
|
||
.into()
|
||
})
|
||
.collect::<Vec<Element<'static, Message>>>();
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
}
|
||
SidebarView::Chat => {
|
||
if conversations.is_empty() {
|
||
text(t(locale, "chat.sidebar.empty"))
|
||
.size(12)
|
||
.shaping(Shaping::Advanced)
|
||
.color(muted)
|
||
.into()
|
||
} else {
|
||
let items = conversations
|
||
.iter()
|
||
.map(|conversation| {
|
||
let style = if active_tab == Some(conversation.id.as_str()) {
|
||
item_active_style
|
||
} else {
|
||
item_style
|
||
};
|
||
let model = conversation
|
||
.model
|
||
.clone()
|
||
.unwrap_or_else(|| t(locale, "chat.model.none"));
|
||
button(
|
||
container(
|
||
column![
|
||
text(conversation.title.clone())
|
||
.size(12)
|
||
.shaping(Shaping::Advanced),
|
||
text(model).size(10).shaping(Shaping::Advanced).color(muted),
|
||
]
|
||
.spacing(1),
|
||
)
|
||
.width(Length::Fill),
|
||
)
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: conversation.id.clone(),
|
||
tab_type: TabType::Chat,
|
||
title: conversation.title.clone(),
|
||
is_transient: false,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(style)
|
||
.into()
|
||
})
|
||
.collect::<Vec<Element<'static, Message>>>();
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
}
|
||
SidebarView::Settings => {
|
||
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections
|
||
use crate::views::settings_view::SettingsSection;
|
||
let sections: &[(&str, Option<SettingsSection>)] = &[
|
||
("settings.nav.project", Some(SettingsSection::Project)),
|
||
("settings.nav.editor", Some(SettingsSection::Editor)),
|
||
("settings.nav.content", Some(SettingsSection::Content)),
|
||
("settings.nav.ai", Some(SettingsSection::AI)),
|
||
("settings.nav.technology", Some(SettingsSection::Technology)),
|
||
("settings.nav.publishing", Some(SettingsSection::Publishing)),
|
||
("settings.nav.data", Some(SettingsSection::Data)),
|
||
("settings.nav.mcp", Some(SettingsSection::MCP)),
|
||
("settings.nav.style", None),
|
||
];
|
||
let items: Vec<Element<'static, Message>> = sections
|
||
.iter()
|
||
.map(|(key, section_opt)| {
|
||
let label = t(locale, key);
|
||
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||
let msg = if let Some(section) = section_opt {
|
||
Message::OpenSettingsSection(section.clone())
|
||
} else {
|
||
Message::OpenTab(Tab {
|
||
id: "style".to_string(),
|
||
tab_type: TabType::Style,
|
||
title: t(locale, "tabBar.style"),
|
||
is_transient: false,
|
||
is_dirty: false,
|
||
})
|
||
};
|
||
button(container(label_text).width(Length::Fill))
|
||
.on_press(msg)
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(item_style)
|
||
.into()
|
||
})
|
||
.collect();
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
SidebarView::Tags => {
|
||
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
|
||
let sections = ["tags.nav.cloud", "tags.nav.manage", "tags.nav.merge"];
|
||
let items: Vec<Element<'static, Message>> = sections
|
||
.iter()
|
||
.map(|key| {
|
||
let label = t(locale, key);
|
||
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||
button(container(label_text).width(Length::Fill))
|
||
.on_press(Message::OpenTab(Tab {
|
||
id: "tags".to_string(),
|
||
tab_type: TabType::Tags,
|
||
title: t(locale, "tabBar.tags"),
|
||
is_transient: false,
|
||
is_dirty: false,
|
||
}))
|
||
.padding([5, 8])
|
||
.width(Length::Fill)
|
||
.style(item_style)
|
||
.into()
|
||
})
|
||
.collect();
|
||
iced::widget::Column::with_children(items).spacing(1).into()
|
||
}
|
||
SidebarView::Git => crate::views::git::sidebar_view(git_state, offline_mode, locale),
|
||
};
|
||
|
||
let content = column![header_row, Space::with_height(8.0), body,]
|
||
.spacing(4)
|
||
.padding(12);
|
||
|
||
// layout.allium: sidebar width is resizable, passed as parameter
|
||
let mut scrolling = scrollable(content)
|
||
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
|
||
.style(inputs::scrollable_style);
|
||
if matches!(sidebar_view, SidebarView::Posts | SidebarView::Pages) && posts_has_more
|
||
|| sidebar_view == SidebarView::Media && media_has_more
|
||
{
|
||
scrolling =
|
||
scrolling.on_scroll(|viewport| Message::SidebarScrolled(viewport.relative_offset().y));
|
||
}
|
||
|
||
container(scrolling)
|
||
.width(Length::Fixed(width))
|
||
.height(Length::Fill)
|
||
.style(sidebar_style)
|
||
.into()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn truncate_media_title_short() {
|
||
assert_eq!(truncate_media_title("short title"), "short title");
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_media_title_exact_60() {
|
||
let title: String = "a".repeat(60);
|
||
assert_eq!(truncate_media_title(&title), title);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_media_title_over_60() {
|
||
let title: String = "a".repeat(65);
|
||
let expected = format!("{}...", "a".repeat(60));
|
||
assert_eq!(truncate_media_title(&title), expected);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_media_title_unicode() {
|
||
// 61 Unicode chars should trigger truncation
|
||
let title: String = "\u{00FC}".repeat(61); // ü × 61
|
||
let expected = format!("{}...", "\u{00FC}".repeat(60));
|
||
assert_eq!(truncate_media_title(&title), expected);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_to_fit_short() {
|
||
// 100px at ~6.8px/char ≈ 14 chars; "Hello" fits.
|
||
assert_eq!(truncate_to_fit("Hello", 100.0), "Hello");
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_to_fit_long() {
|
||
// 50px at ~6.8px/char ≈ 7 chars; 20-char string truncated.
|
||
let result = truncate_to_fit(&"a".repeat(20), 50.0);
|
||
assert!(result.ends_with('\u{2026}'));
|
||
assert!(result.chars().count() <= 8);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_to_fit_narrow() {
|
||
// Very narrow (10px): minimum 6 chars enforced.
|
||
let result = truncate_to_fit(&"a".repeat(20), 10.0);
|
||
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");
|
||
}
|
||
}
|