Files
RuDS/crates/bds-ui/src/views/sidebar.rs
2026-04-05 12:47:58 +02:00

281 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use iced::widget::{button, column, container, scrollable, text, Space};
use iced::widget::text::Shaping;
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post};
use crate::app::Message;
use crate::i18n::t;
use crate::state::navigation::SidebarView;
use crate::state::tabs::{Tab, TabType};
/// 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::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::default(),
..button::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 => "sidebar.chatPlaceholder",
SidebarView::Import => "sidebar.importPlaceholder",
SidebarView::Git => "sidebar.gitPlaceholder",
SidebarView::Settings => "sidebar.settingsHeader",
}
}
/// sidebar_views.allium media_title_max_length = 60
const MEDIA_TITLE_MAX_LEN: usize = 60;
/// 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()
}
}
pub fn view(
sidebar_view: SidebarView,
posts: &[Post],
media: &[Media],
width: f32,
active_tab: Option<&str>,
locale: UiLocale,
) -> 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 body: Element<'static, Message> = match sidebar_view {
SidebarView::Posts => {
if posts.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.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 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 label = format!("{status_indicator}{}", p.title);
let label_text = text(label)
.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: p.id.clone(),
tab_type: TabType::Post,
title: p.title.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
};
let mut sections: Vec<Element<'static, Message>> = Vec::new();
// Draft section
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
if !drafts.is_empty() {
sections.push(section_header(&t(locale, "sidebar.drafts")));
for p in &drafts {
sections.push(make_post_item(p));
}
sections.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() {
sections.push(section_header(&t(locale, "sidebar.published")));
for p in &published {
sections.push(make_post_item(p));
}
sections.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() {
sections.push(section_header(&t(locale, "sidebar.archived")));
for p in &archived {
sections.push(make_post_item(p));
}
}
iced::widget::Column::with_children(sections)
.spacing(1)
.into()
}
}
SidebarView::Media => {
if media.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.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());
// Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..."
// if over limit; fallback originalName (no truncation).
let display_name = match m.title.as_deref() {
Some(title) => truncate_media_title(title),
None => m.original_name.clone(),
};
let label = format!("\u{1F5BC} {display_name}");
let label_text = text(label)
.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: m.id.clone(),
tab_type: TabType::Media,
title: display_name.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
}
}
_ => {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
}
};
let content = column![
header,
Space::with_height(8.0),
body,
]
.spacing(4)
.padding(12);
// layout.allium: sidebar width is resizable, passed as parameter
container(scrollable(content))
.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);
}
}