feat: first take at M2

This commit is contained in:
2026-04-04 18:04:39 +02:00
parent b532104032
commit eedd0a9118
33 changed files with 2899 additions and 78 deletions

View 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()
}

View 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;

View 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()
}

View 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()
}

View 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()
}

View 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()
}

View 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()
}

View 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()
}