diff --git a/crates/bds-ui/assets/icons/folder.svg b/crates/bds-ui/assets/icons/folder.svg new file mode 100644 index 0000000..4d32257 --- /dev/null +++ b/crates/bds-ui/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index f95896e..385e1a6 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -65,6 +65,7 @@ pub enum Message { SetOfflineMode(bool), SetUiLocale(UiLocale), ToggleLocaleDropdown, + ToggleProjectDropdown, // Blog actions (dispatched to engine) RebuildDatabase, @@ -73,6 +74,7 @@ pub enum Message { BlogTaskFinished { label: String, result: Result<(), String> }, Noop, + InitMenuBar, } // ─────────────────────────────────────────────────────────── @@ -115,6 +117,7 @@ pub struct BdsApp { // Flags offline_mode: bool, locale_dropdown_open: bool, + project_dropdown_open: bool, // macOS lifecycle receiver #[cfg(target_os = "macos")] @@ -188,7 +191,9 @@ impl BdsApp { Task::done(Message::ProjectsLoaded(projects.clone())) }; - // Disable items that need selection + // Chain menu initialization after project loading + // (must happen after the event loop has started for macOS) + let init_task = Task::batch([init_task, Task::done(Message::InitMenuBar)]); registry.set_enabled(MenuAction::Save, false); registry.set_enabled(MenuAction::PublishSelected, false); registry.set_enabled(MenuAction::PreviewPost, false); @@ -216,6 +221,7 @@ impl BdsApp { ui_locale: locale, offline_mode: false, locale_dropdown_open: false, + project_dropdown_open: false, #[cfg(target_os = "macos")] _lifecycle_rx, }, @@ -294,6 +300,7 @@ impl BdsApp { Task::none() } Message::SwitchProject(project_id) => { + self.project_dropdown_open = false; if let Some(ref db) = self.db { match engine::project::set_active_project(db.conn(), &project_id) { Ok(()) => { @@ -431,6 +438,12 @@ impl BdsApp { } Message::ToggleLocaleDropdown => { self.locale_dropdown_open = !self.locale_dropdown_open; + self.project_dropdown_open = false; + Task::none() + } + Message::ToggleProjectDropdown => { + self.project_dropdown_open = !self.project_dropdown_open; + self.locale_dropdown_open = false; Task::none() } @@ -463,6 +476,11 @@ impl BdsApp { } Message::Noop => Task::none(), + Message::InitMenuBar => { + #[cfg(target_os = "macos")] + menu::init_menu_for_nsapp(&self._menu_bar); + Task::none() + } } } @@ -479,10 +497,13 @@ impl BdsApp { &self.task_snapshots, &self.output_entries, active_name, + &self.projects, + self.active_project.as_ref().map(|p| p.id.as_str()), 0, // post_count — populated in later milestones 0, // media_count — populated in later milestones self.offline_mode, self.locale_dropdown_open, + self.project_dropdown_open, self.ui_locale, ) } diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index aba7cc4..c2409b8 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -289,15 +289,18 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { let _ = menu.append(&blog_menu); let _ = menu.append(&help_menu); - // Attach on macOS - #[cfg(target_os = "macos")] - { - let _ = menu.init_for_nsapp(); - } - (menu, reg) } +/// Attach the built menu to the macOS NSApp. +/// +/// Must be called **after** the event loop has started (e.g. from the +/// init task or first update), not during `build_menu_bar`. +#[cfg(target_os = "macos")] +pub fn init_menu_for_nsapp(menu: &Menu) { + let _ = menu.init_for_nsapp(); +} + /// Re-translate every registered menu item for a new locale. pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) { for &action in MenuAction::ALL { diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index 68a0293..bca8225 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -4,4 +4,5 @@ pub mod sidebar; pub mod tab_bar; pub mod status_bar; pub mod panel; +pub mod project_selector; pub mod welcome; diff --git a/crates/bds-ui/src/views/project_selector.rs b/crates/bds-ui/src/views/project_selector.rs new file mode 100644 index 0000000..7714585 --- /dev/null +++ b/crates/bds-ui/src/views/project_selector.rs @@ -0,0 +1,226 @@ +use iced::widget::{button, container, row, svg, text, Column, Space}; +use iced::{Background, Border, Color, Element, Length, Theme}; + +use bds_core::i18n::UiLocale; +use bds_core::model::Project; + +use crate::app::Message; +use crate::i18n::t; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +fn dropdown_bg(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 4.0.into(), + }, + ..container::Style::default() + } +} + +fn project_item(_theme: &Theme, status: button::Status) -> button::Style { + let bg = match status { + button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30), + _ => Color::from_rgb(0.18, 0.18, 0.22), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 2.0.into(), + }, + ..button::Style::default() + } +} + +fn project_item_active(_theme: &Theme, status: button::Status) -> button::Style { + let bg = match status { + button::Status::Hovered => Color::from_rgb(0.25, 0.32, 0.45), + _ => Color::from_rgb(0.20, 0.28, 0.40), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 2.0.into(), + }, + ..button::Style::default() + } +} + +fn new_project_btn(_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.55, 0.75, 0.95), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 2.0.into(), + }, + ..button::Style::default() + } +} + +fn header_style(_theme: &Theme) -> container::Style { + container::Style { + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 0.0, + radius: 0.0.into(), + }, + ..container::Style::default() + } +} + +// --------------------------------------------------------------------------- +// Folder icon SVG (16x16) +// --------------------------------------------------------------------------- + +const FOLDER_ICON: &[u8] = include_bytes!("../../assets/icons/folder.svg"); + +// --------------------------------------------------------------------------- +// View: renders the dropdown content (project list + new project) +// --------------------------------------------------------------------------- + +pub fn view( + projects: &[Project], + active_project_id: Option<&str>, + locale: UiLocale, +) -> Element<'static, Message> { + let header = container( + text(t(locale, "projectSelector.projectsHeader")) + .size(11) + .color(Color::from_rgb(0.55, 0.55, 0.60)), + ) + .padding([4, 8]) + .style(header_style); + + let mut items: Vec> = Vec::new(); + items.push(header.into()); + + for project in projects { + let is_active = active_project_id == Some(project.id.as_str()); + let name = project.name.clone(); + let id = project.id.clone(); + + let label = if is_active { + row![ + text("\u{2713}").size(12).color(Color::from_rgb(0.40, 0.80, 0.40)), + text(name).size(12).color(Color::WHITE), + ] + .spacing(6) + } else { + row![ + Space::with_width(Length::Fixed(14.0)), + text(name).size(12).color(Color::from_rgb(0.80, 0.80, 0.85)), + ] + .spacing(6) + }; + + let style_fn = if is_active { project_item_active } else { project_item }; + + items.push( + button(label) + .on_press(Message::SwitchProject(id)) + .padding([4, 8]) + .width(Length::Fill) + .style(style_fn) + .into(), + ); + } + + // Separator + items.push( + container(Space::new(Length::Fill, Length::Fixed(1.0))) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.30, 0.30, 0.35))), + ..container::Style::default() + }) + .width(Length::Fill) + .into(), + ); + + // New project button + items.push( + button( + row![ + text("+").size(14).color(Color::from_rgb(0.55, 0.75, 0.95)), + text(t(locale, "projectSelector.newProject")).size(12), + ] + .spacing(6), + ) + .on_press(Message::RequestCreateProject) + .padding([4, 8]) + .width(Length::Fill) + .style(new_project_btn) + .into(), + ); + + container( + Column::with_children(items) + .spacing(2) + .padding(4) + .width(Length::Fixed(200.0)), + ) + .style(dropdown_bg) + .into() +} + +// --------------------------------------------------------------------------- +// Trigger button for status bar (folder icon + project name + chevron) +// --------------------------------------------------------------------------- + +pub fn trigger_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::WHITE, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 3.0.into(), + }, + ..button::Style::default() + } +} + +pub fn trigger_button(project_name: &str) -> Element<'static, Message> { + let folder = svg::Handle::from_memory(FOLDER_ICON); + let folder_icon = svg(folder) + .width(Length::Fixed(14.0)) + .height(Length::Fixed(14.0)); + + let name = text(project_name.to_string()) + .size(12) + .color(Color::from_rgb(0.80, 0.80, 0.85)); + + let chevron = text("\u{25BE}") + .size(10) + .color(Color::from_rgb(0.55, 0.55, 0.60)); + + button( + row![folder_icon, name, chevron] + .spacing(4) + .align_y(iced::Alignment::Center), + ) + .on_press(Message::ToggleProjectDropdown) + .padding([2, 6]) + .style(trigger_style) + .into() +} diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs index 93296eb..35b3fdd 100644 --- a/crates/bds-ui/src/views/status_bar.rs +++ b/crates/bds-ui/src/views/status_bar.rs @@ -7,6 +7,7 @@ use bds_core::i18n::UiLocale; use crate::app::Message; use crate::i18n::tw; use crate::state::navigation::TaskSnapshot; +use crate::views::project_selector; // --------------------------------------------------------------------------- // Styles @@ -119,9 +120,9 @@ pub fn view( ) -> Element<'static, Message> { let label_color = Color::from_rgb(0.60, 0.60, 0.65); - // ── Left: project name + task indicator ── - let project_label = active_project_name.unwrap_or("\u{2014}").to_string(); - let project_text = text(project_label).size(12).color(Color::from_rgb(0.80, 0.80, 0.85)); + // ── Left: project selector trigger + task indicator ── + let project_name = active_project_name.unwrap_or("\u{2014}"); + let project_trigger = project_selector::trigger_button(project_name); let running: Vec<&TaskSnapshot> = task_snapshots .iter() @@ -138,7 +139,9 @@ pub fn view( Space::with_width(0).into() }; - let left = row![project_text, task_indicator].spacing(12); + let left = row![project_trigger, task_indicator] + .spacing(12) + .align_y(Alignment::Center); // ── Right side ── diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index aa69da0..5568061 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -3,11 +3,12 @@ use iced::widget::text::Shaping; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; use bds_core::i18n::UiLocale; +use bds_core::model::Project; use crate::app::Message; use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; use crate::state::tabs::Tab; -use crate::views::{activity_bar, panel, sidebar, status_bar, tab_bar, welcome}; +use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, welcome}; /// Main content area background. fn content_bg(_theme: &Theme) -> container::Style { @@ -54,10 +55,13 @@ pub fn view( output_entries: &[OutputEntry], // Status bar active_project_name: Option<&str>, + projects: &[Project], + active_project_id: Option<&str>, post_count: usize, media_count: usize, offline_mode: bool, locale_dropdown_open: bool, + project_dropdown_open: bool, // i18n locale: UiLocale, ) -> Element<'static, Message> { @@ -107,8 +111,8 @@ pub fn view( .height(Length::Fill) .into(); - if locale_dropdown_open { - // Build the dropdown menu overlay + // Overlay: either locale dropdown or project dropdown (mutually exclusive) + let overlay: Option> = if locale_dropdown_open { let items: Vec> = UiLocale::all() .iter() .map(|&l| { @@ -129,25 +133,52 @@ pub fn view( ) .style(status_bar::dropdown_bg); - // Position the dropdown at bottom-right, above the status bar (24px) - let overlay: Element<'static, Message> = container( + // Position at bottom-right, above status bar + Some( container( - row![ - Space::with_width(Length::Fill), - dropdown_menu, - // Offset from right edge to align near the flag trigger - Space::with_width(Length::Fixed(40.0)), - ] + container( + row![ + Space::with_width(Length::Fill), + dropdown_menu, + Space::with_width(Length::Fixed(40.0)), + ] + ) + .width(Length::Fill) + .align_y(Alignment::End) + .padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 }) ) .width(Length::Fill) + .height(Length::Fill) .align_y(Alignment::End) - .padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 }) // above status bar + .into() ) - .width(Length::Fill) - .height(Length::Fill) - .align_y(Alignment::End) - .into(); + } else if project_dropdown_open { + let dropdown = project_selector::view(projects, active_project_id, locale); + // Position at bottom-left, above status bar + Some( + container( + container( + row![ + Space::with_width(Length::Fixed(8.0)), + dropdown, + Space::with_width(Length::Fill), + ] + ) + .width(Length::Fill) + .align_y(Alignment::End) + .padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 }) + ) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::End) + .into() + ) + } else { + None + }; + + if let Some(overlay) = overlay { stack![base_layout, overlay] .width(Length::Fill) .height(Length::Fill) diff --git a/crates/bds-ui/tests/app_smoke.rs b/crates/bds-ui/tests/app_smoke.rs index 83f87fa..7d3cb8c 100644 --- a/crates/bds-ui/tests/app_smoke.rs +++ b/crates/bds-ui/tests/app_smoke.rs @@ -71,6 +71,9 @@ fn new_message_variants_constructable() { // Settings let _offline = Message::SetOfflineMode(true); let _locale = Message::SetUiLocale(UiLocale::De); + let _toggle_locale = Message::ToggleLocaleDropdown; + let _toggle_project = Message::ToggleProjectDropdown; + let _init_menu = Message::InitMenuBar; // Blog actions let _rebuild = Message::RebuildDatabase;