fix: even more closing of M0/M1/M2 gaps against the spec
This commit is contained in:
@@ -7,3 +7,4 @@ pub mod panel;
|
||||
pub mod project_selector;
|
||||
pub mod toast;
|
||||
pub mod welcome;
|
||||
pub mod modal;
|
||||
|
||||
252
crates/bds-ui/src/views/modal.rs
Normal file
252
crates/bds-ui/src/views/modal.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use iced::widget::{button, column, container, row, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
|
||||
/// Active modal state. Only one modal at a time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModalState {
|
||||
/// Per modals.allium ConfirmDeleteModal: warning, entity name, references.
|
||||
ConfirmDelete {
|
||||
entity_name: String,
|
||||
references: Vec<String>,
|
||||
on_confirm: ConfirmAction,
|
||||
},
|
||||
/// Per modals.allium ConfirmDialog: generic confirmation.
|
||||
Confirm {
|
||||
title: String,
|
||||
message: String,
|
||||
on_confirm: ConfirmAction,
|
||||
},
|
||||
}
|
||||
|
||||
/// What action to perform when modal is confirmed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfirmAction {
|
||||
DeleteProject(String),
|
||||
DeletePost(String),
|
||||
DeleteMedia(String),
|
||||
DeleteScript(String),
|
||||
DeleteTemplate(String),
|
||||
MergeTags { source: String, target: String },
|
||||
}
|
||||
|
||||
// ── Modal backdrop style ──
|
||||
|
||||
fn backdrop_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn modal_box_style(_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: 8.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.28, 0.28, 0.33),
|
||||
_ => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color::from_rgb(0.80, 0.80, 0.85),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.35, 0.35, 0.40),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn danger_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.85, 0.20, 0.20),
|
||||
_ => Color::from_rgb(0.75, 0.15, 0.15),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.20, 0.55, 0.85),
|
||||
_ => Color::from_rgb(0.15, 0.45, 0.75),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the modal overlay.
|
||||
pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
let modal_content: Element<'static, Message> = match state {
|
||||
ModalState::ConfirmDelete {
|
||||
entity_name,
|
||||
references,
|
||||
on_confirm,
|
||||
} => {
|
||||
let title = text(t(locale, "modal.confirmDelete.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let warning_icon = text("\u{26A0}")
|
||||
.size(20)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(1.0, 0.80, 0.0));
|
||||
|
||||
let entity_label = text(entity_name.clone())
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.90, 0.90, 0.95));
|
||||
|
||||
let warning_text = text(t(locale, "modal.confirmDelete.warning"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75));
|
||||
|
||||
let mut content_col = column![
|
||||
row![warning_icon, Space::with_width(8.0), title]
|
||||
.align_y(Alignment::Center),
|
||||
Space::with_height(12.0),
|
||||
entity_label,
|
||||
warning_text,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
// Reference section
|
||||
if !references.is_empty() {
|
||||
content_col = content_col.push(Space::with_height(8.0));
|
||||
for r in references {
|
||||
content_col = content_col.push(
|
||||
text(format!("\u{2022} {r}"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.65)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let on_confirm_clone = on_confirm.clone();
|
||||
let buttons = row![
|
||||
button(
|
||||
text(t(locale, "modal.confirmDelete.cancel"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(
|
||||
text(t(locale, "modal.confirmDelete.delete"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(danger_button_style),
|
||||
];
|
||||
|
||||
content_col = content_col.push(Space::with_height(16.0));
|
||||
content_col = content_col.push(buttons);
|
||||
|
||||
container(content_col.padding(20))
|
||||
.width(Length::Fixed(380.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::Confirm {
|
||||
title: dialog_title,
|
||||
message: dialog_message,
|
||||
on_confirm,
|
||||
} => {
|
||||
let title = text(dialog_title.clone())
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let msg = text(dialog_message.clone())
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.80, 0.80, 0.85));
|
||||
|
||||
let on_confirm_clone = on_confirm.clone();
|
||||
let buttons = row![
|
||||
button(
|
||||
text(t(locale, "modal.confirm.cancel"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(
|
||||
text(t(locale, "modal.confirm.confirm"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
];
|
||||
|
||||
let content_col = column![
|
||||
title,
|
||||
Space::with_height(12.0),
|
||||
msg,
|
||||
Space::with_height(16.0),
|
||||
buttons,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(content_col.padding(20))
|
||||
.width(Length::Fixed(380.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
// Center the modal in a full-screen backdrop
|
||||
container(
|
||||
container(modal_content)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(backdrop_style)
|
||||
.into()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use iced::widget::text::Shaping;
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::{Media, Post};
|
||||
use bds_core::model::{Media, Post, Script, Template};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
@@ -72,9 +72,6 @@ const AVG_CHAR_WIDTH_PX: f32 = 6.8;
|
||||
/// Sidebar padding on each side (12px) plus item padding (6px each side).
|
||||
const SIDEBAR_TEXT_OVERHEAD_PX: f32 = 36.0;
|
||||
|
||||
/// Extra space used by the post status indicator prefix ("○ " etc.).
|
||||
const STATUS_INDICATOR_CHARS: usize = 2;
|
||||
|
||||
/// Truncate a string to fit approximately within `available_px` pixels,
|
||||
/// appending "…" if truncation occurs.
|
||||
fn truncate_to_fit(s: &str, available_px: f32) -> String {
|
||||
@@ -98,10 +95,35 @@ fn truncate_media_title(title: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per sidebar_views.allium PostTypeIcon: map first category to emoji.
|
||||
fn post_type_icon(categories: &[String]) -> &'static str {
|
||||
for cat in categories {
|
||||
match cat.to_lowercase().as_str() {
|
||||
"picture" => return "\u{1F4F7}", // 📷 camera
|
||||
"article" => return "\u{1F5D2}", // 🗒 notepad
|
||||
"aside" | "blogmark" => return "\u{1F517}", // 🔗 link
|
||||
"video" => return "\u{1F3AC}", // 🎬 film
|
||||
"podcast" => return "\u{1F4AC}", // 💬 speech bubble
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"\u{1F4C4}" // 📄 document (default)
|
||||
}
|
||||
|
||||
/// Per sidebar_views.allium PostDateFormat: "Feb 10, 2026".
|
||||
fn format_post_date(unix_ms: i64) -> String {
|
||||
let secs = unix_ms / 1000;
|
||||
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
|
||||
dt.format("%b %d, %Y").to_string()
|
||||
}
|
||||
|
||||
pub fn view(
|
||||
sidebar_view: SidebarView,
|
||||
posts: &[Post],
|
||||
media: &[Media],
|
||||
scripts: &[Script],
|
||||
templates: &[Template],
|
||||
width: f32,
|
||||
active_tab: Option<&str>,
|
||||
locale: UiLocale,
|
||||
@@ -133,24 +155,33 @@ pub fn view(
|
||||
|
||||
let make_post_item = |p: &Post| -> Element<'static, Message> {
|
||||
let is_active = active_tab == Some(p.id.as_str());
|
||||
// Per sidebar_views.allium PostTypeIcon
|
||||
let icon = post_type_icon(&p.categories);
|
||||
let status_indicator = match p.status {
|
||||
bds_core::model::PostStatus::Draft => "\u{25CB} ",
|
||||
bds_core::model::PostStatus::Published => "\u{25CF} ",
|
||||
bds_core::model::PostStatus::Archived => "\u{25A1} ",
|
||||
bds_core::model::PostStatus::Draft => "\u{25CB}",
|
||||
bds_core::model::PostStatus::Published => "\u{25CF}",
|
||||
bds_core::model::PostStatus::Archived => "\u{25A1}",
|
||||
};
|
||||
// Per sidebar_views.allium PostDateFormat
|
||||
let date = format_post_date(p.created_at);
|
||||
// Truncate title to fit sidebar width, accounting for
|
||||
// padding and the status indicator prefix.
|
||||
// padding and the status/icon prefix.
|
||||
let prefix_chars: usize = 5; // icon + space + status + space
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
|
||||
- (STATUS_INDICATOR_CHARS as f32 * AVG_CHAR_WIDTH_PX);
|
||||
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
|
||||
let display_title = truncate_to_fit(&p.title, text_px);
|
||||
let label = format!("{status_indicator}{display_title}");
|
||||
let label = format!("{icon} {status_indicator} {display_title}");
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None);
|
||||
let date_text = text(date)
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.50, 0.50, 0.55));
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
button(
|
||||
container(label_text)
|
||||
container(column![label_text, date_text].spacing(1))
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
@@ -254,6 +285,166 @@ pub fn view(
|
||||
.into()
|
||||
}
|
||||
}
|
||||
SidebarView::Scripts => {
|
||||
if scripts.is_empty() {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = scripts
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let is_active = active_tab == Some(s.id.as_str());
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
|
||||
let display_title = truncate_to_fit(&s.title, text_px);
|
||||
let label_text = text(display_title.clone())
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None);
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: s.id.clone(),
|
||||
tab_type: TabType::Scripts,
|
||||
title: display_title,
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
SidebarView::Templates => {
|
||||
if templates.is_empty() {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = templates
|
||||
.iter()
|
||||
.map(|tmpl| {
|
||||
let is_active = active_tab == Some(tmpl.id.as_str());
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
|
||||
let display_title = truncate_to_fit(&tmpl.title, text_px);
|
||||
let label_text = text(display_title.clone())
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None);
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: tmpl.id.clone(),
|
||||
tab_type: TabType::Templates,
|
||||
title: display_title,
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
SidebarView::Settings => {
|
||||
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections
|
||||
let sections = [
|
||||
"settings.nav.project",
|
||||
"settings.nav.editor",
|
||||
"settings.nav.content",
|
||||
"settings.nav.ai",
|
||||
"settings.nav.technology",
|
||||
"settings.nav.publishing",
|
||||
"settings.nav.data",
|
||||
"settings.nav.mcp",
|
||||
"settings.nav.style",
|
||||
];
|
||||
let items: Vec<Element<'static, Message>> = sections
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: "settings".to_string(),
|
||||
tab_type: TabType::Settings,
|
||||
title: t(locale, "common.settings"),
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(item_style)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
SidebarView::Tags => {
|
||||
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
|
||||
let sections = [
|
||||
"tags.nav.cloud",
|
||||
"tags.nav.manage",
|
||||
"tags.nav.merge",
|
||||
];
|
||||
let items: Vec<Element<'static, Message>> = sections
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: "tags".to_string(),
|
||||
tab_type: TabType::Tags,
|
||||
title: t(locale, "tabBar.tags"),
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(item_style)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
|
||||
@@ -3,13 +3,13 @@ use iced::widget::text::Shaping;
|
||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::{Media, Post, Project};
|
||||
use bds_core::model::{Media, Post, Project, Script, Template};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
use crate::state::toast::Toast;
|
||||
use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
|
||||
use crate::views::{activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
|
||||
|
||||
/// Main content area background.
|
||||
fn content_bg(_theme: &Theme) -> container::Style {
|
||||
@@ -55,6 +55,8 @@ pub fn view(
|
||||
// Sidebar data
|
||||
sidebar_posts: &[Post],
|
||||
sidebar_media: &[Media],
|
||||
sidebar_scripts: &[Script],
|
||||
sidebar_templates: &[Template],
|
||||
// Status bar
|
||||
active_project_name: Option<&str>,
|
||||
projects: &[Project],
|
||||
@@ -69,6 +71,8 @@ pub fn view(
|
||||
locale: UiLocale,
|
||||
// Toasts
|
||||
toasts: &[Toast],
|
||||
// Modal
|
||||
active_modal: Option<&modal::ModalState>,
|
||||
) -> Element<'static, Message> {
|
||||
// Activity bar (leftmost column)
|
||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||
@@ -106,7 +110,7 @@ pub fn view(
|
||||
let mut main_row = row![activity];
|
||||
|
||||
if sidebar_visible {
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, sidebar_width, active_tab, locale));
|
||||
|
||||
// Resize drag handle: 4px wide strip between sidebar and content
|
||||
let handle = container(Space::new(0, 0))
|
||||
@@ -234,6 +238,11 @@ pub fn view(
|
||||
overlays.push(overlay);
|
||||
}
|
||||
|
||||
// Modal overlay (highest z-index)
|
||||
if let Some(modal_state) = active_modal {
|
||||
overlays.push(modal::view(modal_state, locale));
|
||||
}
|
||||
|
||||
if overlays.is_empty() {
|
||||
base_layout
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user