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;