Implement the Pico CSS theme editor.

This commit is contained in:
2026-07-20 14:28:47 +02:00
parent 1d190d33c5
commit fe7dd82f33
20 changed files with 1033 additions and 161 deletions

View File

@@ -51,6 +51,7 @@ use crate::views::{
default_category_rows,
},
site_validation::SiteValidationState,
style_view::{StyleMsg, StyleViewState},
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
template_editor::{TemplateEditorMsg, TemplateEditorState},
workspace,
@@ -164,6 +165,7 @@ pub enum Message {
},
MainWindowLoaded(Option<window::Id>),
EmbeddedPreviewReady(Result<(), String>),
EmbeddedStylePreviewReady(Result<(), String>),
// Panel
SetPanelTab(PanelTab),
@@ -327,6 +329,7 @@ pub enum Message {
ScriptEditor(ScriptEditorMsg),
Tags(TagsMsg),
Settings(SettingsMsg),
Style(StyleMsg),
ImportEditor(ImportEditorMsg),
MenuEditor(MenuEditorMsg),
@@ -954,6 +957,7 @@ pub struct BdsApp {
preview_session: Option<PreviewSession>,
mcp_server: Option<engine::mcp::McpHttpServer>,
embedded_preview: Option<EmbeddedPreviewState>,
embedded_style_preview: Option<EmbeddedPreviewState>,
main_window_id: Option<window::Id>,
// Editor states (keyed by entity id)
@@ -965,6 +969,7 @@ pub struct BdsApp {
chat_editors: HashMap<String, ChatEditorState>,
tags_view_state: Option<TagsViewState>,
settings_state: Option<SettingsViewState>,
style_view_state: Option<StyleViewState>,
dashboard_state: Option<DashboardState>,
site_validation_state: SiteValidationState,
duplicates_state: DuplicatesState,
@@ -1135,6 +1140,7 @@ impl BdsApp {
preview_session: None,
mcp_server,
embedded_preview: None,
embedded_style_preview: None,
main_window_id: None,
post_editors: HashMap::new(),
media_editors: HashMap::new(),
@@ -1144,6 +1150,7 @@ impl BdsApp {
chat_editors: HashMap::new(),
tags_view_state: None,
settings_state: None,
style_view_state: None,
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
@@ -1227,6 +1234,7 @@ impl BdsApp {
preview_session: None,
mcp_server: None,
embedded_preview: None,
embedded_style_preview: None,
main_window_id: None,
post_editors: HashMap::new(),
media_editors: HashMap::new(),
@@ -1236,6 +1244,7 @@ impl BdsApp {
chat_editors: HashMap::new(),
tags_view_state: None,
settings_state: None,
style_view_state: None,
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
@@ -1582,7 +1591,7 @@ impl BdsApp {
}
self.enforce_panel_tab_fallback();
self.sync_menu_state();
let mut tasks = vec![self.sync_embedded_preview_for_active_post()];
let mut tasks = vec![self.sync_embedded_previews()];
if let Some(post_id) = semantic_post_id {
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
}
@@ -1600,7 +1609,7 @@ impl BdsApp {
self.git_diffs.remove(&id);
self.enforce_panel_tab_fallback();
self.sync_menu_state();
self.sync_embedded_preview_for_active_post()
self.sync_embedded_previews()
}
Message::SelectTab(id) => {
if self.tabs.iter().any(|t| t.id == id) {
@@ -1610,7 +1619,7 @@ impl BdsApp {
self.active_tab = Some(id);
}
self.enforce_panel_tab_fallback();
self.sync_embedded_preview_for_active_post()
self.sync_embedded_previews()
}
Message::PinTab(id) => {
tabs::pin_tab(&mut self.tabs, &id);
@@ -1622,6 +1631,7 @@ impl BdsApp {
self.active_tab = None;
self.git_diffs.clear();
self.hide_embedded_preview();
self.hide_embedded_style_preview();
Task::none()
}
@@ -1692,6 +1702,8 @@ impl BdsApp {
self.preview_session = None;
self.duplicates_state = DuplicatesState::default();
self.hide_embedded_preview();
self.hide_embedded_style_preview();
self.style_view_state = None;
self.data_dir = self
.active_project
.as_ref()
@@ -1710,6 +1722,9 @@ impl BdsApp {
}
}
}
if self.tabs.iter().any(|tab| tab.tab_type == TabType::Style) {
self.style_view_state = self.hydrate_style_state();
}
let name = self
.active_project
.as_ref()
@@ -1737,6 +1752,7 @@ impl BdsApp {
self.refresh_counts(),
self.refresh_git_if_visible(),
Task::done(Message::EmbeddingBackfill),
self.sync_embedded_previews(),
])
}
Message::ProjectSwitched(result) => {
@@ -1862,6 +1878,8 @@ impl BdsApp {
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
self.preview_session = None;
self.hide_embedded_preview();
self.hide_embedded_style_preview();
self.style_view_state = None;
self.notify(
ToastLevel::Success,
&tw(
@@ -2374,9 +2392,9 @@ impl BdsApp {
}
Task::none()
}
message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => {
self.handle_preview_message(message)
}
message @ (Message::MainWindowLoaded(_)
| Message::EmbeddedPreviewReady(_)
| Message::EmbeddedStylePreviewReady(_)) => self.handle_preview_message(message),
// ── Tasks ──
Message::TaskTick => {
@@ -2481,6 +2499,8 @@ impl BdsApp {
self.data_dir = Some(data_dir.clone());
self.preview_session = None;
self.hide_embedded_preview();
self.hide_embedded_style_preview();
self.style_view_state = None;
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
let main_language = meta.main_language.unwrap_or_else(|| "en".into());
self.content_language = main_language.clone();
@@ -3031,6 +3051,7 @@ impl BdsApp {
Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg),
Message::Tags(msg) => self.handle_tags_msg(msg),
Message::Settings(msg) => self.handle_settings_msg(msg),
Message::Style(msg) => self.handle_style_msg(msg),
Message::ImportEditor(msg) => self.handle_import_editor_msg(msg),
Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg),
@@ -3380,6 +3401,13 @@ impl BdsApp {
} else {
None
};
let style_preview_widget = if self.active_style_uses_embedded_preview() {
self.embedded_style_preview
.as_ref()
.map(|preview| webview::webview(&preview.controller).into())
} else {
None
};
native_edit::native_edit(
workspace::view(
@@ -3418,6 +3446,7 @@ impl BdsApp {
self.active_modal.clone(),
self.data_dir.as_deref(),
post_preview_widget,
style_preview_widget,
&self.post_editors,
&self.media_editors,
&self.template_editors,
@@ -3426,6 +3455,7 @@ impl BdsApp {
&self.chat_editors,
self.tags_view_state.as_ref(),
self.settings_state.as_ref(),
self.style_view_state.as_ref(),
self.dashboard_state.as_ref(),
&self.site_validation_state,
&self.duplicates_state,
@@ -3981,6 +4011,15 @@ impl BdsApp {
.and_then(|project| project.data_path.as_deref())
.map(PathBuf::from);
}
if let Some(data_dir) = self.data_dir.as_deref()
&& let Ok(metadata) = engine::meta::read_project_json(data_dir)
{
let applied = StyleViewState::new(metadata.pico_theme.as_deref());
self.theme_badge = applied.applied_theme.clone();
if let Some(state) = self.style_view_state.as_mut() {
state.refresh_applied_theme(metadata.pico_theme.as_deref());
}
}
previous_active.as_deref() == Some(project_id.as_str())
|| self
.active_project
@@ -5323,9 +5362,8 @@ impl BdsApp {
// Read pico theme from project metadata for status bar badge
if let Some(ref data_dir) = self.data_dir
&& let Ok(meta) = engine::meta::read_project_json(data_dir)
&& let Some(theme) = meta.pico_theme
{
self.theme_badge = theme;
self.theme_badge = StyleViewState::new(meta.pico_theme.as_deref()).applied_theme;
}
self.dashboard_state = Some(self.hydrate_dashboard_state());
@@ -7104,6 +7142,64 @@ impl BdsApp {
Task::none()
}
fn hydrate_style_state(&self) -> Option<StyleViewState> {
self.active_project.as_ref()?;
let data_dir = self.data_dir.as_deref()?;
let metadata = engine::meta::read_project_json(data_dir).ok()?;
Some(StyleViewState::new(metadata.pico_theme.as_deref()))
}
fn handle_style_msg(&mut self, message: StyleMsg) -> Task<Message> {
match message {
StyleMsg::SelectTheme(theme) => {
if let Some(state) = self.style_view_state.as_mut() {
state.select_theme(&theme);
}
self.sync_embedded_preview_for_style()
}
StyleMsg::PreviewModeChanged(mode) => {
if let Some(state) = self.style_view_state.as_mut() {
state.set_preview_mode(mode);
}
self.sync_embedded_preview_for_style()
}
StyleMsg::Apply => {
let Some(theme) = self
.style_view_state
.as_ref()
.filter(|state| state.can_apply())
.map(|state| state.selected_theme.clone())
else {
return Task::none();
};
let result = (|| {
let db = self.db.as_ref().ok_or("database unavailable")?;
let data_dir = self
.data_dir
.as_deref()
.ok_or("project data directory unavailable")?;
let project = self.active_project.as_ref().ok_or("project unavailable")?;
let mut metadata = engine::meta::read_project_json(data_dir)
.map_err(|error| error.to_string())?;
metadata.pico_theme = Some(theme.clone());
engine::meta::update_project_metadata(db.conn(), data_dir, project, &metadata)
.map_err(|error| error.to_string())
})();
match result {
Ok(_) => {
if let Some(state) = self.style_view_state.as_mut() {
state.mark_applied();
}
self.theme_badge = theme;
self.notify(ToastLevel::Success, &t(self.ui_locale, "style.applied"));
}
Err(error) => self.notify_operation_failed("style.apply", error),
}
Task::none()
}
}
}
fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task<Message> {
// Ensure settings state exists
if self.settings_state.is_none() {
@@ -7275,11 +7371,9 @@ impl BdsApp {
Some(state.data_path.clone())
};
project.updated_at = bds_core::util::now_unix_ms();
let db_result =
bds_core::db::queries::project::update_project(db.conn(), project);
let file_result = engine::meta::write_project_json(data_dir, &meta);
match (db_result, file_result) {
(Ok(()), Ok(())) => {
match engine::meta::update_project_metadata(db.conn(), data_dir, project, &meta)
{
Ok(_) => {
let semantic_should_backfill =
state.semantic_similarity_enabled && !semantic_was_enabled;
if let Some(listing) =
@@ -7295,8 +7389,7 @@ impl BdsApp {
return Task::done(Message::EmbeddingBackfill);
}
}
(Err(e), _) => self.notify_operation_failed("common.save", e),
(_, Err(e)) => self.notify_operation_failed("common.save", e),
Err(e) => self.notify_operation_failed("common.save", e),
}
}
}
@@ -8166,6 +8259,9 @@ impl BdsApp {
TabType::Settings if self.settings_state.is_none() => {
self.settings_state = Some(self.hydrate_settings_state());
}
TabType::Style if self.style_view_state.is_none() => {
self.style_view_state = self.hydrate_style_state();
}
_ => {}
}
}
@@ -8379,6 +8475,72 @@ impl BdsApp {
}
}
fn active_style_uses_embedded_preview(&self) -> bool {
let active_is_style = self.active_tab.as_ref().is_some_and(|tab_id| {
self.tabs
.iter()
.any(|tab| tab.id == *tab_id && tab.tab_type == TabType::Style)
});
active_is_style && self.active_project.is_some() && self.style_view_state.is_some()
}
fn hide_embedded_style_preview(&self) {
if let Some(preview) = &self.embedded_style_preview {
preview.controller.set_visible(false);
}
}
fn sync_embedded_previews(&mut self) -> Task<Message> {
let post = self.sync_embedded_preview_for_active_post();
let style = self.sync_embedded_preview_for_style();
Task::batch([post, style])
}
fn sync_embedded_preview_for_style(&mut self) -> Task<Message> {
if !self.active_style_uses_embedded_preview() {
self.hide_embedded_style_preview();
return Task::none();
}
if let Err(error) = self.ensure_preview_server() {
self.notify(ToastLevel::Error, &error);
return Task::none();
}
let Some(url) = self
.style_view_state
.as_ref()
.map(StyleViewState::preview_url)
else {
return Task::none();
};
if let Some(preview) = &mut self.embedded_style_preview {
preview.current_url = Some(url.clone());
if preview.controller.is_active() {
preview.controller.navigate(&url);
preview.controller.set_visible(true);
return Task::none();
}
} else {
self.embedded_style_preview = Some(EmbeddedPreviewState {
controller: WebViewController::new(WebViewConfig::default().url(url.clone())),
current_url: Some(url.clone()),
});
}
let Some(window_id) = self.main_window_id else {
return window::get_oldest().map(Message::MainWindowLoaded);
};
if let Some(preview) = &mut self.embedded_style_preview
&& !preview.controller.is_active()
{
preview.controller = WebViewController::new(WebViewConfig::default().url(url));
return preview
.controller
.create_task(window_id, Message::EmbeddedStylePreviewReady);
}
Task::none()
}
fn sync_embedded_preview_for_active_post(&mut self) -> Task<Message> {
let Some(active_id) = self.active_tab.clone() else {
self.hide_embedded_preview();
@@ -9054,6 +9216,7 @@ mod tests {
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState};
use crate::views::style_view::{PreviewMode, StyleMsg, StyleViewState};
use crate::views::template_editor::TemplateEditorState;
use bds_core::db::Database;
use bds_core::db::fts::ensure_fts_tables;
@@ -10364,6 +10527,57 @@ mod tests {
assert!(app.active_post_uses_embedded_preview());
}
#[test]
fn style_selection_and_preview_mode_stay_local_until_apply() {
let mut state = StyleViewState::new(Some("blue"));
assert_eq!(state.selected_theme, "blue");
assert_eq!(state.applied_theme, "blue");
assert!(!state.can_apply());
state.select_theme("green");
state.set_preview_mode(PreviewMode::Dark);
assert_eq!(state.selected_theme, "green");
assert_eq!(state.applied_theme, "blue");
assert_eq!(state.preview_mode, PreviewMode::Dark);
assert!(state.can_apply());
state.mark_applied();
assert!(!state.can_apply());
}
#[test]
fn applying_style_persists_project_metadata_emits_event_and_updates_badge() {
let (db, project, tmp) = setup();
let events = bds_core::engine::domain_events::subscribe();
let mut app = make_app(db, project, &tmp);
app.style_view_state = Some(StyleViewState::new(None));
let _ = app.update(Message::Style(StyleMsg::SelectTheme("purple".to_string())));
let _ = app.update(Message::Style(StyleMsg::Apply));
let metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
assert_eq!(metadata.pico_theme.as_deref(), Some("purple"));
let project_json = std::fs::read_to_string(tmp.path().join("meta/project.json")).unwrap();
assert!(project_json.contains("\"picoTheme\": \"purple\""));
assert!(!project_json.contains("previewMode"));
assert_eq!(app.theme_badge, "purple");
assert_eq!(
app.style_view_state.as_ref().unwrap().applied_theme,
"purple"
);
assert!(events.drain().iter().any(|event| matches!(
event,
DomainEvent::EntityChanged {
project_id,
entity: bds_core::model::DomainEntity::Project,
entity_id,
action: bds_core::model::NotificationAction::Updated,
} if project_id == "p1" && entity_id == "p1"
)));
}
#[test]
fn task_tick_autosaves_dirty_post_editor() {
let (db, project, tmp) = setup();

View File

@@ -5,11 +5,10 @@ impl BdsApp {
match message {
Message::MainWindowLoaded(window_id) => {
self.main_window_id = window_id;
if self.active_post_uses_embedded_preview() {
self.sync_embedded_preview_for_active_post()
} else {
Task::none()
}
Task::batch([
self.sync_embedded_preview_for_active_post(),
self.sync_embedded_preview_for_style(),
])
}
Message::EmbeddedPreviewReady(result) => {
match result {
@@ -29,6 +28,24 @@ impl BdsApp {
}
Task::none()
}
Message::EmbeddedStylePreviewReady(result) => {
match result {
Ok(()) => {
let visible = self.active_style_uses_embedded_preview();
if let Some(preview) = &mut self.embedded_style_preview {
preview.controller.take_staged();
if let Some(url) = preview.current_url.as_deref() {
preview.controller.navigate(url);
}
preview.controller.set_visible(visible);
}
}
Err(error) => {
self.notify(ToastLevel::Error, &error);
}
}
Task::none()
}
_ => unreachable!("non-preview message routed to preview handler"),
}
}

View File

@@ -278,25 +278,23 @@ pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> {
/// Primary action button style.
pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
let _ = theme;
match status {
button::Status::Hovered => button::Style {
background: Some(Background::Color(PRIMARY_HOVER)),
text_color: Color::WHITE,
border: iced::Border {
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
let background = match status {
button::Status::Hovered => PRIMARY_HOVER,
button::Status::Disabled => PRIMARY_BG.scale_alpha(0.45),
_ => PRIMARY_BG,
};
button::Style {
background: Some(Background::Color(background)),
text_color: if matches!(status, button::Status::Disabled) {
Color::WHITE.scale_alpha(0.55)
} else {
Color::WHITE
},
_ => button::Style {
background: Some(Background::Color(PRIMARY_BG)),
text_color: Color::WHITE,
border: iced::Border {
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
border: iced::Border {
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
}
}
@@ -464,6 +462,10 @@ mod tests {
primary_button(&theme, button::Status::Active).background,
primary_button(&theme, button::Status::Hovered).background
);
assert_ne!(
primary_button(&theme, button::Status::Active).background,
primary_button(&theme, button::Status::Disabled).background
);
assert_ne!(
field_style(&theme, text_input::Status::Active).border.color,
field_style(&theme, text_input::Status::Focused)

View File

@@ -18,6 +18,7 @@ pub mod settings_view;
pub mod sidebar;
pub mod site_validation;
pub mod status_bar;
pub mod style_view;
pub mod tab_bar;
pub mod tags_view;
pub mod template_editor;

View File

@@ -0,0 +1,406 @@
use iced::widget::text::Shaping;
use iced::widget::{Column, Space, button, column, container, row, scrollable, text};
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
const DEFAULT_THEME: &str = "default";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreviewMode {
Auto,
Light,
Dark,
}
impl PreviewMode {
pub fn query_value(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Light => "light",
Self::Dark => "dark",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreviewModeOption {
mode: PreviewMode,
label: String,
}
impl std::fmt::Display for PreviewModeOption {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.label)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StyleTheme {
pub name: &'static str,
pub accent_color: &'static str,
pub light_bg_color: &'static str,
pub dark_bg_color: &'static str,
}
const fn theme(name: &'static str, accent_color: &'static str) -> StyleTheme {
StyleTheme {
name,
accent_color,
light_bg_color: "#ffffff",
dark_bg_color: "#13171f",
}
}
pub const THEMES: [StyleTheme; 20] = [
theme("default", "#0172ad"),
theme("amber", "#ffbf00"),
theme("blue", "#2060df"),
theme("cyan", "#047878"),
theme("fuchsia", "#c1208b"),
theme("green", "#398712"),
theme("grey", "#ababab"),
theme("indigo", "#524ed2"),
theme("jade", "#007a50"),
theme("lime", "#a5d601"),
theme("orange", "#d24317"),
theme("pink", "#d92662"),
theme("pumpkin", "#ff9500"),
theme("purple", "#9236a4"),
theme("red", "#c52f21"),
theme("sand", "#ccc6b4"),
theme("slate", "#525f7a"),
theme("violet", "#7540bf"),
theme("yellow", "#f2df0d"),
theme("zinc", "#646b79"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyleViewState {
pub selected_theme: String,
pub applied_theme: String,
pub preview_mode: PreviewMode,
}
impl StyleViewState {
pub fn new(applied_theme: Option<&str>) -> Self {
let applied_theme = normalize_theme(applied_theme);
Self {
selected_theme: applied_theme.clone(),
applied_theme,
preview_mode: PreviewMode::Auto,
}
}
pub fn select_theme(&mut self, theme: &str) {
if is_supported_theme(theme) {
self.selected_theme = theme.to_string();
}
}
pub fn set_preview_mode(&mut self, mode: PreviewMode) {
self.preview_mode = mode;
}
pub fn can_apply(&self) -> bool {
self.selected_theme != self.applied_theme
}
pub fn mark_applied(&mut self) {
self.applied_theme.clone_from(&self.selected_theme);
}
pub fn refresh_applied_theme(&mut self, applied_theme: Option<&str>) {
let preserve_pending_selection = self.can_apply();
self.applied_theme = normalize_theme(applied_theme);
if !preserve_pending_selection {
self.selected_theme.clone_from(&self.applied_theme);
}
}
pub fn preview_url(&self) -> String {
format!(
"http://{}:{}/__style-preview?theme={}&mode={}",
bds_core::engine::preview::PREVIEW_HOST,
bds_core::engine::preview::PREVIEW_PORT,
self.selected_theme,
self.preview_mode.query_value(),
)
}
}
#[derive(Debug, Clone)]
pub enum StyleMsg {
SelectTheme(String),
PreviewModeChanged(PreviewMode),
Apply,
}
pub fn is_supported_theme(theme: &str) -> bool {
THEMES.iter().any(|candidate| candidate.name == theme)
}
pub fn display_theme_name(theme: &str) -> String {
let replaced = theme.replace('-', " ");
let mut characters = replaced.chars();
match characters.next() {
Some(first) => first.to_uppercase().chain(characters).collect(),
None => String::new(),
}
}
fn normalize_theme(theme: Option<&str>) -> String {
theme
.filter(|theme| is_supported_theme(theme))
.unwrap_or(DEFAULT_THEME)
.to_string()
}
fn parse_hex_color(hex: &str) -> Color {
let bytes = hex.as_bytes();
if bytes.len() == 7 && bytes[0] == b'#' {
let component = |start| u8::from_str_radix(&hex[start..start + 2], 16).unwrap_or_default();
Color::from_rgb8(component(1), component(3), component(5))
} else {
Color::TRANSPARENT
}
}
fn theme_button_style(selected: bool, status: button::Status) -> button::Style {
let hovered = matches!(status, button::Status::Hovered);
button::Style {
background: Some(Background::Color(if selected {
Color::from_rgb8(0x1F, 0x45, 0x63)
} else if hovered {
Color::from_rgb8(0x32, 0x32, 0x35)
} else {
Color::from_rgb8(0x24, 0x24, 0x26)
})),
text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4),
border: Border {
color: if selected {
Color::from_rgb8(0x00, 0x7F, 0xD4)
} else {
Color::from_rgb8(0x3C, 0x3C, 0x3C)
},
width: if selected { 2.0 } else { 1.0 },
radius: 6.0.into(),
},
..button::Style::default()
}
}
fn swatch(color: &'static str) -> Element<'static, Message> {
container(Space::new(Length::Fill, Length::Fixed(16.0)))
.width(Length::Fill)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(parse_hex_color(color))),
..container::Style::default()
})
.into()
}
fn theme_button<'a>(theme: &StyleTheme, selected_theme: &str) -> Element<'a, Message> {
let selected = theme.name == selected_theme;
let theme_name = theme.name.to_string();
button(
column![
row![
swatch(theme.accent_color),
swatch(theme.light_bg_color),
swatch(theme.dark_bg_color),
]
.spacing(2),
text(display_theme_name(theme.name))
.size(12)
.shaping(Shaping::Advanced),
]
.spacing(7)
.width(Length::Fill),
)
.on_press(Message::Style(StyleMsg::SelectTheme(theme_name)))
.padding(8)
.width(Length::FillPortion(1))
.style(move |_: &Theme, status| theme_button_style(selected, status))
.into()
}
pub fn view<'a>(
state: &'a StyleViewState,
locale: UiLocale,
preview_widget: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
let header = inputs::card(
column![
text(t(locale, "style.title"))
.size(18)
.shaping(Shaping::Advanced),
text(t(locale, "style.subtitle"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced),
]
.spacing(4),
);
let theme_rows = THEMES
.chunks(4)
.map(|themes| {
let mut children = themes
.iter()
.map(|theme| theme_button(theme, &state.selected_theme))
.collect::<Vec<_>>();
while children.len() < 4 {
children.push(Space::with_width(Length::FillPortion(1)).into());
}
iced::widget::Row::with_children(children)
.spacing(8)
.width(Length::Fill)
.into()
})
.collect::<Vec<Element<'a, Message>>>();
let picker = inputs::card(
column![
text(t(locale, "style.themes"))
.size(12)
.color(inputs::SECTION_COLOR)
.shaping(Shaping::Advanced),
scrollable(Column::with_children(theme_rows).spacing(8))
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
.style(inputs::scrollable_style)
.height(Length::Fixed(250.0)),
]
.spacing(8),
);
let preview_modes = [
PreviewModeOption {
mode: PreviewMode::Auto,
label: t(locale, "style.previewMode.auto"),
},
PreviewModeOption {
mode: PreviewMode::Light,
label: t(locale, "style.previewMode.light"),
},
PreviewModeOption {
mode: PreviewMode::Dark,
label: t(locale, "style.previewMode.dark"),
},
];
let selected_mode = preview_modes
.iter()
.find(|option| option.mode == state.preview_mode);
let mode_select = container(inputs::labeled_select(
&t(locale, "style.previewMode"),
&preview_modes,
selected_mode,
|option| Message::Style(StyleMsg::PreviewModeChanged(option.mode)),
))
.width(Length::Fixed(220.0));
let apply_label = text(t(locale, "style.apply"))
.size(13)
.shaping(Shaping::Advanced);
let apply_button: Element<'a, Message> = if state.can_apply() {
button(apply_label)
.on_press(Message::Style(StyleMsg::Apply))
.padding([8, 16])
.style(inputs::primary_button)
.into()
} else {
button(apply_label)
.padding([8, 16])
.style(inputs::primary_button)
.into()
};
let controls = inputs::toolbar(vec![mode_select.into()], vec![apply_button]);
let preview = preview_widget.unwrap_or_else(|| {
container(
text(t(locale, "tabBar.loading"))
.size(14)
.shaping(Shaping::Advanced),
)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
});
let preview_card = inputs::card(
column![
text(t(locale, "style.preview"))
.size(12)
.color(inputs::SECTION_COLOR)
.shaping(Shaping::Advanced),
preview,
]
.spacing(8)
.height(Length::Fill),
)
.height(Length::Fill);
container(
column![header, picker, controls, preview_card]
.spacing(10)
.height(Length::Fill),
)
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn themes_match_the_allium_and_bds2_catalog() {
assert_eq!(THEMES.len(), 20);
assert_eq!(THEMES.first().unwrap().name, "default");
assert_eq!(THEMES.last().unwrap().name, "zinc");
assert_eq!(
THEMES.iter().map(|theme| theme.name).collect::<Vec<_>>(),
bds_core::model::SUPPORTED_PICO_THEMES.to_vec()
);
assert_eq!(THEMES[1].accent_color, "#ffbf00");
assert_eq!(THEMES[2].accent_color, "#2060df");
assert_eq!(THEMES[18].accent_color, "#f2df0d");
assert_eq!(THEMES[19].accent_color, "#646b79");
assert_eq!(
THEMES
.iter()
.map(|theme| theme.accent_color)
.collect::<std::collections::HashSet<_>>()
.len(),
THEMES.len()
);
assert!(THEMES.iter().all(|theme| {
theme.light_bg_color == "#ffffff" && theme.dark_bg_color == "#13171f"
}));
}
#[test]
fn preview_url_tracks_selection_and_local_mode() {
let mut state = StyleViewState::new(Some("blue"));
state.select_theme("pumpkin");
state.set_preview_mode(PreviewMode::Light);
assert_eq!(
state.preview_url(),
"http://127.0.0.1:4123/__style-preview?theme=pumpkin&mode=light"
);
assert_eq!(state.applied_theme, "blue");
}
#[test]
fn invalid_or_empty_applied_theme_uses_default() {
assert_eq!(StyleViewState::new(None).applied_theme, "default");
assert_eq!(StyleViewState::new(Some("")).applied_theme, "default");
assert_eq!(
StyleViewState::new(Some("unknown")).applied_theme,
"default"
);
}
}

View File

@@ -35,36 +35,3 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
})
.into()
}
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(|| t(locale, "common.tabNotImplemented"));
container(
column![
text(title)
.size(24)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.85, 0.85, 0.90)),
text(subtitle)
.size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.55, 0.55, 0.60)),
]
.spacing(12)
.align_x(iced::Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
..container::Style::default()
})
.into()
}

View File

@@ -31,7 +31,9 @@ use crate::views::{
settings_view::{self, SettingsViewState},
sidebar,
site_validation::{self, SiteValidationState},
status_bar, tab_bar,
status_bar,
style_view::{self, StyleViewState},
tab_bar,
tags_view::{self, TagsViewState},
template_editor::{self, TemplateEditorState},
toast,
@@ -120,6 +122,7 @@ pub fn view<'a>(
// Data directory (for thumbnail paths)
data_dir: Option<&'a Path>,
post_preview_widget: Option<Element<'a, Message>>,
style_preview_widget: Option<Element<'a, Message>>,
// Editor states
post_editors: &'a HashMap<String, PostEditorState>,
media_editors: &'a HashMap<String, MediaEditorState>,
@@ -129,6 +132,7 @@ pub fn view<'a>(
chat_editors: &'a HashMap<String, ChatEditorState>,
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
style_view_state: Option<&'a StyleViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
@@ -157,6 +161,7 @@ pub fn view<'a>(
offline_mode,
data_dir,
post_preview_widget,
style_preview_widget,
post_editors,
media_editors,
template_editors,
@@ -165,6 +170,7 @@ pub fn view<'a>(
chat_editors,
tags_view_state,
settings_state,
style_view_state,
dashboard_state,
site_validation_state,
duplicates_state,
@@ -405,6 +411,7 @@ fn route_content_area<'a>(
offline_mode: bool,
data_dir: Option<&'a Path>,
post_preview_widget: Option<Element<'a, Message>>,
style_preview_widget: Option<Element<'a, Message>>,
post_editors: &'a HashMap<String, PostEditorState>,
media_editors: &'a HashMap<String, MediaEditorState>,
template_editors: &'a HashMap<String, TemplateEditorState>,
@@ -413,6 +420,7 @@ fn route_content_area<'a>(
chat_editors: &'a HashMap<String, ChatEditorState>,
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
style_view_state: Option<&'a StyleViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
@@ -435,6 +443,7 @@ fn route_content_area<'a>(
import_editors,
tags_view_state,
settings_state,
style_view_state,
dashboard_state,
site_validation_state,
) {
@@ -509,6 +518,13 @@ fn route_content_area<'a>(
loading_view(locale)
}
}
ContentRoute::Style => {
if let Some(state) = style_view_state {
style_view::view(state, locale, style_preview_widget)
} else {
welcome::view(locale)
}
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
ContentRoute::Documentation => documentation::view(guide_documentation, locale),
@@ -533,7 +549,6 @@ fn route_content_area<'a>(
loading_view(locale)
}
}
ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
}
}
@@ -550,6 +565,7 @@ enum ContentRoute<'a> {
Chat(&'a str),
Tags,
Settings,
Style,
SiteValidation,
FindDuplicates,
Documentation,
@@ -560,7 +576,6 @@ enum ContentRoute<'a> {
MenuEditor,
TranslationValidation,
GitDiff(&'a str),
Placeholder(&'a str),
}
#[expect(
@@ -577,6 +592,7 @@ fn route_kind<'a>(
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
style_view_state: Option<&'a StyleViewState>,
dashboard_state: Option<&'a DashboardState>,
_site_validation_state: &'a SiteValidationState,
) -> ContentRoute<'a> {
@@ -651,7 +667,13 @@ fn route_kind<'a>(
TabType::CliDocumentation => ContentRoute::CliDocumentation,
TabType::McpDocumentation => ContentRoute::McpDocumentation,
TabType::MenuEditor => ContentRoute::MenuEditor,
TabType::Style => ContentRoute::Placeholder(&tab.title),
TabType::Style => {
if style_view_state.is_some() {
ContentRoute::Style
} else {
ContentRoute::Welcome
}
}
TabType::TranslationValidation => ContentRoute::TranslationValidation,
}
}
@@ -701,35 +723,46 @@ mod tests {
}
#[test]
fn unsupported_tool_tabs_do_not_fall_back_to_welcome_route() {
fn style_tab_routes_to_real_editor_only_with_project_state() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default();
let unsupported = [TabType::Style];
let style_state = StyleViewState::new(Some("blue"));
let tabs = vec![tab("style", TabType::Style, "Style")];
let route = route_kind(
&tabs,
Some("style"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
Some(&style_state),
None,
&site_validation_state,
);
assert!(matches!(route, ContentRoute::Style));
for tab_type in unsupported {
let tabs = vec![tab("tool", tab_type.clone(), "Tool")];
let route = route_kind(
&tabs,
Some("tool"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
&site_validation_state,
);
match route {
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
_ => panic!("expected placeholder route for {tab_type:?}"),
}
}
let no_project_route = route_kind(
&tabs,
Some("style"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
None,
&site_validation_state,
);
assert!(matches!(no_project_route, ContentRoute::Welcome));
}
#[test]
@@ -752,6 +785,7 @@ mod tests {
None,
None,
None,
None,
&site_validation,
);
assert!(matches!(route, ContentRoute::MenuEditor));
@@ -781,6 +815,7 @@ mod tests {
None,
None,
None,
None,
&site_validation,
);
assert!(matches!(
@@ -814,6 +849,7 @@ mod tests {
None,
None,
None,
None,
&site_validation,
);
assert!(matches!(route, ContentRoute::FindDuplicates));
@@ -839,6 +875,7 @@ mod tests {
None,
None,
None,
None,
&site_validation_state,
);
assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt")));
@@ -864,6 +901,7 @@ mod tests {
None,
None,
None,
None,
&site_validation_state,
);
assert!(matches!(route, ContentRoute::Chat("conversation")));
@@ -894,6 +932,7 @@ mod tests {
None,
None,
None,
None,
&site_validation_state,
);
@@ -928,6 +967,7 @@ mod tests {
&empty_imports,
None,
None,
None,
Some(&dashboard),
&site_validation_state,
);
@@ -962,6 +1002,7 @@ mod tests {
None,
None,
None,
None,
&site_validation_state,
);