fix: database migration
This commit is contained in:
@@ -32,11 +32,17 @@ pub enum Message {
|
||||
ToggleSidebar,
|
||||
TogglePanel,
|
||||
|
||||
// Sidebar resize
|
||||
SidebarResizeStart,
|
||||
SidebarResizeMove(f32),
|
||||
SidebarResizeEnd,
|
||||
|
||||
// Tabs
|
||||
OpenTab(Tab),
|
||||
CloseTab(String),
|
||||
SelectTab(String),
|
||||
PinTab(String),
|
||||
ClearTabs,
|
||||
|
||||
// Project
|
||||
ProjectsLoaded(Vec<Project>),
|
||||
@@ -111,6 +117,8 @@ pub struct BdsApp {
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
sidebar_visible: bool,
|
||||
sidebar_width: f32,
|
||||
sidebar_dragging: bool,
|
||||
|
||||
// Tabs
|
||||
tabs: Vec<Tab>,
|
||||
@@ -230,6 +238,8 @@ impl BdsApp {
|
||||
sidebar_media: Vec::new(),
|
||||
sidebar_view: SidebarView::Posts,
|
||||
sidebar_visible: true,
|
||||
sidebar_width: 280.0,
|
||||
sidebar_dragging: false,
|
||||
tabs: Vec::new(),
|
||||
active_tab: None,
|
||||
panel_visible: false,
|
||||
@@ -276,6 +286,22 @@ impl BdsApp {
|
||||
self.panel_visible = !self.panel_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::SidebarResizeStart => {
|
||||
self.sidebar_dragging = true;
|
||||
Task::none()
|
||||
}
|
||||
Message::SidebarResizeMove(x) => {
|
||||
if self.sidebar_dragging {
|
||||
// x is global cursor position; subtract activity bar width (~48px)
|
||||
let effective = x - 48.0;
|
||||
self.sidebar_width = effective.clamp(200.0, 500.0);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SidebarResizeEnd => {
|
||||
self.sidebar_dragging = false;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Tabs ──
|
||||
Message::OpenTab(tab) => {
|
||||
@@ -308,6 +334,11 @@ impl BdsApp {
|
||||
tabs::pin_tab(&mut self.tabs, &id);
|
||||
Task::none()
|
||||
}
|
||||
Message::ClearTabs => {
|
||||
self.tabs.clear();
|
||||
self.active_tab = None;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Project management ──
|
||||
Message::ProjectsLoaded(projects) => {
|
||||
@@ -463,6 +494,12 @@ impl BdsApp {
|
||||
self.ui_locale = locale;
|
||||
self.locale_dropdown_open = false;
|
||||
menu::update_menu_labels(&self.menu_registry, locale);
|
||||
// Re-translate singleton tab titles per tabs.allium
|
||||
for tab in &mut self.tabs {
|
||||
if let Some(key) = tab.tab_type.i18n_key() {
|
||||
tab.title = t(locale, key);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleLocaleDropdown => {
|
||||
@@ -533,7 +570,7 @@ impl BdsApp {
|
||||
)
|
||||
}
|
||||
Message::ValidateTranslations => {
|
||||
self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation");
|
||||
self.open_singleton_tab(TabType::TranslationValidation, "tabBar.translationValidation");
|
||||
self.spawn_engine_task(
|
||||
"engine.validateTranslationsStarted",
|
||||
|db_path, project_id, data_dir, tm, tid| {
|
||||
@@ -569,7 +606,7 @@ impl BdsApp {
|
||||
)
|
||||
}
|
||||
Message::RunMetadataDiff => {
|
||||
self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff");
|
||||
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
||||
Task::none()
|
||||
}
|
||||
Message::EngineTaskDone { task_id, label, result } => {
|
||||
@@ -617,6 +654,7 @@ impl BdsApp {
|
||||
workspace::view(
|
||||
self.sidebar_view,
|
||||
self.sidebar_visible,
|
||||
self.sidebar_width,
|
||||
&self.tabs,
|
||||
self.active_tab.as_deref(),
|
||||
self.panel_visible,
|
||||
@@ -664,12 +702,12 @@ impl BdsApp {
|
||||
if let (Some(db), Some(project), Some(data_dir)) =
|
||||
(&self.db, &self.active_project, &self.data_dir)
|
||||
{
|
||||
let title = t(self.ui_locale, "post.untitled");
|
||||
let display_title = t(self.ui_locale, "post.untitled");
|
||||
match engine::post::create_post(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
&project.id,
|
||||
&title,
|
||||
"",
|
||||
Some(""),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -681,7 +719,7 @@ impl BdsApp {
|
||||
let tab = Tab {
|
||||
id: post.id.clone(),
|
||||
tab_type: TabType::Post,
|
||||
title: post.title.clone(),
|
||||
title: display_title.to_string(),
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
};
|
||||
@@ -713,7 +751,7 @@ impl BdsApp {
|
||||
MenuAction::Find => Task::none(),
|
||||
MenuAction::Replace => Task::none(),
|
||||
MenuAction::EditPreferences => {
|
||||
self.open_singleton_tab(TabType::Settings, "Settings");
|
||||
self.open_singleton_tab(TabType::Settings, "common.settings");
|
||||
Task::none()
|
||||
}
|
||||
// View
|
||||
@@ -733,7 +771,7 @@ impl BdsApp {
|
||||
MenuAction::PublishSelected => Task::none(), // Disabled in M2
|
||||
MenuAction::PreviewPost => Task::none(), // Disabled in M2
|
||||
MenuAction::EditMenu => {
|
||||
self.open_singleton_tab(TabType::MenuEditor, "Menu Editor");
|
||||
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
||||
@@ -751,7 +789,7 @@ impl BdsApp {
|
||||
}
|
||||
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
||||
MenuAction::ValidateSite => {
|
||||
self.open_singleton_tab(TabType::SiteValidation, "Site Validation");
|
||||
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::UploadSite => {
|
||||
@@ -780,7 +818,7 @@ impl BdsApp {
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::OpenDocumentation => {
|
||||
self.open_singleton_tab(TabType::Documentation, "Documentation");
|
||||
self.open_singleton_tab(TabType::Documentation, "tabBar.documentation");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::ViewOnGitHub => {
|
||||
@@ -794,11 +832,12 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) {
|
||||
fn open_singleton_tab(&mut self, tab_type: TabType, i18n_key: &str) {
|
||||
let title = t(self.ui_locale, i18n_key);
|
||||
let tab = Tab {
|
||||
id: tab_type.singleton_id().to_string(),
|
||||
tab_type,
|
||||
title: title.to_string(),
|
||||
title,
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
};
|
||||
@@ -806,6 +845,7 @@ impl BdsApp {
|
||||
if let Some(t) = self.tabs.get(idx) {
|
||||
self.active_tab = Some(t.id.clone());
|
||||
}
|
||||
self.enforce_panel_tab_availability();
|
||||
}
|
||||
|
||||
fn refresh_task_snapshots(&mut self) {
|
||||
@@ -815,7 +855,7 @@ impl BdsApp {
|
||||
.into_iter()
|
||||
.map(|(id, label, status, progress, message)| {
|
||||
let status_str = match &status {
|
||||
TaskStatus::Queued => "queued".to_string(),
|
||||
TaskStatus::Pending => "pending".to_string(),
|
||||
TaskStatus::Running => "running".to_string(),
|
||||
TaskStatus::Completed => "completed".to_string(),
|
||||
TaskStatus::Failed(e) => format!("failed: {e}"),
|
||||
|
||||
@@ -28,7 +28,7 @@ pub enum MenuAction {
|
||||
ViewMedia,
|
||||
ToggleSidebar,
|
||||
TogglePanel,
|
||||
// Blog
|
||||
// Window
|
||||
PublishSelected,
|
||||
PreviewPost,
|
||||
EditMenu,
|
||||
@@ -249,28 +249,28 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
||||
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
||||
|
||||
// -- Blog --
|
||||
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
|
||||
// -- Window --
|
||||
let window_menu = Submenu::new(translate(locale, "menu.group.window"), true);
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP))));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV))));
|
||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
|
||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
|
||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
|
||||
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
||||
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
|
||||
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
|
||||
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL))));
|
||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
|
||||
let _ = window_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
|
||||
|
||||
// -- Help --
|
||||
@@ -286,7 +286,7 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
||||
let _ = menu.append(&file_menu);
|
||||
let _ = menu.append(&edit_menu);
|
||||
let _ = menu.append(&view_menu);
|
||||
let _ = menu.append(&blog_menu);
|
||||
let _ = menu.append(&window_menu);
|
||||
let _ = menu.append(&help_menu);
|
||||
|
||||
(menu, reg)
|
||||
|
||||
@@ -64,6 +64,24 @@ impl TabType {
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the i18n key for the tab title of a singleton tab type.
|
||||
/// Per tabs.allium: singletons use i18n key lookup.
|
||||
pub fn i18n_key(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Settings => Some("common.settings"),
|
||||
Self::Style => Some("tabBar.style"),
|
||||
Self::Tags => Some("tabBar.tags"),
|
||||
Self::MenuEditor => Some("tabBar.menuEditor"),
|
||||
Self::MetadataDiff => Some("tabBar.metadataDiff"),
|
||||
Self::Documentation => Some("tabBar.documentation"),
|
||||
Self::ApiDocumentation => Some("tabBar.apiDocumentation"),
|
||||
Self::SiteValidation => Some("tabBar.siteValidation"),
|
||||
Self::TranslationValidation => Some("tabBar.translationValidation"),
|
||||
Self::FindDuplicates => Some("tabBar.findDuplicates"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single tab in the editor area.
|
||||
|
||||
@@ -127,8 +127,11 @@ pub fn view(
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
// Per layout.allium: last 10 tasks, newest first
|
||||
let items: Vec<Element<'static, Message>> = task_snapshots
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.map(|snap| {
|
||||
let progress_str = snap.progress
|
||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||
|
||||
@@ -10,15 +10,10 @@ use crate::i18n::t;
|
||||
use crate::state::navigation::SidebarView;
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
|
||||
/// Sidebar container style — dark background with right border separator.
|
||||
/// 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))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.25, 0.25, 0.30),
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
@@ -37,6 +32,20 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -53,10 +62,26 @@ fn placeholder_key(view: SidebarView) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -76,30 +101,77 @@ pub fn view(
|
||||
.color(muted)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = posts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
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);
|
||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||
.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])
|
||||
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)
|
||||
.style(item_style)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.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()
|
||||
}
|
||||
@@ -115,20 +187,34 @@ pub fn view(
|
||||
let items: Vec<Element<'static, Message>> = media
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let display_name = m.title.as_deref()
|
||||
.unwrap_or(&m.original_name);
|
||||
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}");
|
||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||
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.to_string(),
|
||||
title: display_name.clone(),
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(item_style)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
@@ -154,9 +240,41 @@ pub fn view(
|
||||
.spacing(4)
|
||||
.padding(12);
|
||||
|
||||
// layout.allium: sidebar width is resizable, passed as parameter
|
||||
container(scrollable(content))
|
||||
.width(Length::Fixed(280.0))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
use iced::widget::{button, container, row, text, Space};
|
||||
use iced::widget::{button, container, row, scrollable, text, tooltip, Space};
|
||||
use iced::widget::scrollable::Direction;
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::tooltip::Position;
|
||||
use iced::{Background, Border, Color, Element, Font, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
use crate::state::tabs::Tab;
|
||||
|
||||
/// tabs.allium config: tab_min_width=100, tab_max_width=160
|
||||
/// In a scrollable tab bar, tabs use the max width since they never need to shrink.
|
||||
const TAB_WIDTH: f32 = 160.0;
|
||||
const CHAT_TITLE_MAX_LEN: usize = 18;
|
||||
|
||||
/// Tab bar background.
|
||||
fn bar_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
@@ -64,9 +74,49 @@ fn close_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tooltip style.
|
||||
fn tooltip_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.24))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.35, 0.35, 0.40),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate chat title per tabs.allium: chat_title_max_length = 18.
|
||||
fn truncate_chat_title(title: &str) -> String {
|
||||
if title.chars().count() > CHAT_TITLE_MAX_LEN {
|
||||
let truncated: String = title.chars().take(CHAT_TITLE_MAX_LEN).collect();
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
title.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build tooltip text per tabs.allium:
|
||||
/// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified".
|
||||
fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String {
|
||||
let mut tip = tab.title.clone();
|
||||
if tab.is_transient {
|
||||
tip.push_str(" (");
|
||||
tip.push_str(&t(locale, "tabBar.preview"));
|
||||
tip.push(')');
|
||||
}
|
||||
if tab.is_dirty {
|
||||
tip.push_str(" * ");
|
||||
tip.push_str(&t(locale, "tabBar.modified"));
|
||||
}
|
||||
tip
|
||||
}
|
||||
|
||||
pub fn view(
|
||||
tabs: &[Tab],
|
||||
active_tab: Option<&str>,
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
// Per tabs.allium: "Hidden when no tabs exist."
|
||||
if tabs.is_empty() {
|
||||
@@ -80,16 +130,25 @@ pub fn view(
|
||||
let tab_id = tab.id.clone();
|
||||
let close_id = tab.id.clone();
|
||||
|
||||
// Per tabs.allium: chat titles are JS-truncated to 18 chars + "..."
|
||||
let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat {
|
||||
truncate_chat_title(&tab.title)
|
||||
} else {
|
||||
tab.title.clone()
|
||||
};
|
||||
|
||||
// Per tabs.allium: transient tabs show italic title
|
||||
let title_label = if tab.is_transient {
|
||||
text(tab.title.clone())
|
||||
text(display_title)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
} else {
|
||||
text(tab.title.clone())
|
||||
text(display_title)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
};
|
||||
|
||||
// Per tabs.allium DirtyIndicator: dot for dirty post tabs
|
||||
@@ -100,32 +159,85 @@ pub fn view(
|
||||
text("").size(10)
|
||||
};
|
||||
|
||||
// Title + dirty indicator clipped to enforce ellipsis-like truncation
|
||||
let title_area = container(
|
||||
row![title_label, dirty_indicator]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.clip(true);
|
||||
|
||||
let label = row![
|
||||
title_label,
|
||||
dirty_indicator,
|
||||
title_area,
|
||||
button(text("\u{2715}").size(10).shaping(Shaping::Advanced))
|
||||
.on_press(Message::CloseTab(close_id))
|
||||
.padding(2)
|
||||
.style(close_style),
|
||||
]
|
||||
.spacing(6)
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
button(label)
|
||||
// tabs.allium: tab_min_width=100, tab_max_width=160
|
||||
let tab_btn = button(label)
|
||||
.on_press(Message::SelectTab(tab_id))
|
||||
.padding([6, 12])
|
||||
.style(if is_active { tab_active } else { tab_inactive })
|
||||
.into()
|
||||
.padding([6, 8])
|
||||
.width(Length::Fixed(TAB_WIDTH))
|
||||
.style(if is_active { tab_active } else { tab_inactive });
|
||||
|
||||
// tabs.allium tooltip: title + "(Preview)" if transient + "* Modified" if dirty
|
||||
let tooltip_text = build_tooltip_text(tab, locale);
|
||||
let tip: Element<'static, Message> = tooltip(
|
||||
tab_btn,
|
||||
text(tooltip_text).size(11).shaping(Shaping::Advanced),
|
||||
Position::Bottom,
|
||||
)
|
||||
.gap(4)
|
||||
.style(tooltip_style)
|
||||
.into();
|
||||
|
||||
tip
|
||||
})
|
||||
.collect();
|
||||
|
||||
container(
|
||||
iced::widget::Row::with_children(tab_buttons)
|
||||
.spacing(1)
|
||||
.height(Length::Fixed(35.0)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(35.0))
|
||||
.style(bar_style)
|
||||
.into()
|
||||
// tabs.allium: horizontal strip with overflow scroll
|
||||
let tab_row = iced::widget::Row::with_children(tab_buttons)
|
||||
.spacing(1)
|
||||
.height(Length::Fixed(35.0));
|
||||
|
||||
let scrollable_tabs = scrollable(tab_row)
|
||||
.direction(Direction::Horizontal(
|
||||
scrollable::Scrollbar::new().width(0).scroller_width(0),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(35.0));
|
||||
|
||||
container(scrollable_tabs)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(35.0))
|
||||
.style(bar_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_chat_title_short() {
|
||||
assert_eq!(truncate_chat_title("Hello"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chat_title_exact_18() {
|
||||
let title: String = "a".repeat(18);
|
||||
assert_eq!(truncate_chat_title(&title), title);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chat_title_over_18() {
|
||||
let title: String = "a".repeat(25);
|
||||
let expected = format!("{}...", "a".repeat(18));
|
||||
assert_eq!(truncate_chat_title(&title), expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use iced::widget::{button, column, container, row, stack, text, Space};
|
||||
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};
|
||||
|
||||
@@ -19,15 +19,12 @@ fn content_bg(_theme: &Theme) -> container::Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal separator line between regions.
|
||||
fn separator_v() -> iced::widget::Container<'static, Message> {
|
||||
container(Space::new(0, 0))
|
||||
.width(Length::Fixed(1.0))
|
||||
.height(Length::Fill)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
|
||||
..container::Style::default()
|
||||
})
|
||||
/// Sidebar resize drag handle style.
|
||||
fn drag_handle_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal line separator (full width).
|
||||
@@ -46,6 +43,7 @@ pub fn view(
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
sidebar_visible: bool,
|
||||
sidebar_width: f32,
|
||||
// Tabs
|
||||
tabs: &[Tab],
|
||||
active_tab: Option<&str>,
|
||||
@@ -76,7 +74,7 @@ pub fn view(
|
||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||
|
||||
// Tab bar
|
||||
let tabs_el = tab_bar::view(tabs, active_tab);
|
||||
let tabs_el = tab_bar::view(tabs, active_tab, locale);
|
||||
|
||||
// Content area
|
||||
let content_area = welcome::view(locale);
|
||||
@@ -104,12 +102,25 @@ pub fn view(
|
||||
.height(Length::Fill)
|
||||
.style(content_bg);
|
||||
|
||||
// Main row: activity bar | separator | sidebar? | separator | right column
|
||||
// Main row: activity bar | sidebar? | drag handle | right column
|
||||
let mut main_row = row![activity];
|
||||
|
||||
if sidebar_visible {
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, locale));
|
||||
main_row = main_row.push(separator_v());
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
|
||||
|
||||
// Resize drag handle: 4px wide strip between sidebar and content
|
||||
let handle = container(Space::new(0, 0))
|
||||
.width(Length::Fixed(4.0))
|
||||
.height(Length::Fill)
|
||||
.style(drag_handle_style);
|
||||
|
||||
let handle_hover = mouse_area(handle)
|
||||
.on_press(Message::SidebarResizeStart)
|
||||
.on_release(Message::SidebarResizeEnd)
|
||||
.on_move(|point| Message::SidebarResizeMove(point.x))
|
||||
.interaction(iced::mouse::Interaction::ResizingHorizontally);
|
||||
|
||||
main_row = main_row.push(handle_hover);
|
||||
}
|
||||
|
||||
main_row = main_row.push(right);
|
||||
|
||||
Reference in New Issue
Block a user