feat: first take at M2
This commit is contained in:
@@ -1,48 +1,610 @@
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Element, Length, Subscription, Task};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::platform::menu;
|
||||
use iced::{Element, Subscription, Task};
|
||||
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::task::{TaskManager, TaskStatus};
|
||||
use bds_core::engine;
|
||||
use bds_core::i18n::{detect_os_locale, UiLocale};
|
||||
use bds_core::model::Project;
|
||||
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::platform::menu::{self, MenuAction, MenuRegistry};
|
||||
use crate::state::navigation::{
|
||||
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
|
||||
};
|
||||
use crate::state::tabs::{self, Tab, TabType};
|
||||
use crate::views::workspace;
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// Message
|
||||
// ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
// Menu
|
||||
MenuEvent(muda::MenuId),
|
||||
|
||||
// Navigation
|
||||
SetActiveView(SidebarView),
|
||||
ToggleSidebar,
|
||||
TogglePanel,
|
||||
|
||||
// Tabs
|
||||
OpenTab(Tab),
|
||||
CloseTab(String),
|
||||
SelectTab(String),
|
||||
PinTab(String),
|
||||
|
||||
// Project
|
||||
ProjectsLoaded(Vec<Project>),
|
||||
SwitchProject(String),
|
||||
ProjectSwitched(Result<String, String>),
|
||||
RequestCreateProject,
|
||||
CreateProject { name: String, data_path: Option<PathBuf> },
|
||||
ProjectCreated(Result<String, String>),
|
||||
DeleteProject(String),
|
||||
ProjectDeleted(Result<String, String>),
|
||||
|
||||
// Dialogs
|
||||
FolderPicked(Option<PathBuf>),
|
||||
MediaFilesPicked(Option<Vec<PathBuf>>),
|
||||
|
||||
// Tasks
|
||||
TaskTick,
|
||||
|
||||
// macOS lifecycle
|
||||
FileOpenRequested(PathBuf),
|
||||
UrlOpenRequested(String),
|
||||
|
||||
// Panel
|
||||
SetPanelTab(PanelTab),
|
||||
|
||||
// Settings
|
||||
SetOfflineMode(bool),
|
||||
SetUiLocale(UiLocale),
|
||||
|
||||
// Blog actions (dispatched to engine)
|
||||
RebuildDatabase,
|
||||
ReindexText,
|
||||
RunMetadataDiff,
|
||||
BlogTaskFinished { label: String, result: Result<(), String> },
|
||||
|
||||
Noop,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// App State
|
||||
// ───────────────────────────────────────────────────────────
|
||||
|
||||
pub struct BdsApp {
|
||||
// Database
|
||||
db: Option<Database>,
|
||||
|
||||
// Project
|
||||
active_project: Option<Project>,
|
||||
projects: Vec<Project>,
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
sidebar_visible: bool,
|
||||
|
||||
// Tabs
|
||||
tabs: Vec<Tab>,
|
||||
active_tab: Option<String>,
|
||||
|
||||
// Panel
|
||||
panel_visible: bool,
|
||||
panel_tab: PanelTab,
|
||||
|
||||
// Tasks
|
||||
task_manager: Arc<TaskManager>,
|
||||
task_snapshots: Vec<TaskSnapshot>,
|
||||
output_entries: Vec<OutputEntry>,
|
||||
|
||||
// Platform
|
||||
_menu_bar: muda::Menu,
|
||||
menu_registry: MenuRegistry,
|
||||
|
||||
// i18n
|
||||
ui_locale: UiLocale,
|
||||
|
||||
// Flags
|
||||
offline_mode: bool,
|
||||
|
||||
// macOS lifecycle receiver
|
||||
#[cfg(target_os = "macos")]
|
||||
_lifecycle_rx: std::sync::mpsc::Receiver<crate::platform::macos::LifecycleEvent>,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// App Implementation
|
||||
// ───────────────────────────────────────────────────────────
|
||||
|
||||
impl BdsApp {
|
||||
pub fn new() -> (Self, Task<Message>) {
|
||||
let menu_bar = menu::build_menu_bar();
|
||||
(Self { _menu_bar: menu_bar }, Task::none())
|
||||
let locale = detect_os_locale();
|
||||
let (menu_bar, registry) = menu::build_menu_bar(locale);
|
||||
|
||||
// Open or create the database
|
||||
let db_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("bds")
|
||||
.join("bds.db");
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = db_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let db = Database::open(&db_path).ok();
|
||||
if let Some(ref db) = db {
|
||||
let _ = db.migrate();
|
||||
}
|
||||
|
||||
// Load projects
|
||||
let projects = db
|
||||
.as_ref()
|
||||
.and_then(|d| engine::project::list_projects(d.conn()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let active_project = db
|
||||
.as_ref()
|
||||
.and_then(|d| engine::project::get_active_project(d.conn()).ok())
|
||||
.flatten();
|
||||
|
||||
let data_dir = active_project
|
||||
.as_ref()
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
|
||||
// If no projects exist, create a default one
|
||||
let init_task = if projects.is_empty() {
|
||||
if let Some(ref db) = db {
|
||||
let default_data = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("bds")
|
||||
.join("projects")
|
||||
.join("my-blog");
|
||||
match engine::project::create_project(
|
||||
db.conn(),
|
||||
"My Blog",
|
||||
Some(default_data.to_str().unwrap_or("my-blog")),
|
||||
) {
|
||||
Ok(project) => {
|
||||
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
||||
Task::done(Message::ProjectsLoaded(vec![project]))
|
||||
}
|
||||
Err(_) => Task::none(),
|
||||
}
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
} else {
|
||||
Task::done(Message::ProjectsLoaded(projects.clone()))
|
||||
};
|
||||
|
||||
// Disable items that need selection
|
||||
registry.set_enabled(MenuAction::Save, false);
|
||||
registry.set_enabled(MenuAction::PublishSelected, false);
|
||||
registry.set_enabled(MenuAction::PreviewPost, false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let (_lifecycle_tx, _lifecycle_rx) = crate::platform::macos::lifecycle_channel();
|
||||
|
||||
(
|
||||
Self {
|
||||
db,
|
||||
active_project: active_project.clone(),
|
||||
projects,
|
||||
data_dir,
|
||||
sidebar_view: SidebarView::Posts,
|
||||
sidebar_visible: true,
|
||||
tabs: Vec::new(),
|
||||
active_tab: None,
|
||||
panel_visible: false,
|
||||
panel_tab: PanelTab::Tasks,
|
||||
task_manager: Arc::new(TaskManager::default()),
|
||||
task_snapshots: Vec::new(),
|
||||
output_entries: Vec::new(),
|
||||
_menu_bar: menu_bar,
|
||||
menu_registry: registry,
|
||||
ui_locale: locale,
|
||||
offline_mode: false,
|
||||
#[cfg(target_os = "macos")]
|
||||
_lifecycle_rx,
|
||||
},
|
||||
init_task,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::MenuEvent(_id) => {
|
||||
// Menu routing will be expanded in M2
|
||||
// ── Menu event dispatch ──
|
||||
Message::MenuEvent(id) => {
|
||||
if let Some(action) = self.menu_registry.lookup(&id) {
|
||||
return self.dispatch_menu_action(action);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
Message::SetActiveView(view) => {
|
||||
let (new_view, new_visible) =
|
||||
handle_activity_click(self.sidebar_view, self.sidebar_visible, view);
|
||||
self.sidebar_view = new_view;
|
||||
self.sidebar_visible = new_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleSidebar => {
|
||||
self.sidebar_visible = !self.sidebar_visible;
|
||||
Task::none()
|
||||
}
|
||||
Message::TogglePanel => {
|
||||
self.panel_visible = !self.panel_visible;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Tabs ──
|
||||
Message::OpenTab(tab) => {
|
||||
let idx = tabs::open_tab(&mut self.tabs, tab);
|
||||
if let Some(t) = self.tabs.get(idx) {
|
||||
self.active_tab = Some(t.id.clone());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::CloseTab(id) => {
|
||||
if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) {
|
||||
self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone());
|
||||
} else {
|
||||
self.active_tab = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SelectTab(id) => {
|
||||
if self.tabs.iter().any(|t| t.id == id) {
|
||||
self.active_tab = Some(id);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::PinTab(id) => {
|
||||
tabs::pin_tab(&mut self.tabs, &id);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Project management ──
|
||||
Message::ProjectsLoaded(projects) => {
|
||||
self.projects = projects;
|
||||
// Re-resolve active
|
||||
if let Some(ref db) = self.db {
|
||||
self.active_project = engine::project::get_active_project(db.conn())
|
||||
.ok()
|
||||
.flatten();
|
||||
self.data_dir = self
|
||||
.active_project
|
||||
.as_ref()
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SwitchProject(project_id) => {
|
||||
if let Some(ref db) = self.db {
|
||||
match engine::project::set_active_project(db.conn(), &project_id) {
|
||||
Ok(()) => {
|
||||
self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned();
|
||||
self.data_dir = self
|
||||
.active_project
|
||||
.as_ref()
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
|
||||
self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_output(&t(self.ui_locale, "projectSelector.toast.switchFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ProjectSwitched(result) => {
|
||||
match result {
|
||||
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
|
||||
Err(msg) => self.add_output(&msg),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RequestCreateProject => {
|
||||
crate::platform::dialog::pick_folder()
|
||||
}
|
||||
Message::CreateProject { name, data_path } => {
|
||||
if let Some(ref db) = self.db {
|
||||
let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string());
|
||||
match engine::project::create_project(
|
||||
db.conn(),
|
||||
&name,
|
||||
path_str.as_deref(),
|
||||
) {
|
||||
Ok(project) => {
|
||||
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
||||
self.projects = engine::project::list_projects(db.conn()).unwrap_or_default();
|
||||
self.active_project = Some(project.clone());
|
||||
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
||||
let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]);
|
||||
self.add_output(&msg);
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_output(&t(self.ui_locale, "projectSelector.toast.createFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ProjectCreated(result) => {
|
||||
match result {
|
||||
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
|
||||
Err(msg) => self.add_output(&msg),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::DeleteProject(project_id) => {
|
||||
if let Some(ref db) = self.db {
|
||||
let data_path = self.projects.iter()
|
||||
.find(|p| p.id == project_id)
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
match engine::project::delete_project(db.conn(), &project_id, data_path.as_deref()) {
|
||||
Ok(()) => {
|
||||
self.projects.retain(|p| p.id != project_id);
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_output(&t(self.ui_locale, "projectSelector.toast.deleteFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ProjectDeleted(result) => {
|
||||
if let Err(msg) = result {
|
||||
self.add_output(&msg);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Dialogs ──
|
||||
Message::FolderPicked(path) => {
|
||||
if let Some(path) = path {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "New Project".to_string());
|
||||
return Task::done(Message::CreateProject {
|
||||
name,
|
||||
data_path: Some(path),
|
||||
});
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::MediaFilesPicked(_paths) => {
|
||||
// Media import will be expanded in later milestones
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Tasks ──
|
||||
Message::TaskTick => {
|
||||
self.refresh_task_snapshots();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── macOS lifecycle ──
|
||||
Message::FileOpenRequested(_path) => {
|
||||
// File open handling deferred to later milestones
|
||||
Task::none()
|
||||
}
|
||||
Message::UrlOpenRequested(_url) => {
|
||||
// URL open handling deferred to later milestones
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Panel ──
|
||||
Message::SetPanelTab(tab) => {
|
||||
self.panel_tab = tab;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
Message::SetOfflineMode(mode) => {
|
||||
self.offline_mode = mode;
|
||||
Task::none()
|
||||
}
|
||||
Message::SetUiLocale(locale) => {
|
||||
self.ui_locale = locale;
|
||||
menu::update_menu_labels(&self.menu_registry, locale);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Blog engine actions ──
|
||||
Message::RebuildDatabase => {
|
||||
self.add_output("Rebuilding database...");
|
||||
// Actual rebuild dispatch deferred to later milestones
|
||||
Task::none()
|
||||
}
|
||||
Message::ReindexText => {
|
||||
self.add_output("Reindexing search text...");
|
||||
Task::none()
|
||||
}
|
||||
Message::RunMetadataDiff => {
|
||||
self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff");
|
||||
Task::none()
|
||||
}
|
||||
Message::BlogTaskFinished { label, result } => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let msg = tw(self.ui_locale, "app.taskCompleted", &[("message", &label)]);
|
||||
self.add_output(&msg);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = tw(self.ui_locale, "app.taskFailed", &[("message", &err)]);
|
||||
self.add_output(&msg);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::Noop => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let content = column![text("bDS — Blogging Desktop Server").size(24),]
|
||||
.padding(20)
|
||||
.spacing(10);
|
||||
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
workspace::view(
|
||||
self.sidebar_view,
|
||||
self.sidebar_visible,
|
||||
&self.tabs,
|
||||
self.active_tab.as_deref(),
|
||||
self.panel_visible,
|
||||
self.panel_tab,
|
||||
&self.task_snapshots,
|
||||
&self.output_entries,
|
||||
active_name,
|
||||
0, // post_count — populated in later milestones
|
||||
0, // media_count — populated in later milestones
|
||||
self.offline_mode,
|
||||
self.ui_locale,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
menu::menu_subscription()
|
||||
let menu_sub = menu::menu_subscription();
|
||||
|
||||
let task_tick = iced::time::every(std::time::Duration::from_millis(500))
|
||||
.map(|_| Message::TaskTick);
|
||||
|
||||
Subscription::batch([menu_sub, task_tick])
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
fn dispatch_menu_action(&mut self, action: MenuAction) -> Task<Message> {
|
||||
match action {
|
||||
// File
|
||||
MenuAction::NewPost => {
|
||||
// Will create post + open tab in later milestones
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(),
|
||||
MenuAction::Save => Task::none(), // Disabled in M2
|
||||
MenuAction::OpenInBrowser => Task::none(),
|
||||
MenuAction::OpenDataFolder => {
|
||||
if let Some(ref dir) = self.data_dir {
|
||||
let _ = open::that(dir);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
// Edit
|
||||
MenuAction::Find => Task::none(),
|
||||
MenuAction::Replace => Task::none(),
|
||||
MenuAction::EditPreferences => {
|
||||
self.open_singleton_tab(TabType::Settings, "Settings");
|
||||
Task::none()
|
||||
}
|
||||
// View
|
||||
MenuAction::ViewPosts => {
|
||||
Task::done(Message::SetActiveView(SidebarView::Posts))
|
||||
}
|
||||
MenuAction::ViewMedia => {
|
||||
Task::done(Message::SetActiveView(SidebarView::Media))
|
||||
}
|
||||
MenuAction::ToggleSidebar => {
|
||||
Task::done(Message::ToggleSidebar)
|
||||
}
|
||||
MenuAction::TogglePanel => {
|
||||
Task::done(Message::TogglePanel)
|
||||
}
|
||||
// Blog
|
||||
MenuAction::PublishSelected => Task::none(), // Disabled in M2
|
||||
MenuAction::PreviewPost => Task::none(), // Disabled in M2
|
||||
MenuAction::EditMenu => {
|
||||
self.open_singleton_tab(TabType::MenuEditor, "Menu Editor");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
||||
MenuAction::ReindexText => Task::done(Message::ReindexText),
|
||||
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
|
||||
MenuAction::RegenerateCalendar => Task::none(),
|
||||
MenuAction::ValidateTranslations => {
|
||||
self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::FillMissingTranslations => Task::none(),
|
||||
MenuAction::GenerateSitemap => Task::none(),
|
||||
MenuAction::ValidateSite => {
|
||||
self.open_singleton_tab(TabType::SiteValidation, "Site Validation");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::UploadSite => Task::none(),
|
||||
// Help
|
||||
MenuAction::About => Task::none(),
|
||||
MenuAction::OpenDocumentation => {
|
||||
self.open_singleton_tab(TabType::Documentation, "Documentation");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::ViewOnGitHub => {
|
||||
let _ = open::that("https://github.com/nickarumern/bds");
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::ReportIssue => {
|
||||
let _ = open::that("https://github.com/nickarumern/bds/issues");
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) {
|
||||
let tab = Tab {
|
||||
id: format!("singleton-{title}"),
|
||||
tab_type,
|
||||
title: title.to_string(),
|
||||
is_transient: false,
|
||||
};
|
||||
let idx = tabs::open_tab(&mut self.tabs, tab);
|
||||
if let Some(t) = self.tabs.get(idx) {
|
||||
self.active_tab = Some(t.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_task_snapshots(&mut self) {
|
||||
self.task_snapshots = self
|
||||
.task_manager
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.map(|(id, label, status, progress, message)| {
|
||||
let status_str = match &status {
|
||||
TaskStatus::Queued => "queued".to_string(),
|
||||
TaskStatus::Running => "running".to_string(),
|
||||
TaskStatus::Completed => "completed".to_string(),
|
||||
TaskStatus::Failed(e) => format!("failed: {e}"),
|
||||
TaskStatus::Cancelled => "cancelled".to_string(),
|
||||
};
|
||||
TaskSnapshot {
|
||||
id,
|
||||
label,
|
||||
status: status_str,
|
||||
progress,
|
||||
message,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn add_output(&mut self, text: &str) {
|
||||
self.output_entries.push(OutputEntry {
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
11
crates/bds-ui/src/i18n/mod.rs
Normal file
11
crates/bds-ui/src/i18n/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use bds_core::i18n::{translate, translate_with, UiLocale};
|
||||
|
||||
/// Shorthand for translate in view code.
|
||||
pub fn t(locale: UiLocale, key: &str) -> String {
|
||||
translate(locale, key)
|
||||
}
|
||||
|
||||
/// Shorthand for translate_with in view code.
|
||||
pub fn tw(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> String {
|
||||
translate_with(locale, key, params)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod i18n;
|
||||
pub mod platform;
|
||||
pub mod state;
|
||||
pub mod views;
|
||||
|
||||
pub use app::BdsApp;
|
||||
|
||||
@@ -3,5 +3,6 @@ use bds_ui::BdsApp;
|
||||
fn main() -> iced::Result {
|
||||
iced::application("bDS", BdsApp::update, BdsApp::view)
|
||||
.subscription(BdsApp::subscription)
|
||||
.window_size((1200.0, 800.0))
|
||||
.run_with(BdsApp::new)
|
||||
}
|
||||
|
||||
35
crates/bds-ui/src/platform/dialog.rs
Normal file
35
crates/bds-ui/src/platform/dialog.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use iced::Task;
|
||||
|
||||
use crate::app::Message;
|
||||
|
||||
/// Pick a folder using the native file dialog.
|
||||
pub fn pick_folder() -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Select Folder")
|
||||
.pick_folder()
|
||||
.await
|
||||
.map(|h| h.path().to_path_buf())
|
||||
},
|
||||
Message::FolderPicked,
|
||||
)
|
||||
}
|
||||
|
||||
/// Pick one or more image/media files using the native file dialog.
|
||||
pub fn pick_media_files() -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Import Media")
|
||||
.add_filter(
|
||||
"Images",
|
||||
&["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff", "bmp"],
|
||||
)
|
||||
.pick_files()
|
||||
.await
|
||||
.map(|hs| hs.into_iter().map(|h| h.path().to_path_buf()).collect())
|
||||
},
|
||||
Message::MediaFilesPicked,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
// macOS lifecycle shim — objc2 hooks for NSApplicationDelegate.
|
||||
//
|
||||
// Handles:
|
||||
// - application:openFile: (Finder open)
|
||||
// - application:openURLs: (URL scheme handling)
|
||||
//
|
||||
// These will be forwarded as Message variants into the Iced event loop
|
||||
// via a channel-based subscription.
|
||||
//
|
||||
// Implementation deferred to M2 when the full message routing is in place.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use crate::app::Message;
|
||||
|
||||
/// Events that arrive from macOS lifecycle hooks (openFile, openURLs).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LifecycleEvent {
|
||||
FileOpen(PathBuf),
|
||||
UrlOpen(String),
|
||||
}
|
||||
|
||||
/// Create the macOS lifecycle channel (sender goes to objc hooks, receiver polled by subscription).
|
||||
pub fn lifecycle_channel() -> (mpsc::Sender<LifecycleEvent>, mpsc::Receiver<LifecycleEvent>) {
|
||||
mpsc::channel()
|
||||
}
|
||||
|
||||
/// Poll the macOS lifecycle receiver for the next event and map to a Message.
|
||||
pub fn poll_lifecycle(
|
||||
receiver: &mpsc::Receiver<LifecycleEvent>,
|
||||
) -> Option<Message> {
|
||||
match receiver.try_recv() {
|
||||
Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)),
|
||||
Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,197 @@
|
||||
use iced::Subscription;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::Subscription;
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL};
|
||||
use muda::{Menu, MenuEvent, MenuItem, MenuId, PredefinedMenuItem, Submenu};
|
||||
|
||||
use bds_core::i18n::{translate, UiLocale};
|
||||
use crate::app::Message;
|
||||
|
||||
/// Build the native menu bar with standard application menus.
|
||||
/// Every custom menu item that the application handles.
|
||||
///
|
||||
/// On macOS, also calls `init_for_nsapp()` to register the menu with AppKit.
|
||||
/// This requires an active NSApplication, so it will silently fail in
|
||||
/// test contexts without one (muda returns an error that we discard).
|
||||
pub fn build_menu_bar() -> Menu {
|
||||
let menu = Menu::new();
|
||||
/// Predefined OS items (Undo, Redo, Cut, Copy, Paste, SelectAll, etc.)
|
||||
/// are handled by the platform and do not appear here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MenuAction {
|
||||
// File
|
||||
NewPost,
|
||||
ImportMedia,
|
||||
Save,
|
||||
OpenInBrowser,
|
||||
OpenDataFolder,
|
||||
// Edit (custom items only)
|
||||
Find,
|
||||
Replace,
|
||||
EditPreferences,
|
||||
// View
|
||||
ViewPosts,
|
||||
ViewMedia,
|
||||
ToggleSidebar,
|
||||
TogglePanel,
|
||||
// Blog
|
||||
PublishSelected,
|
||||
PreviewPost,
|
||||
EditMenu,
|
||||
RebuildDatabase,
|
||||
ReindexText,
|
||||
MetadataDiff,
|
||||
RegenerateCalendar,
|
||||
ValidateTranslations,
|
||||
FillMissingTranslations,
|
||||
GenerateSitemap,
|
||||
ValidateSite,
|
||||
UploadSite,
|
||||
// Help
|
||||
About,
|
||||
OpenDocumentation,
|
||||
ViewOnGitHub,
|
||||
ReportIssue,
|
||||
}
|
||||
|
||||
// App menu (macOS only shows this as the app name)
|
||||
impl MenuAction {
|
||||
/// All variants in declaration order.
|
||||
pub const ALL: &'static [MenuAction] = &[
|
||||
MenuAction::NewPost,
|
||||
MenuAction::ImportMedia,
|
||||
MenuAction::Save,
|
||||
MenuAction::OpenInBrowser,
|
||||
MenuAction::OpenDataFolder,
|
||||
MenuAction::Find,
|
||||
MenuAction::Replace,
|
||||
MenuAction::EditPreferences,
|
||||
MenuAction::ViewPosts,
|
||||
MenuAction::ViewMedia,
|
||||
MenuAction::ToggleSidebar,
|
||||
MenuAction::TogglePanel,
|
||||
MenuAction::PublishSelected,
|
||||
MenuAction::PreviewPost,
|
||||
MenuAction::EditMenu,
|
||||
MenuAction::RebuildDatabase,
|
||||
MenuAction::ReindexText,
|
||||
MenuAction::MetadataDiff,
|
||||
MenuAction::RegenerateCalendar,
|
||||
MenuAction::ValidateTranslations,
|
||||
MenuAction::FillMissingTranslations,
|
||||
MenuAction::GenerateSitemap,
|
||||
MenuAction::ValidateSite,
|
||||
MenuAction::UploadSite,
|
||||
MenuAction::About,
|
||||
MenuAction::OpenDocumentation,
|
||||
MenuAction::ViewOnGitHub,
|
||||
MenuAction::ReportIssue,
|
||||
];
|
||||
|
||||
/// Return the i18n key for this action's menu label.
|
||||
pub fn i18n_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::NewPost => "menu.item.newPost",
|
||||
Self::ImportMedia => "menu.item.importMedia",
|
||||
Self::Save => "menu.item.save",
|
||||
Self::OpenInBrowser => "menu.item.openInBrowser",
|
||||
Self::OpenDataFolder => "menu.item.openDataFolder",
|
||||
Self::Find => "menu.item.find",
|
||||
Self::Replace => "menu.item.replace",
|
||||
Self::EditPreferences => "menu.item.editPreferences",
|
||||
Self::ViewPosts => "menu.item.viewPosts",
|
||||
Self::ViewMedia => "menu.item.viewMedia",
|
||||
Self::ToggleSidebar => "menu.item.toggleSidebar",
|
||||
Self::TogglePanel => "menu.item.togglePanel",
|
||||
Self::PublishSelected => "menu.item.publishSelected",
|
||||
Self::PreviewPost => "menu.item.previewPost",
|
||||
Self::EditMenu => "menu.item.editMenu",
|
||||
Self::RebuildDatabase => "menu.item.rebuildDatabase",
|
||||
Self::ReindexText => "menu.item.reindexText",
|
||||
Self::MetadataDiff => "menu.item.metadataDiff",
|
||||
Self::RegenerateCalendar => "menu.item.regenerateCalendar",
|
||||
Self::ValidateTranslations => "menu.item.validateTranslations",
|
||||
Self::FillMissingTranslations => "menu.item.fillMissingTranslations",
|
||||
Self::GenerateSitemap => "menu.item.generateSitemap",
|
||||
Self::ValidateSite => "menu.item.validateSite",
|
||||
Self::UploadSite => "menu.item.uploadSite",
|
||||
Self::About => "menu.item.about",
|
||||
Self::OpenDocumentation => "menu.item.openDocumentation",
|
||||
Self::ViewOnGitHub => "menu.item.viewOnGitHub",
|
||||
Self::ReportIssue => "menu.item.reportIssue",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps between muda `MenuId`s and application `MenuAction`s.
|
||||
///
|
||||
/// Also holds clones of the `MenuItem` handles so that labels and
|
||||
/// enabled state can be changed at runtime (e.g. on locale switch).
|
||||
pub struct MenuRegistry {
|
||||
action_map: HashMap<MenuId, MenuAction>,
|
||||
id_map: HashMap<MenuAction, MenuId>,
|
||||
items: HashMap<MenuAction, MenuItem>,
|
||||
}
|
||||
|
||||
impl MenuRegistry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
action_map: HashMap::new(),
|
||||
id_map: HashMap::new(),
|
||||
items: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(&mut self, action: MenuAction, item: &MenuItem) {
|
||||
self.action_map.insert(item.id().clone(), action);
|
||||
self.id_map.insert(action, item.id().clone());
|
||||
self.items.insert(action, item.clone());
|
||||
}
|
||||
|
||||
/// Look up the `MenuAction` for a raw muda event id.
|
||||
pub fn lookup(&self, id: &MenuId) -> Option<MenuAction> {
|
||||
self.action_map.get(id).copied()
|
||||
}
|
||||
|
||||
/// Enable or disable the menu item for a given action.
|
||||
pub fn set_enabled(&self, action: MenuAction, enabled: bool) {
|
||||
if let Some(item) = self.items.get(&action) {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the displayed text for a given action.
|
||||
pub fn set_text(&self, action: MenuAction, text: &str) {
|
||||
if let Some(item) = self.items.get(&action) {
|
||||
item.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of registered (action, MenuId) pairs.
|
||||
pub fn action_count(&self) -> usize {
|
||||
self.action_map.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu construction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Helper: create a `MenuItem`, register it, and return a reference for
|
||||
/// appending to a `Submenu`.
|
||||
fn item(
|
||||
registry: &mut MenuRegistry,
|
||||
action: MenuAction,
|
||||
locale: UiLocale,
|
||||
accel: Option<Accelerator>,
|
||||
) -> MenuItem {
|
||||
let label = translate(locale, action.i18n_key());
|
||||
let mi = MenuItem::new(label, true, accel);
|
||||
registry.register(action, &mi);
|
||||
mi
|
||||
}
|
||||
|
||||
/// Build the full native menu bar and a registry that maps ids to actions.
|
||||
///
|
||||
/// On macOS this also calls `init_for_nsapp()` to attach the menu.
|
||||
pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
||||
let menu = Menu::new();
|
||||
let mut reg = MenuRegistry::new();
|
||||
|
||||
// -- macOS app menu --
|
||||
let app_menu = Submenu::new("bDS", true);
|
||||
let _ = app_menu.append(&PredefinedMenuItem::about(None, None));
|
||||
let _ = app_menu.append(&PredefinedMenuItem::separator());
|
||||
@@ -23,15 +203,22 @@ pub fn build_menu_bar() -> Menu {
|
||||
let _ = app_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = app_menu.append(&PredefinedMenuItem::quit(None));
|
||||
|
||||
// File menu
|
||||
let file_menu = Submenu::new("File", true);
|
||||
let _ = file_menu.append(&MenuItem::new("Open Project...", true, None));
|
||||
// -- File --
|
||||
let file_menu = Submenu::new(translate(locale, "menu.group.file"), true);
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::NewPost, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN))));
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::ImportMedia, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI))));
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::Save, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS))));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
|
||||
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = file_menu.append(&MenuItem::new("Save", true, None));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
|
||||
|
||||
// Edit menu
|
||||
let edit_menu = Submenu::new("Edit", true);
|
||||
// -- Edit --
|
||||
let edit_menu = Submenu::new(translate(locale, "menu.group.edit"), true);
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::undo(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::redo(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||
@@ -39,41 +226,91 @@ pub fn build_menu_bar() -> Menu {
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::copy(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::Find, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF))));
|
||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::Replace, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH))));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = edit_menu.append(&item(&mut reg, MenuAction::EditPreferences, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma))));
|
||||
|
||||
// View menu
|
||||
let view_menu = Submenu::new("View", true);
|
||||
let _ = view_menu.append(&MenuItem::new("Toggle Sidebar", true, None));
|
||||
// -- View --
|
||||
let view_menu = Submenu::new(translate(locale, "menu.group.view"), true);
|
||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewPosts, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1))));
|
||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ViewMedia, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2))));
|
||||
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = view_menu.append(&item(&mut reg, MenuAction::ToggleSidebar, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB))));
|
||||
let _ = view_menu.append(&item(&mut reg, MenuAction::TogglePanel, locale,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ))));
|
||||
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
||||
|
||||
// Window menu
|
||||
let window_menu = Submenu::new("Window", true);
|
||||
let _ = window_menu.append(&PredefinedMenuItem::minimize(None));
|
||||
let _ = window_menu.append(&PredefinedMenuItem::maximize(None));
|
||||
// -- Blog --
|
||||
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
|
||||
let _ = blog_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,
|
||||
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,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
|
||||
let _ = blog_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,
|
||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
|
||||
|
||||
// Help menu
|
||||
let help_menu = Submenu::new("Help", true);
|
||||
let _ = help_menu.append(&MenuItem::new("bDS Help", true, None));
|
||||
// -- Help --
|
||||
let help_menu = Submenu::new(translate(locale, "menu.group.help"), true);
|
||||
let _ = help_menu.append(&item(&mut reg, MenuAction::About, locale, None));
|
||||
let _ = help_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = help_menu.append(&item(&mut reg, MenuAction::OpenDocumentation, locale, None));
|
||||
let _ = help_menu.append(&item(&mut reg, MenuAction::ViewOnGitHub, locale, None));
|
||||
let _ = help_menu.append(&item(&mut reg, MenuAction::ReportIssue, locale, None));
|
||||
|
||||
// Assemble the menu bar
|
||||
let _ = menu.append(&app_menu);
|
||||
let _ = menu.append(&file_menu);
|
||||
let _ = menu.append(&edit_menu);
|
||||
let _ = menu.append(&view_menu);
|
||||
let _ = menu.append(&window_menu);
|
||||
let _ = menu.append(&blog_menu);
|
||||
let _ = menu.append(&help_menu);
|
||||
|
||||
// Initialize the menu on macOS
|
||||
// Attach on macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = menu.init_for_nsapp();
|
||||
}
|
||||
|
||||
menu
|
||||
(menu, reg)
|
||||
}
|
||||
|
||||
/// Iced subscription that polls muda menu events.
|
||||
/// Re-translate every registered menu item for a new locale.
|
||||
pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) {
|
||||
for &action in MenuAction::ALL {
|
||||
registry.set_text(action, &translate(locale, action.i18n_key()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Iced subscription that polls muda `MenuEvent`s each frame.
|
||||
///
|
||||
/// Produces `Message::MenuEvent(MenuId)` so the app can look up the
|
||||
/// `MenuAction` via its `MenuRegistry`.
|
||||
pub fn menu_subscription() -> Subscription<Message> {
|
||||
iced::event::listen_with(|_event, _status, _id| {
|
||||
// Check for pending menu events
|
||||
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||
Some(Message::MenuEvent(event.id))
|
||||
} else {
|
||||
@@ -81,3 +318,68 @@ pub fn menu_subscription() -> Subscription<Message> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_listed() {
|
||||
// MenuAction::ALL must contain every variant.
|
||||
// If someone adds a variant but forgets ALL, the i18n_key match
|
||||
// will produce a compile error, but this also catches ALL length.
|
||||
assert_eq!(MenuAction::ALL.len(), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i18n_keys_resolve_for_english() {
|
||||
for &action in MenuAction::ALL {
|
||||
let key = action.i18n_key();
|
||||
let label = translate(UiLocale::En, key);
|
||||
assert_ne!(label, key, "missing English translation for {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_register_and_lookup_roundtrip() {
|
||||
let mut reg = MenuRegistry::new();
|
||||
let mi = MenuItem::new("Test", true, None);
|
||||
reg.register(MenuAction::Save, &mi);
|
||||
|
||||
assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save));
|
||||
assert_eq!(reg.action_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lookup_missing_returns_none() {
|
||||
let reg = MenuRegistry::new();
|
||||
let bogus = MenuId::new("nonexistent");
|
||||
assert_eq!(reg.lookup(&bogus), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_set_enabled_and_text() {
|
||||
let mut reg = MenuRegistry::new();
|
||||
let mi = MenuItem::new("Original", true, None);
|
||||
reg.register(MenuAction::NewPost, &mi);
|
||||
|
||||
reg.set_enabled(MenuAction::NewPost, false);
|
||||
assert!(!mi.is_enabled());
|
||||
|
||||
reg.set_text(MenuAction::NewPost, "Changed");
|
||||
assert_eq!(mi.text(), "Changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_actions_have_unique_i18n_keys() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for &action in MenuAction::ALL {
|
||||
let key = action.i18n_key();
|
||||
assert!(seen.insert(key), "duplicate i18n key: {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod menu;
|
||||
pub mod dialog;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
5
crates/bds-ui/src/state/mod.rs
Normal file
5
crates/bds-ui/src/state/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod navigation;
|
||||
pub mod tabs;
|
||||
|
||||
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
|
||||
pub use tabs::{Tab, TabType};
|
||||
126
crates/bds-ui/src/state/navigation.rs
Normal file
126
crates/bds-ui/src/state/navigation.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Which list the sidebar is currently showing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SidebarView {
|
||||
Posts,
|
||||
Pages,
|
||||
Media,
|
||||
Scripts,
|
||||
Templates,
|
||||
Tags,
|
||||
Chat,
|
||||
Import,
|
||||
Git,
|
||||
Settings,
|
||||
}
|
||||
|
||||
impl fmt::Display for SidebarView {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let key = self.i18n_key();
|
||||
f.write_str(key)
|
||||
}
|
||||
}
|
||||
|
||||
impl SidebarView {
|
||||
/// Returns the `activity.*` i18n key for this sidebar view.
|
||||
pub fn i18n_key(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Posts => "activity.posts",
|
||||
Self::Pages => "activity.pages",
|
||||
Self::Media => "activity.media",
|
||||
Self::Scripts => "activity.scripts",
|
||||
Self::Templates => "activity.templates",
|
||||
Self::Tags => "activity.tags",
|
||||
Self::Chat => "activity.chat",
|
||||
Self::Import => "activity.import",
|
||||
Self::Git => "activity.git",
|
||||
Self::Settings => "activity.settings",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tab is selected in the bottom panel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PanelTab {
|
||||
Tasks,
|
||||
Output,
|
||||
}
|
||||
|
||||
/// A snapshot of a running or completed task shown in the panel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskSnapshot {
|
||||
pub id: u64,
|
||||
pub label: String,
|
||||
pub status: String,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// A single line of output shown in the panel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputEntry {
|
||||
pub timestamp: i64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Determine the next `(SidebarView, sidebar_visible)` after a user clicks
|
||||
/// an activity-bar icon.
|
||||
///
|
||||
/// * Same icon + visible -> toggle sidebar off
|
||||
/// * Same icon + hidden -> toggle sidebar on
|
||||
/// * Different icon -> switch view and ensure visible
|
||||
pub fn handle_activity_click(
|
||||
current_view: SidebarView,
|
||||
sidebar_visible: bool,
|
||||
clicked: SidebarView,
|
||||
) -> (SidebarView, bool) {
|
||||
if clicked == current_view {
|
||||
(current_view, !sidebar_visible)
|
||||
} else {
|
||||
(clicked, true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toggle_off_when_same_and_visible() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
|
||||
assert_eq!(view, SidebarView::Posts);
|
||||
assert!(!visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_on_when_same_and_hidden() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
|
||||
assert_eq!(view, SidebarView::Posts);
|
||||
assert!(visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_view_when_different() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
|
||||
assert_eq!(view, SidebarView::Media);
|
||||
assert!(visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_view_when_different_and_hidden() {
|
||||
let (view, visible) =
|
||||
handle_activity_click(SidebarView::Posts, false, SidebarView::Settings);
|
||||
assert_eq!(view, SidebarView::Settings);
|
||||
assert!(visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_returns_i18n_key() {
|
||||
assert_eq!(SidebarView::Posts.to_string(), "activity.posts");
|
||||
assert_eq!(SidebarView::Settings.to_string(), "activity.settings");
|
||||
}
|
||||
}
|
||||
161
crates/bds-ui/src/state/tabs.rs
Normal file
161
crates/bds-ui/src/state/tabs.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
/// The kind of content a tab holds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TabType {
|
||||
Post,
|
||||
Media,
|
||||
Settings,
|
||||
Style,
|
||||
Tags,
|
||||
Chat,
|
||||
Import,
|
||||
MenuEditor,
|
||||
MetadataDiff,
|
||||
Scripts,
|
||||
Templates,
|
||||
Documentation,
|
||||
SiteValidation,
|
||||
TranslationValidation,
|
||||
}
|
||||
|
||||
impl TabType {
|
||||
/// Singleton tabs may only appear once in the tab bar.
|
||||
/// Every type except `Post` and `Media` is a singleton.
|
||||
pub fn is_singleton(&self) -> bool {
|
||||
!matches!(self, Self::Post | Self::Media)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single tab in the editor area.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tab {
|
||||
pub id: String,
|
||||
pub tab_type: TabType,
|
||||
pub title: String,
|
||||
pub is_transient: bool,
|
||||
}
|
||||
|
||||
/// Open (or focus) a tab in the tab list.
|
||||
///
|
||||
/// * **Singleton** — if a tab with the same `TabType` already exists, return
|
||||
/// its index instead of inserting a duplicate.
|
||||
/// * **Transient** — replace an existing transient tab of the same type, or
|
||||
/// append if none exists.
|
||||
/// * Otherwise — append unconditionally.
|
||||
///
|
||||
/// Returns the index of the resulting tab.
|
||||
pub fn open_tab(tabs: &mut Vec<Tab>, new_tab: Tab) -> usize {
|
||||
if new_tab.tab_type.is_singleton() {
|
||||
if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
if new_tab.is_transient {
|
||||
if let Some(idx) = tabs
|
||||
.iter()
|
||||
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
|
||||
{
|
||||
tabs[idx] = new_tab;
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
tabs.push(new_tab);
|
||||
tabs.len() - 1
|
||||
}
|
||||
|
||||
/// Remove the tab whose `id` matches and return the index that should be
|
||||
/// selected next.
|
||||
///
|
||||
/// Prefers the same index (now occupied by the next tab), clamped to
|
||||
/// `len - 1`. Returns `None` when the list is empty after removal.
|
||||
pub fn close_tab(tabs: &mut Vec<Tab>, id: &str) -> Option<usize> {
|
||||
let pos = tabs.iter().position(|t| t.id == id)?;
|
||||
tabs.remove(pos);
|
||||
if tabs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(pos.min(tabs.len() - 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a transient tab as permanent (non-transient).
|
||||
pub fn pin_tab(tabs: &mut Vec<Tab>, id: &str) {
|
||||
if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) {
|
||||
tab.is_transient = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_tab(id: &str, tab_type: TabType, transient: bool) -> Tab {
|
||||
Tab {
|
||||
id: id.to_string(),
|
||||
tab_type,
|
||||
title: id.to_string(),
|
||||
is_transient: transient,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_dedup() {
|
||||
let mut tabs = Vec::new();
|
||||
let idx1 = open_tab(&mut tabs, make_tab("s1", TabType::Settings, false));
|
||||
let idx2 = open_tab(&mut tabs, make_tab("s2", TabType::Settings, false));
|
||||
assert_eq!(idx1, idx2);
|
||||
assert_eq!(tabs.len(), 1);
|
||||
assert_eq!(tabs[0].id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_replacement() {
|
||||
let mut tabs = Vec::new();
|
||||
open_tab(&mut tabs, make_tab("p1", TabType::Post, true));
|
||||
assert_eq!(tabs.len(), 1);
|
||||
let idx = open_tab(&mut tabs, make_tab("p2", TabType::Post, true));
|
||||
assert_eq!(tabs.len(), 1);
|
||||
assert_eq!(tabs[idx].id, "p2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_makes_permanent() {
|
||||
let mut tabs = Vec::new();
|
||||
open_tab(&mut tabs, make_tab("p1", TabType::Post, true));
|
||||
assert!(tabs[0].is_transient);
|
||||
pin_tab(&mut tabs, "p1");
|
||||
assert!(!tabs[0].is_transient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_selects_neighbor() {
|
||||
let mut tabs = Vec::new();
|
||||
open_tab(&mut tabs, make_tab("a", TabType::Post, false));
|
||||
open_tab(&mut tabs, make_tab("b", TabType::Media, false));
|
||||
open_tab(&mut tabs, make_tab("c", TabType::Post, false));
|
||||
|
||||
// Close the middle tab.
|
||||
let next = close_tab(&mut tabs, "b");
|
||||
assert_eq!(next, Some(1)); // index 1 now holds "c"
|
||||
assert_eq!(tabs[1].id, "c");
|
||||
|
||||
// Close the last tab.
|
||||
let next = close_tab(&mut tabs, "c");
|
||||
assert_eq!(next, Some(0)); // clamped to len-1
|
||||
|
||||
// Close the only remaining tab.
|
||||
let next = close_tab(&mut tabs, "a");
|
||||
assert_eq!(next, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_appends_non_singleton_non_transient() {
|
||||
let mut tabs = Vec::new();
|
||||
let i0 = open_tab(&mut tabs, make_tab("p1", TabType::Post, false));
|
||||
let i1 = open_tab(&mut tabs, make_tab("p2", TabType::Post, false));
|
||||
assert_eq!(i0, 0);
|
||||
assert_eq!(i1, 1);
|
||||
assert_eq!(tabs.len(), 2);
|
||||
}
|
||||
}
|
||||
89
crates/bds-ui/src/views/activity_bar.rs
Normal file
89
crates/bds-ui/src/views/activity_bar.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use iced::widget::{button, column, container, text, Column, Space};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::navigation::SidebarView;
|
||||
|
||||
/// Activity-bar short labels used in M2 (no SVG icons yet).
|
||||
fn short_label(view: SidebarView) -> &'static str {
|
||||
match view {
|
||||
SidebarView::Posts => "Po",
|
||||
SidebarView::Pages => "Pa",
|
||||
SidebarView::Media => "Me",
|
||||
SidebarView::Scripts => "Sc",
|
||||
SidebarView::Templates => "Tp",
|
||||
SidebarView::Tags => "Ta",
|
||||
SidebarView::Chat => "AI",
|
||||
SidebarView::Import => "Im",
|
||||
SidebarView::Git => "Gi",
|
||||
SidebarView::Settings => "Se",
|
||||
}
|
||||
}
|
||||
|
||||
/// Top group of activity items.
|
||||
const TOP_ACTIVITIES: &[SidebarView] = &[
|
||||
SidebarView::Posts,
|
||||
SidebarView::Pages,
|
||||
SidebarView::Media,
|
||||
SidebarView::Scripts,
|
||||
SidebarView::Templates,
|
||||
SidebarView::Tags,
|
||||
SidebarView::Chat,
|
||||
SidebarView::Import,
|
||||
];
|
||||
|
||||
/// Bottom group of activity items.
|
||||
const BOTTOM_ACTIVITIES: &[SidebarView] = &[
|
||||
SidebarView::Git,
|
||||
SidebarView::Settings,
|
||||
];
|
||||
|
||||
pub fn view(
|
||||
active_view: SidebarView,
|
||||
_locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
let make_btn = |view: SidebarView| -> Element<'static, Message> {
|
||||
let label = short_label(view);
|
||||
let mut btn = button(
|
||||
text(label).size(12).center(),
|
||||
)
|
||||
.width(Length::Fixed(48.0))
|
||||
.height(Length::Fixed(48.0))
|
||||
.on_press(Message::SetActiveView(view));
|
||||
|
||||
if view == active_view {
|
||||
btn = btn.style(button::primary);
|
||||
} else {
|
||||
btn = btn.style(button::secondary);
|
||||
}
|
||||
|
||||
container(btn)
|
||||
.center_x(Length::Fixed(48.0))
|
||||
.into()
|
||||
};
|
||||
|
||||
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES
|
||||
.iter()
|
||||
.map(|v| make_btn(*v))
|
||||
.collect();
|
||||
|
||||
let bottom_items: Vec<Element<'static, Message>> = BOTTOM_ACTIVITIES
|
||||
.iter()
|
||||
.map(|v| make_btn(*v))
|
||||
.collect();
|
||||
|
||||
let top = Column::with_children(top_items).spacing(2);
|
||||
let bottom = Column::with_children(bottom_items).spacing(2);
|
||||
|
||||
column![
|
||||
top,
|
||||
Space::with_height(Length::Fill),
|
||||
bottom,
|
||||
]
|
||||
.width(Length::Fixed(48.0))
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
7
crates/bds-ui/src/views/mod.rs
Normal file
7
crates/bds-ui/src/views/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod workspace;
|
||||
pub mod activity_bar;
|
||||
pub mod sidebar;
|
||||
pub mod tab_bar;
|
||||
pub mod status_bar;
|
||||
pub mod panel;
|
||||
pub mod welcome;
|
||||
111
crates/bds-ui/src/views/panel.rs
Normal file
111
crates/bds-ui/src/views/panel.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
|
||||
|
||||
pub fn view(
|
||||
panel_tab: PanelTab,
|
||||
task_snapshots: &[TaskSnapshot],
|
||||
output_entries: &[OutputEntry],
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
// Tab header
|
||||
let tasks_btn = {
|
||||
let mut btn = button(text(t(locale, "common.tasks")).size(12))
|
||||
.on_press(Message::SetPanelTab(PanelTab::Tasks))
|
||||
.padding([4, 8]);
|
||||
if panel_tab == PanelTab::Tasks {
|
||||
btn = btn.style(button::primary);
|
||||
} else {
|
||||
btn = btn.style(button::secondary);
|
||||
}
|
||||
btn
|
||||
};
|
||||
|
||||
let output_btn = {
|
||||
let mut btn = button(text(t(locale, "panel.output")).size(12))
|
||||
.on_press(Message::SetPanelTab(PanelTab::Output))
|
||||
.padding([4, 8]);
|
||||
if panel_tab == PanelTab::Output {
|
||||
btn = btn.style(button::primary);
|
||||
} else {
|
||||
btn = btn.style(button::secondary);
|
||||
}
|
||||
btn
|
||||
};
|
||||
|
||||
let close_btn = button(text("×").size(12))
|
||||
.on_press(Message::TogglePanel)
|
||||
.padding([4, 6])
|
||||
.style(button::text);
|
||||
|
||||
let tab_header = row![
|
||||
tasks_btn,
|
||||
output_btn,
|
||||
Space::with_width(Length::Fill),
|
||||
close_btn,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// Tab content
|
||||
let content: Element<'static, Message> = match panel_tab {
|
||||
PanelTab::Tasks => {
|
||||
if task_snapshots.is_empty() {
|
||||
container(text(t(locale, "tasks.noActive")).size(12))
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = task_snapshots
|
||||
.iter()
|
||||
.map(|snap| {
|
||||
let status_text = format!(
|
||||
"{} — {}{}",
|
||||
snap.label,
|
||||
snap.status,
|
||||
snap.progress
|
||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
text(status_text).size(11).into()
|
||||
})
|
||||
.collect();
|
||||
scrollable(
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(4)
|
||||
.padding(8),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
PanelTab::Output => {
|
||||
if output_entries.is_empty() {
|
||||
container(text(t(locale, "panel.noOutput")).size(12))
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = output_entries
|
||||
.iter()
|
||||
.map(|entry| text(entry.text.clone()).size(11).into())
|
||||
.collect();
|
||||
scrollable(
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(2)
|
||||
.padding(8),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container(
|
||||
column![tab_header, content].spacing(4),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(200.0))
|
||||
.into()
|
||||
}
|
||||
37
crates/bds-ui/src/views/sidebar.rs
Normal file
37
crates/bds-ui/src/views/sidebar.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use iced::widget::{column, container, scrollable, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
use crate::state::navigation::SidebarView;
|
||||
|
||||
pub fn view(
|
||||
sidebar_view: SidebarView,
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
let header_key = sidebar_view.i18n_key();
|
||||
let header_text = t(locale, header_key);
|
||||
|
||||
let placeholder = match sidebar_view {
|
||||
SidebarView::Posts => t(locale, "sidebar.noPostsYet"),
|
||||
SidebarView::Pages => t(locale, "sidebar.noPagesYet"),
|
||||
SidebarView::Media => t(locale, "sidebar.noMediaYet"),
|
||||
SidebarView::Settings => t(locale, "sidebar.settingsHeader"),
|
||||
SidebarView::Tags => t(locale, "sidebar.tagsHeader"),
|
||||
_ => t(locale, "sidebar.loading"),
|
||||
};
|
||||
|
||||
let content = column![
|
||||
text(header_text).size(14),
|
||||
text(placeholder).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding(12);
|
||||
|
||||
container(scrollable(content))
|
||||
.width(Length::Fixed(280.0))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
97
crates/bds-ui/src/views/status_bar.rs
Normal file
97
crates/bds-ui/src/views/status_bar.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use iced::widget::{button, container, row, text, Space};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::state::navigation::TaskSnapshot;
|
||||
|
||||
pub fn view(
|
||||
active_project_name: Option<&str>,
|
||||
post_count: usize,
|
||||
media_count: usize,
|
||||
locale: UiLocale,
|
||||
offline_mode: bool,
|
||||
task_snapshots: &[TaskSnapshot],
|
||||
) -> Element<'static, Message> {
|
||||
// Left: project name + task indicator
|
||||
let project_label = active_project_name.unwrap_or("—").to_string();
|
||||
let project_text = text(project_label).size(12);
|
||||
|
||||
let running: Vec<&TaskSnapshot> = task_snapshots
|
||||
.iter()
|
||||
.filter(|t| t.status == "running")
|
||||
.collect();
|
||||
let task_indicator: Element<'static, Message> = if !running.is_empty() {
|
||||
let first = running[0].label.clone();
|
||||
if running.len() > 1 {
|
||||
text(format!("{first} (+{})", running.len() - 1)).size(11).into()
|
||||
} else {
|
||||
text(first).size(11).into()
|
||||
}
|
||||
} else {
|
||||
Space::with_width(0).into()
|
||||
};
|
||||
|
||||
let left = row![project_text, task_indicator].spacing(12);
|
||||
|
||||
// Right: post count, media count, airplane mode, locale selector, brand
|
||||
let posts_label = tw(locale, "statusBar.posts", &[("count", &post_count.to_string())]);
|
||||
let media_label = tw(locale, "statusBar.media", &[("count", &media_count.to_string())]);
|
||||
|
||||
let airplane_label = if offline_mode {
|
||||
t(locale, "statusBar.offlineModeActive")
|
||||
} else {
|
||||
t(locale, "statusBar.offlineMode")
|
||||
};
|
||||
|
||||
let airplane_btn = button(text(airplane_label).size(11))
|
||||
.on_press(Message::SetOfflineMode(!offline_mode))
|
||||
.padding([2, 6])
|
||||
.style(if offline_mode { button::primary } else { button::text });
|
||||
|
||||
let ui_label = t(locale, "statusBar.ui");
|
||||
|
||||
let locale_buttons: Vec<Element<'static, Message>> = UiLocale::all()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let code = l.code().to_string();
|
||||
let mut btn = button(text(code).size(11))
|
||||
.on_press(Message::SetUiLocale(*l))
|
||||
.padding([2, 4]);
|
||||
if *l == locale {
|
||||
btn = btn.style(button::primary);
|
||||
} else {
|
||||
btn = btn.style(button::text);
|
||||
}
|
||||
btn.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let locale_row = iced::widget::Row::with_children(locale_buttons).spacing(2);
|
||||
|
||||
let right = row![
|
||||
text(posts_label).size(11),
|
||||
text(media_label).size(11),
|
||||
airplane_btn,
|
||||
text(ui_label).size(11),
|
||||
locale_row,
|
||||
text("bDS").size(11),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(
|
||||
row![
|
||||
left,
|
||||
Space::with_width(Length::Fill),
|
||||
right,
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.padding([0, 8]),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(22.0))
|
||||
.into()
|
||||
}
|
||||
58
crates/bds-ui/src/views/tab_bar.rs
Normal file
58
crates/bds-ui/src/views/tab_bar.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use iced::widget::{button, container, row, text, Space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::tabs::Tab;
|
||||
|
||||
pub fn view(
|
||||
tabs: &[Tab],
|
||||
active_tab: Option<&str>,
|
||||
) -> Element<'static, Message> {
|
||||
if tabs.is_empty() {
|
||||
return Space::with_height(0).into();
|
||||
}
|
||||
|
||||
let tab_buttons: Vec<Element<'static, Message>> = tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
let is_active = active_tab == Some(tab.id.as_str());
|
||||
let title_text = if tab.is_transient {
|
||||
format!("{} (preview)", tab.title)
|
||||
} else {
|
||||
tab.title.clone()
|
||||
};
|
||||
let tab_id = tab.id.clone();
|
||||
let close_id = tab.id.clone();
|
||||
|
||||
let label = row![
|
||||
text(title_text).size(12),
|
||||
button(text("×").size(12))
|
||||
.on_press(Message::CloseTab(close_id))
|
||||
.padding(2)
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
let mut btn = button(label)
|
||||
.on_press(Message::SelectTab(tab_id))
|
||||
.padding([4, 8]);
|
||||
|
||||
if is_active {
|
||||
btn = btn.style(button::primary);
|
||||
} else {
|
||||
btn = btn.style(button::secondary);
|
||||
}
|
||||
|
||||
btn.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
container(
|
||||
iced::widget::Row::with_children(tab_buttons)
|
||||
.spacing(1)
|
||||
.height(Length::Fixed(35.0)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(35.0))
|
||||
.into()
|
||||
}
|
||||
26
crates/bds-ui/src/views/welcome.rs
Normal file
26
crates/bds-ui/src/views/welcome.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
|
||||
pub fn view(locale: UiLocale) -> Element<'static, Message> {
|
||||
let title = t(locale, "welcome.title");
|
||||
let subtitle = t(locale, "welcome.subtitle");
|
||||
|
||||
container(
|
||||
column![
|
||||
text(title).size(28),
|
||||
text(subtitle).size(14),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_x(iced::Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
84
crates/bds-ui/src/views/workspace.rs
Normal file
84
crates/bds-ui/src/views/workspace.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
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};
|
||||
|
||||
/// Compose the full workspace layout.
|
||||
pub fn view(
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
sidebar_visible: bool,
|
||||
// Tabs
|
||||
tabs: &[Tab],
|
||||
active_tab: Option<&str>,
|
||||
// Panel
|
||||
panel_visible: bool,
|
||||
panel_tab: PanelTab,
|
||||
task_snapshots: &[TaskSnapshot],
|
||||
output_entries: &[OutputEntry],
|
||||
// Status bar
|
||||
active_project_name: Option<&str>,
|
||||
post_count: usize,
|
||||
media_count: usize,
|
||||
offline_mode: bool,
|
||||
// i18n
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
// Activity bar (leftmost column)
|
||||
let activity = activity_bar::view(sidebar_view, locale);
|
||||
|
||||
// Sidebar (conditionally visible)
|
||||
let sidebar_el: Option<Element<'static, Message>> = if sidebar_visible {
|
||||
Some(sidebar::view(sidebar_view, locale))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Tab bar
|
||||
let tabs_el = tab_bar::view(tabs, active_tab);
|
||||
|
||||
// Content area
|
||||
let content_area = welcome::view(locale);
|
||||
|
||||
// Panel (conditionally visible)
|
||||
let panel_el: Option<Element<'static, Message>> = if panel_visible {
|
||||
Some(panel::view(panel_tab, task_snapshots, output_entries, locale))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Right column: tab bar + content + panel
|
||||
let mut right_col = column![tabs_el, content_area];
|
||||
if let Some(p) = panel_el {
|
||||
right_col = right_col.push(p);
|
||||
}
|
||||
let right = right_col.width(Length::Fill).height(Length::Fill);
|
||||
|
||||
// Main row: activity bar + sidebar? + right column
|
||||
let mut main_row = row![activity];
|
||||
if let Some(sb) = sidebar_el {
|
||||
main_row = main_row.push(sb);
|
||||
}
|
||||
main_row = main_row.push(right);
|
||||
let main_row = main_row.height(Length::Fill);
|
||||
|
||||
// Status bar at bottom
|
||||
let status = status_bar::view(
|
||||
active_project_name,
|
||||
post_count,
|
||||
media_count,
|
||||
locale,
|
||||
offline_mode,
|
||||
task_snapshots,
|
||||
);
|
||||
|
||||
column![main_row, status]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
Reference in New Issue
Block a user