chore: source formatting and spec allignment

This commit is contained in:
2026-07-18 14:20:23 +02:00
parent a594b99e90
commit 16a210c0ad
119 changed files with 8868 additions and 5250 deletions

View File

@@ -1,5 +1,5 @@
use iced::widget::{button, column, container, svg, text, tooltip, Column, Space};
use iced::widget::text::Shaping;
use iced::widget::{Column, Space, button, column, container, svg, text, tooltip};
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -40,10 +40,7 @@ const TOP_ACTIVITIES: &[SidebarView] = &[
];
/// Bottom group of activity items.
const BOTTOM_ACTIVITIES: &[SidebarView] = &[
SidebarView::Git,
SidebarView::Settings,
];
const BOTTOM_ACTIVITIES: &[SidebarView] = &[SidebarView::Git, SidebarView::Settings];
// ---------------------------------------------------------------------------
// Styles — matching bDS ActivityBar.css
@@ -108,7 +105,7 @@ pub fn view(
let icon = svg(handle)
.width(Length::Fixed(24.0))
.height(Length::Fixed(24.0))
.opacity(if is_active { 1.0 } else { 0.4 });
.opacity(if is_active { 1.0_f32 } else { 0.4_f32 });
let btn = button(
container(icon)
@@ -119,7 +116,11 @@ pub fn view(
.height(Length::Fixed(48.0))
.padding(0)
.on_press(Message::SetActiveView(view))
.style(if is_active { active_button_style } else { inactive_button_style });
.style(if is_active {
active_button_style
} else {
inactive_button_style
});
// Active indicator: 2px left border (like bDS/VS Code)
let btn_row: Element<'static, Message> = if is_active {
@@ -138,33 +139,29 @@ pub fn view(
// Wrap in tooltip per layout.allium ActivityButton.label_key
let tip_text = t(locale, view.i18n_key());
tooltip(btn_row, text(tip_text).size(12).shaping(Shaping::Advanced), tooltip::Position::Right)
.gap(4)
.into()
tooltip(
btn_row,
text(tip_text).size(12).shaping(Shaping::Advanced),
tooltip::Position::Right,
)
.gap(4)
.into()
};
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES
.iter()
.map(|v| make_btn(*v))
.collect();
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 bottom_items: Vec<Element<'static, Message>> =
BOTTOM_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
let top = Column::with_children(top_items).spacing(0);
let bottom = Column::with_children(bottom_items).spacing(0);
container(
column![
top,
Space::with_height(Length::Fill),
bottom,
]
.width(Length::Fixed(50.0))
.height(Length::Fill)
.padding([4, 0]),
column![top, Space::with_height(Length::Fill), bottom,]
.width(Length::Fixed(50.0))
.height(Length::Fill)
.padding([4, 0]),
)
.width(Length::Fixed(50.0))
.height(Length::Fill)

View File

@@ -1,4 +1,4 @@
use iced::widget::{button, column, container, row, scrollable, text, tooltip, Space};
use iced::widget::{Space, button, column, container, row, scrollable, text, tooltip};
use iced::{Alignment, Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -88,13 +88,8 @@ impl DashboardState {
}
/// Render the dashboard overview.
pub fn view<'a>(
state: &'a DashboardState,
locale: UiLocale,
) -> Element<'a, Message> {
let header = text(state.title.clone())
.size(24)
.color(Color::WHITE);
pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Message> {
let header = text(state.title.clone()).size(24).color(Color::WHITE);
let project_label = text(state.subtitle.clone())
.size(14)
@@ -106,11 +101,23 @@ pub fn view<'a>(
state.stats.total_posts.to_string(),
{
let mut details = vec![
format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")),
format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")),
format!(
"{} {}",
state.stats.published_count,
t(locale, "dashboard.published")
),
format!(
"{} {}",
state.stats.draft_count,
t(locale, "dashboard.drafts")
),
];
if state.stats.archived_count > 0 {
details.push(format!("{} {}", state.stats.archived_count, t(locale, "dashboard.archived")));
details.push(format!(
"{} {}",
state.stats.archived_count,
t(locale, "dashboard.archived")
));
}
details
},
@@ -119,14 +126,22 @@ pub fn view<'a>(
&t(locale, "dashboard.media"),
state.stats.media_count.to_string(),
vec![
format!("{} {}", state.stats.image_count, t(locale, "dashboard.images")),
format!(
"{} {}",
state.stats.image_count,
t(locale, "dashboard.images")
),
state.stats.total_media_size.clone(),
],
),
stat_card(
&t(locale, "dashboard.tags"),
state.stats.tag_count.to_string(),
vec![format!("{} {}", state.stats.category_count, t(locale, "dashboard.categories"))],
vec![format!(
"{} {}",
state.stats.category_count,
t(locale, "dashboard.categories")
)],
),
]
.spacing(16);
@@ -166,7 +181,12 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
container(
column(
std::iter::once(text(value).size(28).color(Color::WHITE).into())
.chain(std::iter::once(text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).into()))
.chain(std::iter::once(
text(label.to_string())
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65))
.into(),
))
.chain(details.into_iter().map(|detail| {
text(detail)
.size(12)
@@ -191,65 +211,84 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
.into()
}
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -> Element<'a, Message> {
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1);
row(
months
.iter()
.map(|month| {
let height = if month.count == 0 {
8.0
} else {
24.0 + (month.count as f32 / max_count as f32) * 96.0
};
container(
column![
text(month.count.to_string()).size(12).color(Color::WHITE),
container(Space::with_height(height))
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
border: iced::Border { radius: 6.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
text(format!("{} {}", month.label, month.year)).size(11).color(Color::from_rgb(0.7, 0.72, 0.78)),
]
.spacing(6)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.into()
})
.collect::<Vec<_>>(),
)
fn timeline_chart<'a>(
months: &'a [DashboardTimelineMonth],
_locale: UiLocale,
) -> Element<'a, Message> {
let max_count = months
.iter()
.map(|month| month.count)
.max()
.unwrap_or(1)
.max(1);
row(months
.iter()
.map(|month| {
let height = if month.count == 0 {
8.0
} else {
24.0 + (month.count as f32 / max_count as f32) * 96.0
};
container(
column![
text(month.count.to_string()).size(12).color(Color::WHITE),
container(Space::with_height(height))
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
border: iced::Border {
radius: 6.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
text(format!("{} {}", month.label, month.year))
.size(11)
.color(Color::from_rgb(0.7, 0.72, 0.78)),
]
.spacing(6)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.into()
})
.collect::<Vec<_>>())
.spacing(10)
.align_y(Alignment::End)
.into()
}
fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> {
fn tag_cloud<'a>(
tags: &'a [DashboardTag],
overflow_count: usize,
locale: UiLocale,
) -> Element<'a, Message> {
let cloud = if tags.is_empty() {
row![text(t(locale, "dashboard.noTags")).size(13).color(Color::from_rgb(0.6, 0.62, 0.68))]
.spacing(8)
.wrap()
row![
text(t(locale, "dashboard.noTags"))
.size(13)
.color(Color::from_rgb(0.6, 0.62, 0.68))
]
.spacing(8)
.wrap()
} else {
let words = tags
.iter()
.map(|tag| {
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
let bg =
parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
let fg = contrast_color(bg);
tooltip(
container(
text(tag.name.clone())
.size(scale_font(tag.count))
.color(fg),
)
.padding([6, 10])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg))
.padding([6, 10])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
text(format!("{} {}", tag.name, tag.count)).size(12),
tooltip::Position::Top,
)
@@ -269,30 +308,49 @@ fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLoca
]
.spacing(8)
} else {
column![inputs::section_header(&t(locale, "dashboard.tagCloud")), cloud].spacing(8)
column![
inputs::section_header(&t(locale, "dashboard.tagCloud")),
cloud
]
.spacing(8)
};
container(content).width(Length::FillPortion(1)).into()
}
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> {
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| {
column.push(
container(
row![
text(category.name.clone()).size(13).color(Color::WHITE),
text(category.count.to_string()).size(12).color(Color::from_rgb(0.72, 0.74, 0.80)),
]
.spacing(8),
fn category_cloud<'a>(
categories: &'a [DashboardCategory],
locale: UiLocale,
) -> Element<'a, Message> {
let items = categories.iter().fold(
column![inputs::section_header(&t(
locale,
"dashboard.categoryCloud"
))]
.spacing(8),
|column, category| {
column.push(
container(
row![
text(category.name.clone()).size(13).color(Color::WHITE),
text(category.count.to_string())
.size(12)
.color(Color::from_rgb(0.72, 0.74, 0.80)),
]
.spacing(8),
)
.padding([6, 10])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
}),
)
.padding([6, 10])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
)
});
},
);
container(items).width(Length::FillPortion(1)).into()
}
@@ -313,7 +371,9 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
button(
column![
text(post.title.clone()).size(14).color(Color::WHITE),
text(post.date.clone()).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)),
text(post.date.clone())
.size(12)
.color(Color::from_rgb(0.6, 0.62, 0.68)),
]
.spacing(4),
)
@@ -342,7 +402,10 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
.padding(12)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
border: iced::Border { radius: 8.0.into(), ..iced::Border::default() },
border: iced::Border {
radius: 8.0.into(),
..iced::Border::default()
},
..container::Style::default()
})
.into()
@@ -363,7 +426,10 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> {
.padding([4, 8])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
border: iced::Border {
radius: 999.0.into(),
..iced::Border::default()
},
..container::Style::default()
})
.into()
@@ -387,5 +453,9 @@ fn parse_color(value: Option<&str>) -> Option<Color> {
fn contrast_color(background: Color) -> Color {
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
if luma > 0.55 { Color::BLACK } else { Color::WHITE }
if luma > 0.55 {
Color::BLACK
} else {
Color::WHITE
}
}

View File

@@ -1,8 +1,8 @@
use std::collections::HashMap;
use std::path::Path;
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, image, row, scrollable, text};
use iced::{Color, Element, Length};
use bds_core::i18n::{self, UiLocale};
@@ -72,12 +72,15 @@ impl MediaEditorState {
let mut translation_drafts = HashMap::new();
for tr in translations {
translation_drafts.insert(tr.language.clone(), MediaTranslationDraft {
title: tr.title.clone().unwrap_or_default(),
alt: tr.alt.clone().unwrap_or_default(),
caption: tr.caption.clone().unwrap_or_default(),
is_dirty: false,
});
translation_drafts.insert(
tr.language.clone(),
MediaTranslationDraft {
title: tr.title.clone().unwrap_or_default(),
alt: tr.alt.clone().unwrap_or_default(),
caption: tr.caption.clone().unwrap_or_default(),
is_dirty: false,
},
);
}
Self {
@@ -125,12 +128,15 @@ impl MediaEditorState {
is_dirty: self.is_dirty,
});
} else {
self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft {
title: self.title.clone(),
alt: self.alt.clone(),
caption: self.caption.clone(),
is_dirty: self.is_dirty,
});
self.translation_drafts.insert(
self.active_language.clone(),
MediaTranslationDraft {
title: self.title.clone(),
alt: self.alt.clone(),
caption: self.caption.clone(),
is_dirty: self.is_dirty,
},
);
}
// Load target fields
if target_lang == self.canonical_language {
@@ -225,24 +231,28 @@ pub fn view<'a>(
ai_enabled: bool,
) -> Element<'a, Message> {
let header = inputs::toolbar(
vec![
text(state.original_name.clone()).size(18).into(),
],
vec![text(state.original_name.clone()).size(18).into()],
vec![
if state.mime_type.starts_with("image/") {
button(text(t(locale, "editor.aiAnalyze")).size(13))
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)))
.on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)),
)
.padding([6, 16])
.into()
} else {
Space::new(0, 0).into()
},
button(text(t(locale, "editor.detectLanguage")).size(13))
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)))
.on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)),
)
.padding([6, 16])
.into(),
button(text(t(locale, "editor.translate")).size(13))
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)))
.on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)),
)
.padding([6, 16])
.into(),
button(text(t(locale, "common.save")).size(13))
@@ -273,7 +283,9 @@ pub fn view<'a>(
Color::from_rgb(0.55, 0.58, 0.65)
};
button(text(label).size(12).shaping(Shaping::Advanced).color(color))
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(flag.language.clone())))
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(
flag.language.clone(),
)))
.padding([4, 8])
.style(|_: &iced::Theme, _| button::Style::default())
.into()
@@ -295,13 +307,13 @@ pub fn view<'a>(
.width(Length::Fill)
.into()
} else {
no_preview()
no_preview(locale)
}
} else {
no_preview()
no_preview(locale)
}
} else {
no_preview()
no_preview(locale)
};
// File info
@@ -311,9 +323,12 @@ pub fn view<'a>(
};
let size_str = format_file_size(state.size);
let info = row![
text(format!("{}{}{}", state.mime_type, size_str, dimensions))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
text(format!(
"{}{}{}",
state.mime_type, size_str, dimensions
))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
]
.padding(8);
@@ -332,18 +347,13 @@ pub fn view<'a>(
);
let meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill);
let caption_input = inputs::labeled_input(
&t(locale, "editor.caption"),
"",
&state.caption,
|s| Message::MediaEditor(MediaEditorMsg::CaptionChanged(s)),
);
let author_input = inputs::labeled_input(
&t(locale, "editor.author"),
"",
&state.author,
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
);
let caption_input =
inputs::labeled_input(&t(locale, "editor.caption"), "", &state.caption, |s| {
Message::MediaEditor(MediaEditorMsg::CaptionChanged(s))
});
let author_input = inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| {
Message::MediaEditor(MediaEditorMsg::AuthorChanged(s))
});
let tags_input = inputs::labeled_input(
&t(locale, "editor.tags"),
&t(locale, "editor.tagsPlaceholder"),
@@ -361,8 +371,12 @@ pub fn view<'a>(
Some(&state.language),
|lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)),
);
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill);
let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill);
let meta_row2 = row![caption_input, author_input]
.spacing(16)
.width(Length::Fill);
let meta_row3 = row![tags_input, language_input]
.spacing(16)
.width(Length::Fill);
let linked_posts_header = row![
text(t(locale, "editor.linkedPosts"))
@@ -396,24 +410,30 @@ pub fn view<'a>(
.iter()
.map(|post| {
button(text(post.title.clone()).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(post.post_id.clone())))
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(
post.post_id.clone(),
)))
.padding([4, 10])
.width(Length::Fill)
.into()
})
.collect()
};
container(column![search, column(results).spacing(4)].spacing(8).padding(8))
.style(|_: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border {
color: Color::from_rgb(0.28, 0.28, 0.32),
width: 1.0,
radius: 6.0.into(),
},
..iced::widget::container::Style::default()
})
.into()
container(
column![search, column(results).spacing(4)]
.spacing(8)
.padding(8),
)
.style(|_: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border {
color: Color::from_rgb(0.28, 0.28, 0.32),
width: 1.0,
radius: 6.0.into(),
},
..iced::widget::container::Style::default()
})
.into()
} else {
Space::new(0, 0).into()
};
@@ -430,11 +450,15 @@ pub fn view<'a>(
.map(|post| {
row![
button(text(post.title.clone()).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(post.post_id.clone())))
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(
post.post_id.clone()
)))
.padding([4, 0]),
Space::with_width(Length::Fill),
button(text(t(locale, "editor.unlinkMedia")).size(11))
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(post.post_id.clone())))
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(
post.post_id.clone()
)))
.padding([3, 8])
.style(inputs::danger_button),
]
@@ -471,7 +495,7 @@ pub fn view<'a>(
]
.spacing(12)
.padding(16)
.width(Length::Fill)
.width(Length::Fill),
);
container(body)
@@ -480,6 +504,29 @@ pub fn view<'a>(
.into()
}
fn no_preview<'a>(locale: UiLocale) -> Element<'a, Message> {
container(
text(t(locale, "editor.noPreviewAvailable"))
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.center_x(Length::Fill)
.center_y(Length::Fixed(200.0))
.into()
}
fn format_file_size(bytes: i64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
#[cfg(test)]
mod tests {
use super::MediaEditorState;
@@ -519,30 +566,10 @@ mod tests {
);
let flags = state.translation_flags();
let languages = flags.into_iter().map(|flag| flag.language).collect::<Vec<_>>();
let languages = flags
.into_iter()
.map(|flag| flag.language)
.collect::<Vec<_>>();
assert_eq!(languages, vec!["en".to_string(), "de".to_string()]);
}
}
fn no_preview<'a>() -> Element<'a, Message> {
container(
text("No preview available")
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.center_x(Length::Fill)
.center_y(Length::Fixed(200.0))
.into()
}
fn format_file_size(bytes: i64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}

View File

@@ -1,18 +1,18 @@
pub mod workspace;
pub mod activity_bar;
pub mod sidebar;
pub mod tab_bar;
pub mod status_bar;
pub mod dashboard;
pub mod media_editor;
pub mod modal;
pub mod panel;
pub mod post_editor;
pub mod project_selector;
pub mod script_editor;
pub mod settings_view;
pub mod sidebar;
pub mod site_validation;
pub mod status_bar;
pub mod tab_bar;
pub mod tags_view;
pub mod template_editor;
pub mod toast;
pub mod welcome;
pub mod modal;
pub mod post_editor;
pub mod media_editor;
pub mod template_editor;
pub mod script_editor;
pub mod tags_view;
pub mod settings_view;
pub mod dashboard;
pub mod site_validation;
pub mod workspace;

View File

@@ -1,7 +1,9 @@
use std::path::Path;
use iced::widget::text::Shaping;
use iced::widget::{button, checkbox, column, container, image, row, scrollable, text, text_input, Space};
use iced::widget::{
Space, button, checkbox, column, container, image, row, scrollable, text, text_input,
};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -112,7 +114,10 @@ pub enum ConfirmAction {
DeleteTemplate(String),
ForceDeleteTemplate(String),
DeleteTag(String),
MergeTags { sources: Vec<String>, target: String },
MergeTags {
sources: Vec<String>,
target: String,
},
}
// ── Modal backdrop style ──
@@ -231,7 +236,11 @@ fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
}
/// Render the modal overlay.
pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Element<'static, Message> {
pub fn view(
state: ModalState,
locale: UiLocale,
data_dir: Option<&Path>,
) -> Element<'static, Message> {
let modal_content: Element<'static, Message> = match state {
ModalState::ConfirmDelete {
entity_name,
@@ -452,14 +461,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
}),
)
.padding([3, 8])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 999.0.into(),
},
..container::Style::default()
.style(|_: &Theme| {
container::Style {
background: Some(Background::Color(Color::from_rgb(
0.18, 0.20, 0.25,
))),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 999.0.into(),
},
..container::Style::default()
}
}),
]
.spacing(12)
@@ -505,7 +518,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
row![
Space::with_width(Length::Fill),
button(text(t(locale, "modal.postInsertLink.insert")))
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert))
.on_press(Message::PostEditor(
PostEditorMsg::PostInsertLinkExternalInsert
))
.padding([6, 16])
.style(confirm_button_style),
]
@@ -537,11 +552,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.size(13)
.shaping(Shaping::Advanced);
let trailing_button: Element<'static, Message> = if active_tab == PostInsertLinkTab::Internal {
create_post_btn
} else {
Space::with_width(0.0).into()
};
let trailing_button: Element<'static, Message> =
if active_tab == PostInsertLinkTab::Internal {
create_post_btn
} else {
Space::with_width(0.0).into()
};
let buttons = row![
button(cancel_text)
@@ -597,31 +613,27 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.iter()
.map(|m| {
let is_image = m.mime_type.starts_with("image/");
let title = m
.title
.as_ref()
.map(|s| s.as_str())
.unwrap_or("")
.to_string();
let title = m.title.as_deref().unwrap_or("").to_string();
let original_name = m.original_name.clone();
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
image(path)
.width(Length::Fixed(120.0))
.height(Length::Fixed(90.0))
.into()
} else if is_image {
text("\u{1F5BC}")
.size(32)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
} else {
text("\u{1F4C4}")
.size(32)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
};
let thumb: Element<'static, Message> =
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
image(path)
.width(Length::Fixed(120.0))
.height(Length::Fixed(90.0))
.into()
} else if is_image {
text("\u{1F5BC}")
.size(32)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
} else {
text("\u{1F4C4}")
.size(32)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
};
let media_title = text(title.clone())
.size(12)
@@ -716,10 +728,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.size(13)
.shaping(Shaping::Advanced);
let buttons = row![button(cancel_text)
.on_press(Message::DismissModal)
.padding([6, 16])
.style(cancel_button_style),];
let buttons = row![
button(cancel_text)
.on_press(Message::DismissModal)
.padding([6, 16])
.style(cancel_button_style),
];
let content = column![
title,
@@ -754,24 +768,20 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.iter()
.enumerate()
.map(|(index, m)| {
let title = m
.title
.as_ref()
.map(|s| s.as_str())
.unwrap_or("")
.to_string();
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
image(path)
.width(Length::Fixed(180.0))
.height(Length::Fixed(120.0))
.into()
} else {
text("\u{1F5BC}")
.size(32)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
};
let title = m.title.as_deref().unwrap_or("").to_string();
let thumb: Element<'static, Message> =
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
image(path)
.width(Length::Fixed(180.0))
.height(Length::Fixed(120.0))
.into()
} else {
text("\u{1F5BC}")
.size(32)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.40, 0.45))
.into()
};
let media_title = text(title)
.size(12)
@@ -845,17 +855,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
let lightbox: Element<'static, Message> = if let Some(index) = selected_index {
if let Some(selected) = image_media.get(index) {
let preview: Element<'static, Message> = if let Some(path) = resolve_media_file(data_dir, selected) {
image(path)
.width(Length::Fill)
.height(Length::Fixed(420.0))
.into()
} else {
text(t(locale, "modal.postGallery.unavailable"))
.size(13)
.shaping(Shaping::Advanced)
.into()
};
let preview: Element<'static, Message> =
if let Some(path) = resolve_media_file(data_dir, selected) {
image(path)
.width(Length::Fill)
.height(Length::Fixed(420.0))
.into()
} else {
text(t(locale, "modal.postGallery.unavailable"))
.size(13)
.shaping(Shaping::Advanced)
.into()
};
column![
container(preview)
.width(Length::Fill)
@@ -876,7 +887,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.style(cancel_button_style),
Space::with_width(12.0),
button(text(t(locale, "modal.postGallery.backToGrid")))
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox))
.on_press(Message::PostEditor(
PostEditorMsg::PostGalleryCloseLightbox
))
.padding([6, 12])
.style(cancel_button_style),
]
@@ -915,35 +928,53 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.shaping(Shaping::Advanced)
.color(Color::WHITE);
let rows = fields.iter().enumerate().map(|(index, field)| {
let toggle = checkbox(field.label.clone(), field.accepted)
.on_toggle_maybe((!field.locked)
.then_some(move |value| Message::ToggleAiSuggestionField(index, value)))
.size(16)
.text_size(13);
container(column![
toggle,
row![
container(text(field.current_value.clone()).size(12).color(Color::from_rgb(0.58, 0.58, 0.64))).width(Length::FillPortion(1)),
text("").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
container(text(field.suggested_value.clone()).size(12).color(Color::WHITE)).width(Length::FillPortion(1)),
]
.spacing(10)
.align_y(Alignment::Center),
]
.spacing(8))
.padding(10)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 6.0.into(),
},
..container::Style::default()
let rows = fields
.iter()
.enumerate()
.map(|(index, field)| {
let toggle =
checkbox(field.label.clone(), field.accepted)
.on_toggle_maybe((!field.locked).then_some(move |value| {
Message::ToggleAiSuggestionField(index, value)
}))
.size(16)
.text_size(13);
container(
column![
toggle,
row![
container(
text(field.current_value.clone())
.size(12)
.color(Color::from_rgb(0.58, 0.58, 0.64))
)
.width(Length::FillPortion(1)),
text("").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
container(
text(field.suggested_value.clone())
.size(12)
.color(Color::WHITE)
)
.width(Length::FillPortion(1)),
]
.spacing(10)
.align_y(Alignment::Center),
]
.spacing(8),
)
.padding(10)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 6.0.into(),
},
..container::Style::default()
})
.into()
})
.into()
}).collect::<Vec<Element<'static, Message>>>();
.collect::<Vec<Element<'static, Message>>>();
let buttons = row![
button(text(t(locale, "common.cancel")).size(13))
@@ -972,35 +1003,59 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
.into()
}
ModalState::LanguagePicker { target, source_language, available_targets } => {
ModalState::LanguagePicker {
target,
source_language,
available_targets,
} => {
let title = text(t(locale, "modal.languagePicker.title"))
.size(16)
.shaping(Shaping::Advanced)
.color(Color::WHITE);
let subtitle = text(format!("{}: {}", t(locale, "editor.language"), source_language))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.60, 0.60, 0.68));
let subtitle = text(format!(
"{}: {}",
t(locale, "editor.language"),
source_language
))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.60, 0.60, 0.68));
let rows = if available_targets.is_empty() {
vec![text(t(locale, "common.noResults")).size(12).into()]
} else {
available_targets.into_iter().map(|language| {
let message = match &target {
AiEntityTarget::Post(_) => Message::PostEditor(PostEditorMsg::TranslateTo(language.code.clone())),
AiEntityTarget::Media(_) => Message::MediaEditor(crate::views::media_editor::MediaEditorMsg::TranslateTo(language.code.clone())),
};
let status = language.existing_status.clone().unwrap_or_default();
button(row![
text(format!("{} {}", language.flag_emoji, language.name)).size(13).color(Color::WHITE),
Space::with_width(Length::Fill),
text(status).size(11).color(Color::from_rgb(0.60, 0.60, 0.68)),
].align_y(Alignment::Center))
.on_press(message)
.padding([8, 12])
.style(cancel_button_style)
.into()
}).collect::<Vec<Element<'static, Message>>>()
available_targets
.into_iter()
.map(|language| {
let message = match &target {
AiEntityTarget::Post(_) => Message::PostEditor(
PostEditorMsg::TranslateTo(language.code.clone()),
),
AiEntityTarget::Media(_) => Message::MediaEditor(
crate::views::media_editor::MediaEditorMsg::TranslateTo(
language.code.clone(),
),
),
};
let status = language.existing_status.clone().unwrap_or_default();
button(
row![
text(format!("{} {}", language.flag_emoji, language.name))
.size(13)
.color(Color::WHITE),
Space::with_width(Length::Fill),
text(status)
.size(11)
.color(Color::from_rgb(0.60, 0.60, 0.68)),
]
.align_y(Alignment::Center),
)
.on_press(message)
.padding([8, 12])
.style(cancel_button_style)
.into()
})
.collect::<Vec<Element<'static, Message>>>()
};
let content = column![
@@ -1042,7 +1097,10 @@ mod tests {
#[test]
fn external_link_markdown_requires_url() {
assert_eq!(external_link_markdown("", "text"), None);
assert_eq!(external_link_markdown("https://example.com", ""), Some("https://example.com".to_string()));
assert_eq!(
external_link_markdown("https://example.com", ""),
Some("https://example.com".to_string())
);
assert_eq!(
external_link_markdown("https://example.com", "Example"),
Some("[Example](https://example.com)".to_string())

View File

@@ -1,5 +1,5 @@
use iced::widget::{button, column, container, scrollable, text, Space};
use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, scrollable, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -64,6 +64,10 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
}
}
#[expect(
clippy::too_many_arguments,
reason = "arguments are independent panel state slices"
)]
pub fn view(
panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot],
@@ -79,39 +83,68 @@ pub fn view(
// Tab header — per layout.allium: tasks, output, post_links (only when
// active editor tab is a post), git_log (only when active tab is post or
// media).
let tasks_btn = button(text(t(locale, "common.tasks")).size(12).shaping(Shaping::Advanced))
.on_press(Message::SetPanelTab(PanelTab::Tasks))
.padding([4, 8])
.style(if panel_tab == PanelTab::Tasks { tab_active } else { tab_inactive });
let tasks_btn = button(
text(t(locale, "common.tasks"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::Tasks))
.padding([4, 8])
.style(if panel_tab == PanelTab::Tasks {
tab_active
} else {
tab_inactive
});
let output_btn = button(text(t(locale, "panel.output")).size(12).shaping(Shaping::Advanced))
.on_press(Message::SetPanelTab(PanelTab::Output))
.padding([4, 8])
.style(if panel_tab == PanelTab::Output { tab_active } else { tab_inactive });
let output_btn = button(
text(t(locale, "panel.output"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::Output))
.padding([4, 8])
.style(if panel_tab == PanelTab::Output {
tab_active
} else {
tab_inactive
});
let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced))
.on_press(Message::TogglePanel)
.padding([4, 6])
.style(close_btn_style);
let mut tab_row: Vec<Element<'static, Message>> = vec![
tasks_btn.into(),
output_btn.into(),
];
let mut tab_row: Vec<Element<'static, Message>> = vec![tasks_btn.into(), output_btn.into()];
if active_tab_is_post {
let post_links_btn = button(text(t(locale, "panel.postLinks")).size(12).shaping(Shaping::Advanced))
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
.padding([4, 8])
.style(if panel_tab == PanelTab::PostLinks { tab_active } else { tab_inactive });
let post_links_btn = button(
text(t(locale, "panel.postLinks"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
.padding([4, 8])
.style(if panel_tab == PanelTab::PostLinks {
tab_active
} else {
tab_inactive
});
tab_row.push(post_links_btn.into());
}
if active_tab_is_post_or_media {
let git_log_btn = button(text(t(locale, "panel.gitLog")).size(12).shaping(Shaping::Advanced))
.on_press(Message::SetPanelTab(PanelTab::GitLog))
.padding([4, 8])
.style(if panel_tab == PanelTab::GitLog { tab_active } else { tab_inactive });
let git_log_btn = button(
text(t(locale, "panel.gitLog"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::GitLog))
.padding([4, 8])
.style(if panel_tab == PanelTab::GitLog {
tab_active
} else {
tab_inactive
});
tab_row.push(git_log_btn.into());
}
@@ -127,9 +160,14 @@ pub fn view(
let content: Element<'static, Message> = match panel_tab {
PanelTab::Tasks => {
if task_snapshots.is_empty() {
container(text(t(locale, "tasks.noActive")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8)
.into()
container(
text(t(locale, "tasks.noActive"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
// Per layout.allium: last 10 tasks, newest first
let items: Vec<Element<'static, Message>> = task_snapshots
@@ -137,21 +175,24 @@ pub fn view(
.rev()
.take(10)
.map(|snap| {
let progress_str = snap.progress
let progress_str = snap
.progress
.map(|p| format!(" ({:.0}%)", p * 100.0))
.unwrap_or_default();
let phase_str = snap.message
let phase_str = snap
.message
.as_deref()
.map(|m| format!(" \u{2014} {m}"))
.unwrap_or_default();
let status_text = format!(
"{} \u{2014} {}{}{}",
snap.label,
snap.status,
progress_str,
phase_str,
snap.label, snap.status, progress_str, phase_str,
);
text(status_text).size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.70, 0.70, 0.75)).into()
text(status_text)
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75))
.into()
})
.collect();
scrollable(
@@ -164,9 +205,14 @@ pub fn view(
}
PanelTab::Output => {
if output_entries.is_empty() {
container(text(t(locale, "panel.noOutput")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8)
.into()
container(
text(t(locale, "panel.noOutput"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let items: Vec<Element<'static, Message>> = output_entries
.iter()
@@ -188,9 +234,14 @@ pub fn view(
}
PanelTab::PostLinks => {
if post_outlinks.is_empty() && post_backlinks.is_empty() {
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8)
.into()
container(
text(t(locale, "panel.postLinksPlaceholder"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let mut items: Vec<Element<'static, Message>> = vec![
text(t(locale, "editor.outlinks"))
@@ -201,7 +252,12 @@ pub fn view(
];
if post_outlinks.is_empty() {
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
items.push(
text(t(locale, "panel.postLinksPlaceholder"))
.size(11)
.color(muted)
.into(),
);
} else {
for link in post_outlinks {
items.push(post_link_button(locale, link));
@@ -218,7 +274,12 @@ pub fn view(
);
if post_backlinks.is_empty() {
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
items.push(
text(t(locale, "panel.postLinksPlaceholder"))
.size(11)
.color(muted)
.into(),
);
} else {
for link in post_backlinks {
items.push(post_link_button(locale, link));
@@ -235,19 +296,22 @@ pub fn view(
}
PanelTab::GitLog => {
// Git Log content populated in extension bucket A (git integration)
container(text(t(locale, "panel.gitLogPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8)
.into()
container(
text(t(locale, "panel.gitLogPlaceholder"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
}
};
container(
column![tab_header, content].spacing(0),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.style(panel_style)
.into()
container(column![tab_header, content].spacing(0))
.width(Length::Fill)
.height(Length::Fixed(200.0))
.style(panel_style)
.into()
}
fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> {

View File

@@ -2,12 +2,12 @@ use std::cell::RefCell;
use std::collections::HashMap;
use iced::widget::text::{Shaping, Wrapping};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input};
use iced::{Color, Element, Length, Theme};
use bds_core::i18n::{self, UiLocale};
use bds_core::model::{Post, PostStatus, PostTranslation};
use bds_editor::{highlighter, CodeEditor, EditorBuffer, EditorMessage};
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
use crate::app::Message;
use crate::components::inputs;
@@ -297,7 +297,11 @@ impl PostEditorState {
langs.sort();
for lang in langs {
let locale = i18n::normalize_language(&lang);
let status = match self.translation_drafts.get(&lang).map(|draft| &draft.status) {
let status = match self
.translation_drafts
.get(&lang)
.map(|draft| &draft.status)
{
Some(PostStatus::Published) => "published",
Some(_) => "draft",
None => "missing",
@@ -480,10 +484,30 @@ pub fn view<'a>(
Space::with_width(Length::Fill),
container(
column![
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled),
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled),
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled),
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled),
quick_action_item(
locale,
t(locale, "editor.aiAnalyze"),
PostEditorMsg::AnalyzeWithAi,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.suggestTaxonomy"),
PostEditorMsg::AnalyzeTaxonomy,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.translate"),
PostEditorMsg::Translate,
ai_enabled
),
quick_action_item(
locale,
t(locale, "editor.detectLanguage"),
PostEditorMsg::DetectLanguage,
ai_enabled
),
]
.spacing(4)
)
@@ -525,7 +549,7 @@ pub fn view<'a>(
let mut flag_row = row![].spacing(2);
for flag in &flags {
let lang = flag.language.clone();
let label = format!("{}", flag.flag_emoji);
let label = flag.flag_emoji.to_string();
let btn = button(text(label).size(14).shaping(Shaping::Advanced))
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
.padding([2, 4])
@@ -574,9 +598,13 @@ pub fn view<'a>(
Some(&state.language),
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
);
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
.padding([6, 12]);
let detect_language = button(
text(t(locale, "editor.detectLanguage"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
.padding([6, 12]);
let template_input = inputs::labeled_input(
&t(locale, "editor.templateSlug"),
"",
@@ -619,11 +647,13 @@ pub fn view<'a>(
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
Space::new(0, 0).into()
} else {
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.outlinks"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced)
.into()];
let mut items: Vec<Element<'a, Message>> = vec![
text(t(locale, "editor.outlinks"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced)
.into(),
];
for link in &state.outlinks {
items.push(
text(format!("\u{2192} {}", link.title))
@@ -639,11 +669,13 @@ pub fn view<'a>(
let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() {
Space::new(0, 0).into()
} else {
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.backlinks"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced)
.into()];
let mut items: Vec<Element<'a, Message>> = vec![
text(t(locale, "editor.backlinks"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced)
.into(),
];
for link in &state.backlinks {
items.push(
text(format!("\u{2190} {}", link.title))
@@ -712,20 +744,32 @@ pub fn view<'a>(
]
.spacing(2)
.width(Length::Fill),
button(text(t(locale, "common.open")).size(11).shaping(Shaping::Advanced))
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
.padding([4, 10]),
button(text(t(locale, "editor.unlinkMedia")).size(11).shaping(Shaping::Advanced))
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(media_id)))
.padding([4, 10])
.style(inputs::danger_button),
button(
text(t(locale, "common.open"))
.size(11)
.shaping(Shaping::Advanced)
)
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
.padding([4, 10]),
button(
text(t(locale, "editor.unlinkMedia"))
.size(11)
.shaping(Shaping::Advanced)
)
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(
media_id
)))
.padding([4, 10])
.style(inputs::danger_button),
]
.spacing(8)
.align_y(iced::Alignment::Center),
)
.padding(8)
.style(|_: &Theme| container::Style {
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
background: Some(iced::Background::Color(Color::from_rgb(
0.16, 0.18, 0.22,
))),
border: iced::Border {
color: Color::from_rgb(0.28, 0.28, 0.32),
width: 1.0,
@@ -788,13 +832,31 @@ pub fn view<'a>(
// ── Content section (fills remaining space) ──
let content_label = inputs::section_header(&t(locale, "editor.content"));
let mode_toggle = row![
mode_button(locale, &state.editor_mode, "markdown", Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))),
mode_button(locale, &state.editor_mode, "preview", Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))),
mode_button(
locale,
&state.editor_mode,
"markdown",
Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))
),
mode_button(
locale,
&state.editor_mode,
"preview",
Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))
),
]
.spacing(6)
.align_y(iced::Alignment::Center);
let body_toolbar = inputs::toolbar(
vec![row![content_label, Space::with_width(Length::Fixed(12.0)), mode_toggle].align_y(iced::Alignment::Center).into()],
vec![
row![
content_label,
Space::with_width(Length::Fixed(12.0)),
mode_toggle
]
.align_y(iced::Alignment::Center)
.into(),
],
vec![
if state.editor_mode == "markdown" {
button(
@@ -980,10 +1042,15 @@ fn mode_button<'a>(
.into()
}
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> {
fn quick_action_item<'a>(
locale: UiLocale,
label: String,
msg: PostEditorMsg,
enabled: bool,
) -> Element<'a, Message> {
let _ = locale;
button(text(label).size(12).shaping(Shaping::Advanced))
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
.padding([6, 12])
.style(status_bar::dropdown_item)
.width(Length::Fixed(220.0))
@@ -1138,5 +1205,4 @@ mod tests {
state.set_editor_mode("preview");
assert_eq!(state.editor_mode, "preview");
}
}

View File

@@ -1,5 +1,5 @@
use iced::widget::{button, container, row, svg, text, Column, Space};
use iced::widget::text::Shaping;
use iced::widget::{Column, Space, button, container, row, svg, text};
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -120,19 +120,32 @@ pub fn view(
let label = if is_active {
row![
text("\u{2713}").size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.40, 0.80, 0.40)),
text(name).size(12).shaping(Shaping::Advanced).color(Color::WHITE),
text("\u{2713}")
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.40, 0.80, 0.40)),
text(name)
.size(12)
.shaping(Shaping::Advanced)
.color(Color::WHITE),
]
.spacing(6)
} else {
row![
Space::with_width(Length::Fixed(14.0)),
text(name).size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.80, 0.80, 0.85)),
text(name)
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.80, 0.80, 0.85)),
]
.spacing(6)
};
let style_fn = if is_active { project_item_active } else { project_item };
let style_fn = if is_active {
project_item_active
} else {
project_item
};
items.push(
button(label)
@@ -159,8 +172,13 @@ pub fn view(
items.push(
button(
row![
text("+").size(14).shaping(Shaping::Advanced).color(Color::from_rgb(0.55, 0.75, 0.95)),
text(t(locale, "projectSelector.newProject")).size(12).shaping(Shaping::Advanced),
text("+")
.size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.55, 0.75, 0.95)),
text(t(locale, "projectSelector.newProject"))
.size(12)
.shaping(Shaping::Advanced),
]
.spacing(6),
)

View File

@@ -1,6 +1,6 @@
use std::cell::RefCell;
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -118,10 +118,7 @@ pub enum ScriptEditorMsg {
}
/// Render the script editor view.
pub fn view<'a>(
state: &'a ScriptEditorState,
locale: UiLocale,
) -> Element<'a, Message> {
pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, Message> {
let header = inputs::toolbar(
vec![
text(state.title.clone()).size(18).into(),
@@ -162,7 +159,9 @@ pub fn view<'a>(
&state.slug,
|s| Message::ScriptEditor(ScriptEditorMsg::SlugChanged(s)),
);
let meta_row1 = row![title_input, slug_input].spacing(16).width(Length::Fill);
let meta_row1 = row![title_input, slug_input]
.spacing(16)
.width(Length::Fill);
// Metadata row 2: kind, entrypoint, enabled
let kind_options = vec![
@@ -179,7 +178,10 @@ pub fn view<'a>(
);
let mut entrypoint_options = state.discovered_entrypoints.clone();
if !entrypoint_options.iter().any(|entrypoint| entrypoint == &state.entrypoint) {
if !entrypoint_options
.iter()
.any(|entrypoint| entrypoint == &state.entrypoint)
{
entrypoint_options.push(state.entrypoint.clone());
}
let selected_entrypoint = entrypoint_options
@@ -192,28 +194,30 @@ pub fn view<'a>(
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
);
let enabled_check = inputs::labeled_checkbox(
&t(locale, "editor.enabled"),
state.enabled,
|b| Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b)),
);
let enabled_check =
inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b))
});
let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
.spacing(16)
.width(Length::Fill);
// Content editor (CodeEditor with syntax highlighting based on file extension)
let syntax_ext = if state.file_path.ends_with(".py") { "py" } else { "lua" };
let syntax_ext = if state.file_path.ends_with(".py") {
"py"
} else {
"lua"
};
let content_section: Element<'a, Message> = column![
inputs::section_header(&t(locale, "editor.content")),
Element::from(
CodeEditor::new(
&state.editor_buffer,
highlighter(),
syntax_ext,
)
.on_change(|msg| match msg {
EditorMessage::ContentChanged(s) => Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)),
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| {
match msg {
EditorMessage::ContentChanged(s) => {
Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))
}
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
}
})
),
]
@@ -245,29 +249,20 @@ pub fn view<'a>(
// Top pane: header + metadata (scrollable for overflow)
let top_pane = scrollable(
column![
header,
meta_row1,
meta_row2,
]
.spacing(12)
.padding(16)
.width(Length::Fill)
column![header, meta_row1, meta_row2,]
.spacing(12)
.padding(16)
.width(Length::Fill),
)
.height(Length::Shrink);
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
column![
top_pane,
content_section,
validation,
footer,
]
.spacing(4)
.padding([0, 16])
.width(Length::Fill)
.height(Length::Fill)
.into()
column![top_pane, content_section, validation, footer,]
.spacing(4)
.padding([0, 16])
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn status_badge<'a>(status: &ScriptStatus) -> Element<'a, Message> {

View File

@@ -1,5 +1,5 @@
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
use iced::widget::text::Shaping;
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
use iced::{Alignment, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -209,7 +209,13 @@ impl Default for SettingsViewState {
public_url: String::new(),
main_language: "en".to_string(),
blog_languages: vec!["en".to_string()],
available_languages: vec!["en".to_string(), "de".to_string(), "fr".to_string(), "it".to_string(), "es".to_string()],
available_languages: vec![
"en".to_string(),
"de".to_string(),
"fr".to_string(),
"it".to_string(),
"es".to_string(),
],
default_author: String::new(),
max_posts_per_page: "50".to_string(),
blogmark_category: String::new(),
@@ -255,11 +261,11 @@ impl SettingsViewState {
fn ordered_sections(&self) -> Vec<SettingsSection> {
let mut sections = SettingsSection::all().to_vec();
if let Some(active) = &self.active_section {
if let Some(index) = sections.iter().position(|section| section == active) {
let focused = sections.remove(index);
sections.insert(0, focused);
}
if let Some(active) = &self.active_section
&& let Some(index) = sections.iter().position(|section| section == active)
{
let focused = sections.remove(index);
sections.insert(0, focused);
}
sections
}
@@ -337,10 +343,7 @@ pub enum SettingsMsg {
}
/// Render the settings view.
pub fn view<'a>(
state: &'a SettingsViewState,
locale: UiLocale,
) -> Element<'a, Message> {
pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
.size(14);
@@ -388,38 +391,6 @@ pub fn view<'a>(
.into()
}
#[cfg(test)]
mod tests {
use super::{SettingsSection, SettingsViewState};
#[test]
fn focus_section_collapses_other_sections_and_clears_search() {
let mut state = SettingsViewState {
search_query: "publish".to_string(),
..SettingsViewState::default()
};
state.focus_section(SettingsSection::Publishing);
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
assert!(state.search_query.is_empty());
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
assert!(state.collapsed.contains(&SettingsSection::Project));
assert!(state.collapsed.contains(&SettingsSection::MCP));
}
#[test]
fn focused_section_is_rendered_first() {
let mut state = SettingsViewState::default();
state.focus_section(SettingsSection::Technology);
let ordered = state.ordered_sections();
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
assert_eq!(ordered.len(), SettingsSection::all().len());
}
}
fn render_section<'a>(
state: &'a SettingsViewState,
section: &SettingsSection,
@@ -436,7 +407,9 @@ fn render_section<'a>(
.spacing(8)
.align_y(Alignment::Center),
)
.on_press(Message::Settings(SettingsMsg::ToggleSection(section.clone())))
.on_press(Message::Settings(SettingsMsg::ToggleSection(
section.clone(),
)))
.padding([6, 8])
.width(Length::Fill)
.style(|_: &Theme, _| button::Style::default());
@@ -456,9 +429,7 @@ fn render_section<'a>(
SettingsSection::MCP => section_mcp(locale),
};
column![header, content]
.spacing(4)
.into()
column![header, content].spacing(4).into()
}
fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -469,7 +440,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|s| Message::Settings(SettingsMsg::ProjectNameChanged(s)),
);
let desc = column![
text(t(locale, "settings.projectDescription")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
text(t(locale, "settings.projectDescription"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced),
text_editor(&state.project_description)
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
.height(Length::Fixed(80.0))
@@ -477,12 +451,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
]
.spacing(4);
let data_path = row![
inputs::labeled_input(
&t(locale, "settings.dataPath"),
"",
&state.data_path,
|s| Message::Settings(SettingsMsg::DataPathChanged(s)),
),
inputs::labeled_input(&t(locale, "settings.dataPath"), "", &state.data_path, |s| {
Message::Settings(SettingsMsg::DataPathChanged(s))
},),
button(text(t(locale, "settings.browse")).size(12))
.on_press(Message::Settings(SettingsMsg::BrowseDataPath))
.padding([6, 12]),
@@ -512,14 +483,24 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.iter()
.map(|language| {
let label = if *language == state.main_language {
format!("{} ({})", language, t(locale, "settings.mainLanguageRequired"))
format!(
"{} ({})",
language,
t(locale, "settings.mainLanguageRequired")
)
} else {
language.clone()
};
inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), {
let language = language.clone();
move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
})
inputs::labeled_checkbox(
&label,
state.blog_languages.iter().any(|item| item == language),
{
let language = language.clone();
move |_| {
Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
}
},
)
})
.collect::<Vec<_>>(),
)
@@ -539,7 +520,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
let blogmark_category = inputs::labeled_select(
&t(locale, "settings.blogmarkCategory"),
&state.categories,
state.categories.iter().find(|row| row.name == state.blogmark_category),
state
.categories
.iter()
.find(|row| row.name == state.blogmark_category),
|row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)),
);
let save = button(text(t(locale, "common.save")).size(13))
@@ -566,9 +550,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
blogmark_category,
save,
]
.spacing(8)
.padding([0, 16])
.into()
.spacing(8)
.padding([0, 16])
.into()
}
fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -616,79 +600,112 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.chain(state.template_options.iter().cloned())
.collect::<Vec<_>>();
let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| {
let title = inputs::labeled_input(
&tw(locale, "settings.categoryTitle", &[("category", &category.name)]),
"",
&category.title,
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
},
);
let post_template = inputs::labeled_select(
&t(locale, "settings.categoryPostTemplate"),
&template_options,
Some(&category.post_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryPostTemplateChanged(name.clone(), value))
},
);
let list_template = inputs::labeled_select(
&t(locale, "settings.categoryListTemplate"),
&template_options,
Some(&category.list_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryListTemplateChanged(name.clone(), value))
},
);
let toggles = column![
inputs::labeled_checkbox(
&t(locale, "settings.categoryRenderInLists"),
category.render_in_lists,
let category_rows = state
.categories
.iter()
.fold(column![].spacing(8), |column, category| {
let title = inputs::labeled_input(
&tw(
locale,
"settings.categoryTitle",
&[("category", &category.name)],
),
"",
&category.title,
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value))
move |value| {
Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
}
},
),
inputs::labeled_checkbox(
&t(locale, "settings.categoryShowTitles"),
category.show_title,
);
let post_template = inputs::labeled_select(
&t(locale, "settings.categoryPostTemplate"),
&template_options,
Some(&category.post_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value))
move |value| {
Message::Settings(SettingsMsg::CategoryPostTemplateChanged(
name.clone(),
value,
))
}
},
),
]
.spacing(6);
let actions = row![
button(text(t(locale, "common.save")).size(13))
.on_press(Message::Settings(SettingsMsg::SaveCategory(category.name.clone())))
.style(inputs::primary_button)
.padding([6, 12]),
button(text(t(locale, "common.remove")).size(13))
.on_press_maybe((!category.is_protected).then(|| Message::Settings(SettingsMsg::RemoveCategory(category.name.clone()))))
.style(inputs::danger_button)
.padding([6, 12]),
]
.spacing(8);
);
let list_template = inputs::labeled_select(
&t(locale, "settings.categoryListTemplate"),
&template_options,
Some(&category.list_template_slug),
{
let name = category.name.clone();
move |value| {
Message::Settings(SettingsMsg::CategoryListTemplateChanged(
name.clone(),
value,
))
}
},
);
let toggles = column![
inputs::labeled_checkbox(
&t(locale, "settings.categoryRenderInLists"),
category.render_in_lists,
{
let name = category.name.clone();
move |value| {
Message::Settings(SettingsMsg::CategoryRenderInListsChanged(
name.clone(),
value,
))
}
},
),
inputs::labeled_checkbox(
&t(locale, "settings.categoryShowTitles"),
category.show_title,
{
let name = category.name.clone();
move |value| {
Message::Settings(SettingsMsg::CategoryShowTitleChanged(
name.clone(),
value,
))
}
},
),
]
.spacing(6);
let actions = row![
button(text(t(locale, "common.save")).size(13))
.on_press(Message::Settings(SettingsMsg::SaveCategory(
category.name.clone()
)))
.style(inputs::primary_button)
.padding([6, 12]),
button(text(t(locale, "common.remove")).size(13))
.on_press_maybe((!category.is_protected).then(|| Message::Settings(
SettingsMsg::RemoveCategory(category.name.clone())
)))
.style(inputs::danger_button)
.padding([6, 12]),
]
.spacing(8);
column.push(
container(
column![
text(category.name.clone()).size(15).color(Color::WHITE),
title,
toggles,
row![post_template, list_template].spacing(12),
actions,
]
.spacing(8),
column.push(
container(
column![
text(category.name.clone()).size(15).color(Color::WHITE),
title,
toggles,
row![post_template, list_template].spacing(12),
actions,
]
.spacing(8),
)
.padding(12),
)
.padding(12),
)
});
});
let add_row = row![
inputs::labeled_input(
@@ -732,7 +749,12 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
label: t(locale, "tags.noTemplate"),
supports_vision: false,
})
.chain(active_model_options.iter().filter(|option| option.supports_vision).cloned())
.chain(
active_model_options
.iter()
.filter(|option| option.supports_vision)
.cloned(),
)
.collect::<Vec<_>>();
let offline = inputs::labeled_checkbox(
@@ -786,23 +808,32 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
let default_model = inputs::labeled_select(
&t(locale, "settings.defaultModel"),
&model_options,
model_options.iter().find(|option| option.id == state.default_model),
model_options
.iter()
.find(|option| option.id == state.default_model),
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
);
let title_model = inputs::labeled_select(
&t(locale, "settings.titleModel"),
&model_options,
model_options.iter().find(|option| option.id == state.title_model),
model_options
.iter()
.find(|option| option.id == state.title_model),
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
);
let image_model = inputs::labeled_select(
&t(locale, "settings.imageAnalysisModel"),
&image_model_options,
image_model_options.iter().find(|option| option.id == state.image_model),
image_model_options
.iter()
.find(|option| option.id == state.image_model),
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
);
let prompt = column![
text(t(locale, "settings.systemPrompt")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
text(t(locale, "settings.systemPrompt"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced),
text_editor(&state.system_prompt)
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
.height(Length::Fixed(200.0))
@@ -822,11 +853,15 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
column![
offline,
text(t(locale, "settings.onlineEndpointSection")).size(12).color(inputs::LABEL_COLOR),
text(t(locale, "settings.onlineEndpointSection"))
.size(12)
.color(inputs::LABEL_COLOR),
online_url,
row![online_model, online_api_key].spacing(12),
online_refresh,
text(t(locale, "settings.airplaneEndpointSection")).size(12).color(inputs::LABEL_COLOR),
text(t(locale, "settings.airplaneEndpointSection"))
.size(12)
.color(inputs::LABEL_COLOR),
airplane_url,
row![airplane_model, airplane_refresh].spacing(12),
default_model,
@@ -835,9 +870,9 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
prompt,
btns,
]
.spacing(8)
.padding([0, 16])
.into()
.spacing(8)
.padding([0, 16])
.into()
}
fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -857,12 +892,9 @@ fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Ele
}
fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let host = inputs::labeled_input(
&t(locale, "settings.sshHost"),
"",
&state.ssh_host,
|s| Message::Settings(SettingsMsg::SshHostChanged(s)),
);
let host = inputs::labeled_input(&t(locale, "settings.sshHost"), "", &state.ssh_host, |s| {
Message::Settings(SettingsMsg::SshHostChanged(s))
});
let user = inputs::labeled_input(
&t(locale, "settings.sshUsername"),
"",
@@ -939,3 +971,35 @@ fn section_mcp<'a>(locale: UiLocale) -> Element<'a, Message> {
.color(Color::from_rgb(0.5, 0.5, 0.5))
.into()
}
#[cfg(test)]
mod tests {
use super::{SettingsSection, SettingsViewState};
#[test]
fn focus_section_collapses_other_sections_and_clears_search() {
let mut state = SettingsViewState {
search_query: "publish".to_string(),
..SettingsViewState::default()
};
state.focus_section(SettingsSection::Publishing);
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
assert!(state.search_query.is_empty());
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
assert!(state.collapsed.contains(&SettingsSection::Project));
assert!(state.collapsed.contains(&SettingsSection::MCP));
}
#[test]
fn focused_section_is_rendered_first() {
let mut state = SettingsViewState::default();
state.focus_section(SettingsSection::Technology);
let ordered = state.ordered_sections();
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
assert_eq!(ordered.len(), SettingsSection::all().len());
}
}

View File

@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space};
use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, image, row, scrollable, text, text_input};
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -125,11 +125,11 @@ fn truncate_media_title(title: &str) -> String {
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
"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
"video" => return "\u{1F3AC}", // 🎬 film
"podcast" => return "\u{1F4AC}", // 💬 speech bubble
_ => {}
}
}
@@ -269,9 +269,18 @@ fn clear_filters_style(_theme: &Theme, status: button::Status) -> button::Style
/// Month name abbreviation for calendar display.
fn month_abbr(month: u32) -> &'static str {
match month {
1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "???",
}
}
@@ -300,10 +309,12 @@ fn calendar_widget(
let label = format!("{} ({})", cy.year, total);
let year_val = cy.year;
let on_year_clone = on_year.clone();
let style_fn = if year_selected { calendar_selected_style } else { calendar_style };
let year_btn = button(
text(label).size(11).shaping(Shaping::Advanced)
)
let style_fn = if year_selected {
calendar_selected_style
} else {
calendar_style
};
let year_btn = button(text(label).size(11).shaping(Shaping::Advanced))
.on_press(if year_selected {
on_year_clone(None)
} else {
@@ -320,10 +331,12 @@ fn calendar_widget(
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
let month_val = cm.month;
let on_month_clone = on_month.clone();
let style_fn = if month_selected { calendar_selected_style } else { calendar_style };
let month_btn = button(
text(label).size(10).shaping(Shaping::Advanced)
)
let style_fn = if month_selected {
calendar_selected_style
} else {
calendar_style
};
let month_btn = button(text(label).size(10).shaping(Shaping::Advanced))
.on_press(if month_selected {
on_month_clone(None)
} else {
@@ -362,10 +375,12 @@ fn chip_selector(
let is_selected = selected.contains(tag);
let tag_clone = tag.clone();
let on_toggle_clone = on_toggle.clone();
let style_fn = if is_selected { chip_selected_style } else { chip_style };
let chip = button(
text(tag.clone()).size(10).shaping(Shaping::Advanced)
)
let style_fn = if is_selected {
chip_selected_style
} else {
chip_style
};
let chip = button(text(tag.clone()).size(10).shaping(Shaping::Advanced))
.on_press(on_toggle_clone(tag_clone))
.padding([2, 6])
.style(style_fn);
@@ -377,9 +392,7 @@ fn chip_selector(
while !collected.is_empty() {
let chunk_size = 3.min(collected.len());
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
items.push(
iced::widget::Row::with_children(chunk).spacing(4).into()
);
items.push(iced::widget::Row::with_children(chunk).spacing(4).into());
}
}
@@ -408,7 +421,11 @@ fn single_select_chip_selector(
.iter()
.map(|(value, display)| {
let is_selected = selected == Some(value.as_str());
let style_fn = if is_selected { chip_selected_style } else { chip_style };
let style_fn = if is_selected {
chip_selected_style
} else {
chip_style
};
let value = value.clone();
let on_toggle = on_toggle.clone();
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
@@ -473,8 +490,8 @@ fn post_filter_panel(
&filter.calendar_years,
filter.calendar.selected_year,
filter.calendar.selected_month,
|y| Message::SetPostCalendarYear(y),
|m| Message::SetPostCalendarMonth(m),
Message::SetPostCalendarYear,
Message::SetPostCalendarMonth,
locale,
));
sections.push(Space::with_height(4.0).into());
@@ -486,7 +503,7 @@ fn post_filter_panel(
"sidebar.filter.tags",
&filter.available_tags,
&filter.tag_filter,
|tag| Message::TogglePostTagFilter(tag),
Message::TogglePostTagFilter,
locale,
));
sections.push(Space::with_height(4.0).into());
@@ -498,7 +515,7 @@ fn post_filter_panel(
"sidebar.filter.categories",
&filter.available_categories,
&filter.category_filter,
|cat| Message::TogglePostCategoryFilter(cat),
Message::TogglePostCategoryFilter,
locale,
));
sections.push(Space::with_height(4.0).into());
@@ -537,23 +554,22 @@ fn post_filter_panel(
button(
text(t(locale, "sidebar.filter.clearAll"))
.size(10)
.shaping(Shaping::Advanced)
.shaping(Shaping::Advanced),
)
.on_press(Message::ClearPostFilters)
.padding([2, 4])
.style(clear_filters_style)
.into()
.on_press(Message::ClearPostFilters)
.padding([2, 4])
.style(clear_filters_style)
.into(),
);
}
iced::widget::Column::with_children(sections).spacing(2).into()
iced::widget::Column::with_children(sections)
.spacing(2)
.into()
}
/// Build the filter panel for Media view.
fn media_filter_panel(
filter: &MediaFilter,
locale: UiLocale,
) -> Element<'static, Message> {
fn media_filter_panel(filter: &MediaFilter, locale: UiLocale) -> Element<'static, Message> {
let mut sections: Vec<Element<'static, Message>> = Vec::new();
if !filter.calendar_years.is_empty() {
@@ -561,8 +577,8 @@ fn media_filter_panel(
&filter.calendar_years,
filter.calendar.selected_year,
filter.calendar.selected_month,
|y| Message::SetMediaCalendarYear(y),
|m| Message::SetMediaCalendarMonth(m),
Message::SetMediaCalendarYear,
Message::SetMediaCalendarMonth,
locale,
));
sections.push(Space::with_height(4.0).into());
@@ -573,7 +589,7 @@ fn media_filter_panel(
"sidebar.filter.tags",
&filter.available_tags,
&filter.tag_filter,
|tag| Message::ToggleMediaTagFilter(tag),
Message::ToggleMediaTagFilter,
locale,
));
sections.push(Space::with_height(4.0).into());
@@ -584,18 +600,24 @@ fn media_filter_panel(
button(
text(t(locale, "sidebar.filter.clearAll"))
.size(10)
.shaping(Shaping::Advanced)
.shaping(Shaping::Advanced),
)
.on_press(Message::ClearMediaFilters)
.padding([2, 4])
.style(clear_filters_style)
.into()
.on_press(Message::ClearMediaFilters)
.padding([2, 4])
.style(clear_filters_style)
.into(),
);
}
iced::widget::Column::with_children(sections).spacing(2).into()
iced::widget::Column::with_children(sections)
.spacing(2)
.into()
}
#[expect(
clippy::too_many_arguments,
reason = "arguments are independent sidebar state slices"
)]
pub fn view(
sidebar_view: SidebarView,
posts: &[Post],
@@ -633,10 +655,14 @@ pub fn view(
row![
header,
Space::with_width(Length::Fill),
button(text(t(locale, "common.add")).size(11).shaping(Shaping::Advanced))
.on_press(action)
.padding([3, 8])
.style(filter_button_style),
button(
text(t(locale, "common.add"))
.size(11)
.shaping(Shaping::Advanced)
)
.on_press(action)
.padding([3, 8])
.style(filter_button_style),
]
.align_y(iced::Alignment::Center)
.into()
@@ -652,10 +678,7 @@ pub fn view(
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
// Search input + filter toggle row
let search = text_input(
&t(locale, "sidebar.filter.search"),
&filter.search_query,
)
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
.on_input(Message::PostSearchChanged)
.size(11)
.padding([4, 6])
@@ -673,16 +696,12 @@ pub fn view(
} else {
"\u{25BC}" // ▼ toggle icon
};
let filter_toggle = button(
text(toggle_label).size(11).shaping(Shaping::Advanced)
)
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
.on_press(Message::TogglePostFilterPanel)
.padding([4, 6])
.style(toggle_style);
top_items.push(
row![search, filter_toggle].spacing(4).into()
);
top_items.push(row![search, filter_toggle].spacing(4).into());
// Filter panel (collapsible)
if filter.filter_panel_visible {
@@ -703,7 +722,7 @@ pub fn view(
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
.into(),
);
} else {
let section_header = |label: &str| -> Element<'static, Message> {
@@ -724,7 +743,8 @@ pub fn view(
};
let date = format_post_date(p.created_at);
let prefix_chars: usize = 5;
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
let text_px = width
- SIDEBAR_TEXT_OVERHEAD_PX
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
let display_title = truncate_to_fit(&p.title, text_px);
let label = format!("{icon} {status_indicator} {display_title}");
@@ -736,27 +756,34 @@ pub fn view(
.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 };
let style_fn = if is_active {
item_active_style
} else {
item_style
};
button(
container(column![label_text, date_text].spacing(1))
.width(Length::Fill)
.clip(true)
.clip(true),
)
.on_press(Message::OpenTab(Tab {
id: p.id.clone(),
tab_type: TabType::Post,
title: p.title.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
.on_press(Message::OpenTab(Tab {
id: p.id.clone(),
tab_type: TabType::Post,
title: p.title.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
};
// Draft section
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
let drafts: Vec<&Post> = posts
.iter()
.filter(|p| p.status == bds_core::model::PostStatus::Draft)
.collect();
if !drafts.is_empty() {
top_items.push(section_header(&t(locale, "sidebar.drafts")));
for p in &drafts {
@@ -766,7 +793,10 @@ pub fn view(
}
// Published section
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
let published: Vec<&Post> = posts
.iter()
.filter(|p| p.status == bds_core::model::PostStatus::Published)
.collect();
if !published.is_empty() {
top_items.push(section_header(&t(locale, "sidebar.published")));
for p in &published {
@@ -776,7 +806,10 @@ pub fn view(
}
// Archived section
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
let archived: Vec<&Post> = posts
.iter()
.filter(|p| p.status == bds_core::model::PostStatus::Archived)
.collect();
if !archived.is_empty() {
top_items.push(section_header(&t(locale, "sidebar.archived")));
for p in &archived {
@@ -791,13 +824,13 @@ pub fn view(
button(
text(t(locale, "sidebar.loadMore"))
.size(11)
.shaping(Shaping::Advanced)
.shaping(Shaping::Advanced),
)
.on_press(Message::LoadMorePosts)
.padding([4, 8])
.width(Length::Fill)
.style(filter_button_style)
.into()
.on_press(Message::LoadMorePosts)
.padding([4, 8])
.width(Length::Fill)
.style(filter_button_style)
.into(),
);
}
}
@@ -811,10 +844,7 @@ pub fn view(
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
// Search input + filter toggle row
let search = text_input(
&t(locale, "sidebar.filter.search"),
&filter.search_query,
)
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
.on_input(Message::MediaSearchChanged)
.size(11)
.padding([4, 6])
@@ -832,16 +862,12 @@ pub fn view(
} else {
"\u{25BC}" // ▼ toggle icon
};
let filter_toggle = button(
text(toggle_label).size(11).shaping(Shaping::Advanced)
)
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
.on_press(Message::ToggleMediaFilterPanel)
.padding([4, 6])
.style(toggle_style);
top_items.push(
row![search, filter_toggle].spacing(4).into()
);
top_items.push(row![search, filter_toggle].spacing(4).into());
// Filter panel (collapsible)
if filter.filter_panel_visible {
@@ -861,7 +887,7 @@ pub fn view(
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
.into(),
);
} else {
let items: Vec<Element<'static, Message>> = media
@@ -874,36 +900,38 @@ pub fn view(
};
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
let display_name = truncate_to_fit(&display_name, text_px);
let style_fn = if is_active { item_active_style } else { item_style };
let style_fn = if is_active {
item_active_style
} else {
item_style
};
// Left: 40x40 thumbnail or file icon (pre-resolved, no filesystem I/O)
let thumb_path = media_thumbs
.get(&m.id)
.and_then(|opt| opt.as_ref());
let thumb_path = media_thumbs.get(&m.id).and_then(|opt| opt.as_ref());
let left: Element<'static, Message> = if let Some(path) = thumb_path {
container(
image(path.to_string_lossy().to_string())
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.content_fit(iced::ContentFit::Cover)
.content_fit(iced::ContentFit::Cover),
)
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.clip(true)
.style(thumbnail_container_style)
.into()
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.clip(true)
.style(thumbnail_container_style)
.into()
} else {
container(
text("\u{1F4C4}") // 📄 generic file icon
.size(20)
.shaping(Shaping::Advanced)
.shaping(Shaping::Advanced),
)
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.style(thumbnail_container_style)
.into()
.width(Length::Fixed(40.0))
.height(Length::Fixed(40.0))
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.style(thumbnail_container_style)
.into()
};
// Right column: name + metadata line
@@ -924,13 +952,11 @@ pub fn view(
let right = column![name_text, meta_text].spacing(1);
let content = row![left, right].spacing(8).align_y(iced::Alignment::Center);
let content = row![left, right]
.spacing(8)
.align_y(iced::Alignment::Center);
button(
container(content)
.width(Length::Fill)
.clip(true)
)
button(container(content).width(Length::Fill).clip(true))
.on_press(Message::OpenTab(Tab {
id: m.id.clone(),
tab_type: TabType::Media,
@@ -953,13 +979,13 @@ pub fn view(
button(
text(t(locale, "sidebar.loadMore"))
.size(11)
.shaping(Shaping::Advanced)
.shaping(Shaping::Advanced),
)
.on_press(Message::LoadMoreMedia)
.padding([4, 8])
.width(Length::Fill)
.style(filter_button_style)
.into()
.on_press(Message::LoadMoreMedia)
.padding([4, 8])
.width(Length::Fill)
.style(filter_button_style)
.into(),
);
}
}
@@ -986,12 +1012,12 @@ pub fn view(
.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)
)
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,
@@ -1005,9 +1031,7 @@ pub fn view(
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
iced::widget::Column::with_children(items).spacing(1).into()
}
}
SidebarView::Templates => {
@@ -1028,12 +1052,12 @@ pub fn view(
.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)
)
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,
@@ -1047,9 +1071,7 @@ pub fn view(
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
iced::widget::Column::with_children(items).spacing(1).into()
}
}
SidebarView::Settings => {
@@ -1070,9 +1092,7 @@ pub fn view(
.iter()
.map(|(key, section_opt)| {
let label = t(locale, key);
let label_text = text(label)
.size(12)
.shaping(Shaping::Advanced);
let label_text = text(label).size(12).shaping(Shaping::Advanced);
let msg = if let Some(section) = section_opt {
Message::OpenSettingsSection(section.clone())
} else {
@@ -1084,10 +1104,7 @@ pub fn view(
is_dirty: false,
})
};
button(
container(label_text)
.width(Length::Fill)
)
button(container(label_text).width(Length::Fill))
.on_press(msg)
.padding([3, 6])
.width(Length::Fill)
@@ -1095,28 +1112,17 @@ pub fn view(
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
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 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)
)
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,
@@ -1130,26 +1136,18 @@ pub fn view(
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
}
_ => {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
iced::widget::Column::with_children(items).spacing(1).into()
}
_ => text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into(),
};
let content = column![
header_row,
Space::with_height(8.0),
body,
]
.spacing(4)
.padding(12);
let content = column![header_row, Space::with_height(8.0), body,]
.spacing(4)
.padding(12);
// layout.allium: sidebar width is resizable, passed as parameter
container(scrollable(content))

View File

@@ -20,19 +20,41 @@ pub struct SiteValidationState {
pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, Message> {
let run_button = if state.is_running {
button(text(t(locale, "siteValidation.running")).size(13).shaping(Shaping::Advanced))
button(
text(t(locale, "siteValidation.running"))
.size(13)
.shaping(Shaping::Advanced),
)
} else {
button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced))
.on_press(Message::RunSiteValidation)
button(
text(t(locale, "siteValidation.run"))
.size(13)
.shaping(Shaping::Advanced),
)
.on_press(Message::RunSiteValidation)
};
let has_issues = !state.missing_files.is_empty() || !state.extra_files.is_empty() || !state.stale_files.is_empty();
let has_issues = !state.missing_files.is_empty()
|| !state.extra_files.is_empty()
|| !state.stale_files.is_empty();
let apply_button = if state.is_applying {
button(text(t(locale, "siteValidation.applying")).size(13).shaping(Shaping::Advanced))
button(
text(t(locale, "siteValidation.applying"))
.size(13)
.shaping(Shaping::Advanced),
)
} else if !state.is_running && state.error_message.is_none() && has_issues {
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
.on_press(Message::ApplySiteValidation)
button(
text(t(locale, "siteValidation.apply"))
.size(13)
.shaping(Shaping::Advanced),
)
.on_press(Message::ApplySiteValidation)
} else {
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
button(
text(t(locale, "siteValidation.apply"))
.size(13)
.shaping(Shaping::Advanced),
)
};
let mut content = column![
@@ -57,26 +79,41 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
));
} else if !state.has_run {
content = content.push(help_text(t(locale, "siteValidation.idle")));
} else if state.missing_files.is_empty() && state.extra_files.is_empty() && state.stale_files.is_empty() {
} else if state.missing_files.is_empty()
&& state.extra_files.is_empty()
&& state.stale_files.is_empty()
{
content = content.push(help_text(t(locale, "siteValidation.clean")));
} else {
if !state.missing_files.is_empty() {
content = content.push(section(
format!("{} ({})", t(locale, "siteValidation.missing"), state.missing_files.len()),
format!(
"{} ({})",
t(locale, "siteValidation.missing"),
state.missing_files.len()
),
&state.missing_files,
Color::from_rgb(0.70, 0.25, 0.25),
));
}
if !state.extra_files.is_empty() {
content = content.push(section(
format!("{} ({})", t(locale, "siteValidation.extra"), state.extra_files.len()),
format!(
"{} ({})",
t(locale, "siteValidation.extra"),
state.extra_files.len()
),
&state.extra_files,
Color::from_rgb(0.68, 0.48, 0.18),
));
}
if !state.stale_files.is_empty() {
content = content.push(section(
format!("{} ({})", t(locale, "siteValidation.stale"), state.stale_files.len()),
format!(
"{} ({})",
t(locale, "siteValidation.stale"),
state.stale_files.len()
),
&state.stale_files,
Color::from_rgb(0.62, 0.32, 0.18),
));
@@ -135,4 +172,4 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'
..container::Style::default()
})
.into()
}
}

View File

@@ -1,5 +1,5 @@
use iced::widget::{button, container, row, text, Space};
use iced::widget::text::Shaping;
use iced::widget::{Space, button, container, row, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -110,6 +110,10 @@ pub fn dropdown_bg(_theme: &Theme) -> container::Style {
// View
// ---------------------------------------------------------------------------
#[expect(
clippy::too_many_arguments,
reason = "arguments are independent status values"
)]
pub fn view(
active_project_name: Option<&str>,
post_count: usize,
@@ -132,12 +136,11 @@ pub fn view(
.collect();
let task_indicator: Element<'static, Message> = if !running.is_empty() {
let first = &running[0];
let progress_str = first.progress
let progress_str = first
.progress
.map(|p| format!(" {:.0}%", p * 100.0))
.unwrap_or_default();
let phase_str = first.message
.as_deref()
.unwrap_or("");
let phase_str = first.message.as_deref().unwrap_or("");
let extra = if running.len() > 1 {
format!(" (+{})", running.len() - 1)
} else {
@@ -148,7 +151,11 @@ pub fn view(
} else {
format!("{phase_str}{progress_str}{extra}")
};
text(display).size(11).shaping(Shaping::Advanced).color(label_color).into()
text(display)
.size(11)
.shaping(Shaping::Advanced)
.color(label_color)
.into()
} else {
Space::with_width(0).into()
};
@@ -162,27 +169,41 @@ pub fn view(
// Post status indicator dot (per layout.allium StatusBarRight.post_status)
let post_status_el: Element<'static, Message> = if let Some(status) = active_post_status {
let (dot, color) = match status {
"draft" => ("\u{25CB}", Color::from_rgb(0.60, 0.60, 0.65)), // hollow circle
"published" => ("\u{25CF}", Color::from_rgb(0.30, 0.75, 0.40)), // green filled
"archived" => ("\u{25A0}", Color::from_rgb(0.60, 0.60, 0.65)), // square
"draft" => ("\u{25CB}", Color::from_rgb(0.60, 0.60, 0.65)), // hollow circle
"published" => ("\u{25CF}", Color::from_rgb(0.30, 0.75, 0.40)), // green filled
"archived" => ("\u{25A0}", Color::from_rgb(0.60, 0.60, 0.65)), // square
_ => ("\u{25CF}", Color::from_rgb(0.60, 0.60, 0.65)),
};
text(dot).size(11).shaping(Shaping::Advanced).color(color).into()
text(dot)
.size(11)
.shaping(Shaping::Advanced)
.color(color)
.into()
} else {
Space::with_width(0).into()
};
// Post + media counts
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 posts_label = tw(
locale,
"statusBar.posts",
&[("count", &post_count.to_string())],
);
let media_label = tw(
locale,
"statusBar.media",
&[("count", &media_count.to_string())],
);
// Airplane mode toggle — ✈ icon
let airplane_btn = button(
text("\u{2708}").size(13).shaping(Shaping::Advanced),
)
let airplane_btn = button(text("\u{2708}").size(13).shaping(Shaping::Advanced))
.on_press(Message::SetOfflineMode(!offline_mode))
.padding([2, 4])
.style(if offline_mode { airplane_active } else { airplane_inactive });
.style(if offline_mode {
airplane_active
} else {
airplane_inactive
});
// Language selector — current flag as trigger
let trigger_flag = text(locale.flag_emoji())
@@ -196,24 +217,32 @@ pub fn view(
let right = row![
post_status_el,
text(posts_label).size(11).shaping(Shaping::Advanced).color(label_color),
text(media_label).size(11).shaping(Shaping::Advanced).color(label_color),
text(theme_badge.to_string()).size(11).shaping(Shaping::Advanced).color(label_color),
text(posts_label)
.size(11)
.shaping(Shaping::Advanced)
.color(label_color),
text(media_label)
.size(11)
.shaping(Shaping::Advanced)
.color(label_color),
text(theme_badge.to_string())
.size(11)
.shaping(Shaping::Advanced)
.color(label_color),
airplane_btn,
locale_trigger,
text("bDS").size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.45, 0.45, 0.50)),
text("bDS")
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.45, 0.45, 0.50)),
]
.spacing(8)
.align_y(Alignment::Center);
container(
row![
left,
Space::with_width(Length::Fill),
right,
]
.align_y(Alignment::Center)
.padding([0, 8]),
row![left, Space::with_width(Length::Fill), right,]
.align_y(Alignment::Center)
.padding([0, 8]),
)
.width(Length::Fill)
.height(Length::Fixed(24.0))

View File

@@ -1,7 +1,7 @@
use iced::widget::{button, container, row, scrollable, text, tooltip, Space};
use iced::widget::scrollable::Direction;
use iced::widget::text::Shaping;
use iced::widget::tooltip::Position;
use iced::widget::{Space, button, container, row, scrollable, text, tooltip};
use iced::{Background, Border, Color, Element, Font, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -106,7 +106,10 @@ fn truncate_chat_title(title: &str) -> String {
/// Uses "…" (Unicode ellipsis) as the truncation indicator.
fn truncate_tab_title(title: &str) -> String {
if title.chars().count() > TAB_TITLE_MAX_LEN {
let truncated: String = title.chars().take(TAB_TITLE_MAX_LEN.saturating_sub(1)).collect();
let truncated: String = title
.chars()
.take(TAB_TITLE_MAX_LEN.saturating_sub(1))
.collect();
format!("{truncated}\u{2026}")
} else {
title.to_string()
@@ -129,11 +132,7 @@ fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String {
tip
}
pub fn view(
tabs: &[Tab],
active_tab: Option<&str>,
locale: UiLocale,
) -> Element<'static, Message> {
pub fn view(tabs: &[Tab], active_tab: Option<&str>, locale: UiLocale) -> Element<'static, Message> {
// Per tabs.allium: "Hidden when no tabs exist."
if tabs.is_empty() {
return Space::with_height(0).into();
@@ -159,7 +158,10 @@ pub fn view(
text(display_title)
.size(12)
.shaping(Shaping::Advanced)
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
.font(Font {
style: iced::font::Style::Italic,
..Font::DEFAULT
})
.wrapping(iced::widget::text::Wrapping::None)
} else {
text(display_title)
@@ -170,7 +172,9 @@ pub fn view(
// Per tabs.allium DirtyIndicator: dot for dirty post tabs
let dirty_indicator = if tab.is_dirty {
text(" \u{25CF}").size(10).shaping(Shaping::Advanced)
text(" \u{25CF}")
.size(10)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.90, 0.70, 0.30))
} else {
text("").size(10)
@@ -180,7 +184,7 @@ pub fn view(
let title_area = container(
row![title_label, dirty_indicator]
.spacing(2)
.align_y(iced::Alignment::Center)
.align_y(iced::Alignment::Center),
)
.width(Length::Fill)
.clip(true);

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::widget::{Space, button, column, container, row, scrollable, text, text_input};
use iced::{Alignment, Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -12,9 +12,9 @@ use crate::i18n::{t, tw};
const DEFAULT_TAG_COLOR: &str = "#6495ed";
const COLOR_PRESETS: [&str; 17] = [
"#e63946", "#f77f00", "#fcbf49", "#90be6d", "#43aa8b", "#4d908e",
"#577590", "#277da1", "#4361ee", "#7209b7", "#b5179e", "#f72585",
"#9c6644", "#6c757d", "#adb5bd", "#2a9d8f", "#264653",
"#e63946", "#f77f00", "#fcbf49", "#90be6d", "#43aa8b", "#4d908e", "#577590", "#277da1",
"#4361ee", "#7209b7", "#b5179e", "#f72585", "#9c6644", "#6c757d", "#adb5bd", "#2a9d8f",
"#264653",
];
/// Active view within the Tags tab.
@@ -118,10 +118,26 @@ pub enum TagsMsg {
/// Render the tags management view.
pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let section_nav = row![
section_tab(&t(locale, "tags.nav.cloud"), state.section == TagsSection::Cloud, TagsSection::Cloud),
section_tab(&t(locale, "tags.nav.manage"), state.section == TagsSection::Manage, TagsSection::Manage),
section_tab(&t(locale, "tags.nav.merge"), state.section == TagsSection::Merge, TagsSection::Merge),
section_tab(&t(locale, "tags.nav.discover"), state.section == TagsSection::Discover, TagsSection::Discover),
section_tab(
&t(locale, "tags.nav.cloud"),
state.section == TagsSection::Cloud,
TagsSection::Cloud
),
section_tab(
&t(locale, "tags.nav.manage"),
state.section == TagsSection::Manage,
TagsSection::Manage
),
section_tab(
&t(locale, "tags.nav.merge"),
state.section == TagsSection::Merge,
TagsSection::Merge
),
section_tab(
&t(locale, "tags.nav.discover"),
state.section == TagsSection::Discover,
TagsSection::Discover
),
]
.spacing(4)
.padding(8);
@@ -168,7 +184,9 @@ fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'
fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
if state.tags.is_empty() {
return container(
text(t(locale, "tags.noTags")).size(14).color(Color::from_rgb(0.5, 0.5, 0.5)),
text(t(locale, "tags.noTags"))
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fill)
@@ -180,7 +198,12 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
let counts: Vec<usize> = state
.tags
.iter()
.map(|tag| *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0))
.map(|tag| {
*state
.tag_post_counts
.get(&tag.name.to_lowercase())
.unwrap_or(&0)
})
.collect();
let min_count = *counts.iter().min().unwrap_or(&0);
let max_count = *counts.iter().max().unwrap_or(&1);
@@ -197,14 +220,23 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.iter()
.map(|tag| {
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
let post_count = *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0);
let font_size = MIN_FONT + ((post_count - min_count) as f32 / count_range) * (MAX_FONT - MIN_FONT);
let post_count = *state
.tag_post_counts
.get(&tag.name.to_lowercase())
.unwrap_or(&0);
let font_size =
MIN_FONT + ((post_count - min_count) as f32 / count_range) * (MAX_FONT - MIN_FONT);
let vert_pad = ((MAX_FONT - font_size) / 4.0).max(2.0) as u16;
let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
let selected = state
.selected_tags
.iter()
.any(|selected_id| selected_id == &tag.id);
button(
row![
text(&tag.name).size(font_size).color(Color::WHITE),
text(post_count.to_string()).size(11).color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)),
text(post_count.to_string())
.size(11)
.color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)),
]
.spacing(6)
.align_y(Alignment::Center),
@@ -216,7 +248,11 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
border: iced::Border {
radius: 12.0.into(),
width: if selected { 2.0 } else { 0.0 },
color: if selected { Color::WHITE } else { Color::TRANSPARENT },
color: if selected {
Color::WHITE
} else {
Color::TRANSPARENT
},
},
text_color: Color::WHITE,
..button::Style::default()
@@ -226,12 +262,19 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.collect();
let selection_summary: Element<'a, Message> = if state.selected_tags.is_empty() {
text(t(locale, "tags.cloudHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)).into()
text(t(locale, "tags.cloudHelp"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65))
.into()
} else {
row![
text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
text(tw(
locale,
"tags.selectedCount",
&[("count", &state.selected_tags.len().to_string())]
))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
button(text(t(locale, "tags.clearSelection")).size(12))
.on_press(Message::Tags(TagsMsg::ClearSelection))
.padding([4, 8]),
@@ -273,7 +316,9 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
),
color_swatches(locale, true),
button(text(t(locale, "tags.createButton")).size(13))
.on_press_maybe((!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag)))
.on_press_maybe(
(!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag))
)
.style(inputs::primary_button)
.padding([6, 16]),
]
@@ -288,7 +333,10 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
.iter()
.filter(|tag| {
state.search_query.is_empty()
|| tag.name.to_lowercase().contains(&state.search_query.to_lowercase())
|| tag
.name
.to_lowercase()
.contains(&state.search_query.to_lowercase())
})
.collect();
@@ -296,8 +344,15 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
.iter()
.map(|tag| {
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
let count = state.tag_post_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0);
let selected = state
.selected_tags
.iter()
.any(|selected_id| selected_id == &tag.id);
let count = state
.tag_post_counts
.get(&tag.name.to_lowercase())
.copied()
.unwrap_or(0);
button(
row![
container(Space::new(12, 12)).style(move |_: &Theme| container::Style {
@@ -309,7 +364,9 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
..container::Style::default()
}),
text(&tag.name).size(14),
text(format!("({count})")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
text(format!("({count})"))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
Space::with_width(Length::Fill),
]
.spacing(8)
@@ -338,21 +395,21 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
let edit_panel: Element<'a, Message> = if let Some(ref editing) = state.editing_tag {
let template_options = template_options(state, locale);
let selected_template = template_options.iter().find(|option| option.slug == editing.template_slug);
let delete_button: Element<'a, Message> = button(text(t(locale, "modal.confirmDelete.delete")).size(13))
.on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone())))
.style(inputs::danger_button)
.padding([6, 16])
.into();
let selected_template = template_options
.iter()
.find(|option| option.slug == editing.template_slug);
let delete_button: Element<'a, Message> =
button(text(t(locale, "modal.confirmDelete.delete")).size(13))
.on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone())))
.style(inputs::danger_button)
.padding([6, 16])
.into();
column![
inputs::section_header(&t(locale, "tags.editTag")),
inputs::labeled_input(
&t(locale, "tags.name"),
"",
&editing.name,
|value| Message::Tags(TagsMsg::EditTagName(value)),
),
inputs::labeled_input(&t(locale, "tags.name"), "", &editing.name, |value| {
Message::Tags(TagsMsg::EditTagName(value))
},),
inputs::labeled_input(
&t(locale, "tags.color"),
DEFAULT_TAG_COLOR,
@@ -378,9 +435,13 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
.spacing(8)
.into()
} else {
container(text(t(locale, "tags.selectTag")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
.padding([8, 0])
.into()
container(
text(t(locale, "tags.selectTag"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
)
.padding([8, 0])
.into()
};
scrollable(
@@ -402,9 +463,13 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
if state.selected_tags.len() < 2 {
return container(text(t(locale, "tags.mergeHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
.padding(16)
.into();
return container(
text(t(locale, "tags.mergeHelp"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
)
.padding(16)
.into();
}
let tag_options = selected_tag_options(state);
@@ -428,9 +493,13 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
column![
inputs::section_header(&t(locale, "tags.mergeSection")),
text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
text(tw(
locale,
"tags.selectedCount",
&[("count", &state.selected_tags.len().to_string())]
))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
inputs::labeled_select(
&t(locale, "tags.mergeTarget"),
&tag_options,
@@ -445,7 +514,12 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.into()
},
button(text(t(locale, "tags.merge")).size(13))
.on_press_maybe(state.merge_target.is_some().then_some(Message::Tags(TagsMsg::MergeTags)))
.on_press_maybe(
state
.merge_target
.is_some()
.then_some(Message::Tags(TagsMsg::MergeTags))
)
.style(inputs::primary_button)
.padding([6, 16]),
]
@@ -489,10 +563,14 @@ fn selected_tag_options(state: &TagsViewState) -> Vec<TagOption> {
.selected_tags
.iter()
.filter_map(|tag_id| {
state.tags.iter().find(|tag| &tag.id == tag_id).map(|tag| TagOption {
id: tag.id.clone(),
name: tag.name.clone(),
})
state
.tags
.iter()
.find(|tag| &tag.id == tag_id)
.map(|tag| TagOption {
id: tag.id.clone(),
name: tag.name.clone(),
})
})
.collect()
}
@@ -523,7 +601,9 @@ fn color_swatches<'a>(locale: UiLocale, create_mode: bool) -> Element<'a, Messag
.collect();
column![
text(t(locale, "tags.colorPresets")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
text(t(locale, "tags.colorPresets"))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
row(buttons).spacing(6).wrap(),
]
.spacing(6)
@@ -540,4 +620,4 @@ fn parse_tag_color(hex: &str) -> Color {
} else {
Color::from_rgb(0.4, 0.5, 0.7)
}
}
}

View File

@@ -1,6 +1,6 @@
use std::cell::RefCell;
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -107,10 +107,7 @@ pub enum TemplateEditorMsg {
}
/// Render the template editor view.
pub fn view<'a>(
state: &'a TemplateEditorState,
locale: UiLocale,
) -> Element<'a, Message> {
pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a, Message> {
let header = inputs::toolbar(
vec![
text(state.title.clone()).size(18).into(),
@@ -147,7 +144,9 @@ pub fn view<'a>(
&state.slug,
|s| Message::TemplateEditor(TemplateEditorMsg::SlugChanged(s)),
);
let meta_row1 = row![title_input, slug_input].spacing(16).width(Length::Fill);
let meta_row1 = row![title_input, slug_input]
.spacing(16)
.width(Length::Fill);
// Metadata row 2: kind (select), enabled (checkbox)
let kind_options = vec![
@@ -163,25 +162,27 @@ pub fn view<'a>(
selected_kind.as_ref(),
|k| Message::TemplateEditor(TemplateEditorMsg::KindChanged(k)),
);
let enabled_check = inputs::labeled_checkbox(
&t(locale, "editor.enabled"),
state.enabled,
|b| Message::TemplateEditor(TemplateEditorMsg::EnabledChanged(b)),
);
let meta_row2 = row![kind_select, enabled_check].spacing(16).width(Length::Fill);
let enabled_check =
inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
Message::TemplateEditor(TemplateEditorMsg::EnabledChanged(b))
});
let meta_row2 = row![kind_select, enabled_check]
.spacing(16)
.width(Length::Fill);
// Content editor (CodeEditor with Liquid/HTML syntax highlighting)
let content_section: Element<'a, Message> = column![
inputs::section_header(&t(locale, "editor.content")),
Element::from(
CodeEditor::new(
&state.editor_buffer,
highlighter(),
"liquid",
)
.on_change(|msg| match msg {
EditorMessage::ContentChanged(s) => Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s)),
EditorMessage::SaveRequested => Message::TemplateEditor(TemplateEditorMsg::Save),
CodeEditor::new(&state.editor_buffer, highlighter(), "liquid",).on_change(|msg| {
match msg {
EditorMessage::ContentChanged(s) => {
Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s))
}
EditorMessage::SaveRequested => {
Message::TemplateEditor(TemplateEditorMsg::Save)
}
}
})
),
]
@@ -213,29 +214,20 @@ pub fn view<'a>(
// Top pane: header + metadata (scrollable for overflow)
let top_pane = scrollable(
column![
header,
meta_row1,
meta_row2,
]
.spacing(12)
.padding(16)
.width(Length::Fill)
column![header, meta_row1, meta_row2,]
.spacing(12)
.padding(16)
.width(Length::Fill),
)
.height(Length::Shrink);
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
column![
top_pane,
content_section,
validation,
footer,
]
.spacing(4)
.padding([0, 16])
.width(Length::Fill)
.height(Length::Fill)
.into()
column![top_pane, content_section, validation, footer,]
.spacing(4)
.padding([0, 16])
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn status_badge<'a>(status: &TemplateStatus) -> Element<'a, Message> {

View File

@@ -1,5 +1,5 @@
use iced::widget::{button, container, row, text, Space};
use iced::widget::text::Shaping;
use iced::widget::{Space, button, container, row, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
use crate::app::Message;
@@ -95,7 +95,12 @@ pub fn view(toasts: &[Toast]) -> Option<Element<'static, Message>> {
)
.width(Length::Fill)
.align_x(Alignment::Center)
.padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0 }),
.padding(Padding {
top: 8.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
}),
)
.width(Length::Fill)
.height(Length::Shrink)

View File

@@ -1,5 +1,5 @@
use iced::widget::{column, container, text};
use iced::widget::text::Shaping;
use iced::widget::{column, container, text};
use iced::{Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -37,11 +37,12 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
}
pub fn tab_placeholder(
locale: UiLocale,
title: impl Into<String>,
subtitle: Option<String>,
) -> Element<'static, Message> {
let title = title.into();
let subtitle = subtitle.unwrap_or_else(|| "This tab is routed correctly, but its content surface is not implemented yet.".to_string());
let subtitle = subtitle.unwrap_or_else(|| t(locale, "common.tabNotImplemented"));
container(
column![

View File

@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use iced::widget::text::Shaping;
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
use iced::widget::{Space, button, column, container, mouse_area, row, stack, text};
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
use bds_core::i18n::UiLocale;
@@ -20,10 +20,11 @@ use crate::views::{
modal, panel,
post_editor::{self, PostEditorState},
project_selector,
site_validation::{self, SiteValidationState},
script_editor::{self, ScriptEditorState},
settings_view::{self, SettingsViewState},
sidebar, status_bar, tab_bar,
sidebar,
site_validation::{self, SiteValidationState},
status_bar, tab_bar,
tags_view::{self, TagsViewState},
template_editor::{self, TemplateEditorState},
toast, welcome,
@@ -58,6 +59,10 @@ fn separator_h<'a>() -> Element<'a, Message> {
}
/// Compose the full workspace layout.
#[expect(
clippy::too_many_arguments,
reason = "root Iced view receives the application state explicitly"
)]
pub fn view<'a>(
// Navigation
sidebar_view: SidebarView,
@@ -344,6 +349,10 @@ pub fn view<'a>(
}
/// Route the content area based on the active tab type.
#[expect(
clippy::too_many_arguments,
reason = "router receives the state required by each routed view"
)]
fn route_content_area<'a>(
tabs: &'a [Tab],
active_tab: Option<&'a str>,
@@ -378,14 +387,25 @@ fn route_content_area<'a>(
ContentRoute::Post(tab_id) => {
if let Some(state) = post_editors.get(tab_id) {
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
post_editor::view(state, locale, wrap, is_ai_enabled(settings_state, offline_mode), post_preview_widget)
post_editor::view(
state,
locale,
wrap,
is_ai_enabled(settings_state, offline_mode),
post_preview_widget,
)
} else {
loading_view(locale)
}
}
ContentRoute::Media(tab_id) => {
if let Some(state) = media_editors.get(tab_id) {
media_editor::view(state, locale, data_dir, is_ai_enabled(settings_state, offline_mode))
media_editor::view(
state,
locale,
data_dir,
is_ai_enabled(settings_state, offline_mode),
)
} else {
loading_view(locale)
}
@@ -419,7 +439,7 @@ fn route_content_area<'a>(
}
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
}
}
@@ -438,6 +458,10 @@ enum ContentRoute<'a> {
Placeholder(&'a str),
}
#[expect(
clippy::too_many_arguments,
reason = "route selection checks independent loaded-state collections"
)]
fn route_kind<'a>(
tabs: &'a [Tab],
active_tab: Option<&'a str>,
@@ -451,30 +475,58 @@ fn route_kind<'a>(
_site_validation_state: &'a SiteValidationState,
) -> ContentRoute<'a> {
let Some(tab_id) = active_tab else {
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
return dashboard_state
.map(ContentRoute::Dashboard)
.unwrap_or(ContentRoute::Welcome);
};
let Some(tab) = tabs.iter().find(|t| t.id == tab_id) else {
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
return dashboard_state
.map(ContentRoute::Dashboard)
.unwrap_or(ContentRoute::Welcome);
};
match tab.tab_type {
TabType::Post => {
if post_editors.contains_key(tab_id) { ContentRoute::Post(tab_id) } else { ContentRoute::Loading }
if post_editors.contains_key(tab_id) {
ContentRoute::Post(tab_id)
} else {
ContentRoute::Loading
}
}
TabType::Media => {
if media_editors.contains_key(tab_id) { ContentRoute::Media(tab_id) } else { ContentRoute::Loading }
if media_editors.contains_key(tab_id) {
ContentRoute::Media(tab_id)
} else {
ContentRoute::Loading
}
}
TabType::Templates => {
if template_editors.contains_key(tab_id) { ContentRoute::Templates(tab_id) } else { ContentRoute::Loading }
if template_editors.contains_key(tab_id) {
ContentRoute::Templates(tab_id)
} else {
ContentRoute::Loading
}
}
TabType::Scripts => {
if script_editors.contains_key(tab_id) { ContentRoute::Scripts(tab_id) } else { ContentRoute::Loading }
if script_editors.contains_key(tab_id) {
ContentRoute::Scripts(tab_id)
} else {
ContentRoute::Loading
}
}
TabType::Tags => {
if tags_view_state.is_some() { ContentRoute::Tags } else { ContentRoute::Loading }
if tags_view_state.is_some() {
ContentRoute::Tags
} else {
ContentRoute::Loading
}
}
TabType::Settings => {
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
if settings_state.is_some() {
ContentRoute::Settings
} else {
ContentRoute::Loading
}
}
TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::Style
@@ -496,7 +548,8 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool)
};
if offline_mode {
!state.airplane_endpoint_url.trim().is_empty() && !state.airplane_endpoint_model.trim().is_empty()
!state.airplane_endpoint_url.trim().is_empty()
&& !state.airplane_endpoint_model.trim().is_empty()
} else {
!state.online_endpoint_url.trim().is_empty()
&& !state.online_endpoint_model.trim().is_empty()
@@ -504,6 +557,20 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool)
}
}
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
use crate::i18n::t;
container(
text(t(locale, "tabBar.loading"))
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -593,7 +660,11 @@ mod tests {
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let site_validation_state = SiteValidationState::default();
let tabs = vec![tab("site_validation", TabType::SiteValidation, "Validation")];
let tabs = vec![tab(
"site_validation",
TabType::SiteValidation,
"Validation",
)];
let route = route_kind(
&tabs,
@@ -631,17 +702,3 @@ mod tests {
assert!(is_ai_enabled(Some(&settings), true));
}
}
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
use crate::i18n::t;
container(
text(t(locale, "tabBar.loading"))
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}