//! Full theme editor component with all 16 colours, fonts, spacing. use dioxus::prelude::*; use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; use crate::components::HexColourInput; use crate::components::button::{Button, ButtonVariant}; use crate::components::inline_theme_editor::InlineThemeValues; use crate::components::select::{Select, SelectList, SelectOption, SelectTrigger, SelectValue}; use crate::components::theme_preview::ThemePreview; use crate::components::toggle_group::{ToggleGroup, ToggleItem}; const THEME_EDITOR_CSS: Asset = asset!("/assets/styling/theme-editor.css"); /// Full theme values for the theme editor. #[derive(Debug, Clone, PartialEq, Default)] pub struct ThemeEditorValues { // Light scheme colours (all 16). pub light: ColourSchemeValues, // Dark scheme colours (all 16). pub dark: ColourSchemeValues, // Fonts. pub font_body: String, pub font_heading: String, pub font_mono: String, // Spacing. pub spacing_base: String, pub spacing_line_height: String, pub spacing_scale: String, // Code themes. pub light_code_theme: String, pub dark_code_theme: String, // Default mode. pub default_mode: String, } /// All 16 colour values for a single scheme. #[derive(Debug, Clone, PartialEq, Default)] pub struct ColourSchemeValues { pub base: String, pub surface: String, pub overlay: String, pub text: String, pub muted: String, pub subtle: String, pub emphasis: String, pub primary: String, pub secondary: String, pub tertiary: String, pub error: String, pub warning: String, pub success: String, pub border: String, pub link: String, pub highlight: String, } impl ThemeEditorValues { /// Convert to InlineThemeValues for preview (uses primary 4 colours from light scheme). pub fn to_inline_values(&self) -> InlineThemeValues { InlineThemeValues { background: self.light.base.clone(), text: self.light.text.clone(), primary: self.light.primary.clone(), link: self.light.link.clone(), light_background: Some(self.light.base.clone()), light_text: Some(self.light.text.clone()), light_primary: Some(self.light.primary.clone()), light_link: Some(self.light.link.clone()), dark_background: Some(self.dark.base.clone()), dark_text: Some(self.dark.text.clone()), dark_primary: Some(self.dark.primary.clone()), dark_link: Some(self.dark.link.clone()), light_code_theme: self.light_code_theme.clone(), dark_code_theme: self.dark_code_theme.clone(), default_mode: self.default_mode.clone(), } } /// Create from a preset colour scheme. pub fn from_preset(scheme_id: &str) -> Option { let scheme = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == scheme_id)?; let colours = &scheme.colours; let scheme_values = ColourSchemeValues { base: strip_hex_prefix(&colours.base), surface: strip_hex_prefix(&colours.surface), overlay: strip_hex_prefix(&colours.overlay), text: strip_hex_prefix(&colours.text), muted: strip_hex_prefix(&colours.muted), subtle: strip_hex_prefix(&colours.subtle), emphasis: strip_hex_prefix(&colours.emphasis), primary: strip_hex_prefix(&colours.primary), secondary: strip_hex_prefix(&colours.secondary), tertiary: strip_hex_prefix(&colours.tertiary), error: strip_hex_prefix(&colours.error), warning: strip_hex_prefix(&colours.warning), success: strip_hex_prefix(&colours.success), border: strip_hex_prefix(&colours.border), link: strip_hex_prefix(&colours.link), highlight: strip_hex_prefix(&colours.highlight), }; // Find counterpart scheme for dark/light. let is_dark = scheme.variant == "dark"; let counterpart_id = if is_dark { "rose-pine-dawn" } else { "rose-pine" }; let counterpart = BUILTIN_COLOUR_SCHEMES .iter() .find(|s| s.id == counterpart_id); let counterpart_values = counterpart .map(|c| { let colours = &c.colours; ColourSchemeValues { base: strip_hex_prefix(&colours.base), surface: strip_hex_prefix(&colours.surface), overlay: strip_hex_prefix(&colours.overlay), text: strip_hex_prefix(&colours.text), muted: strip_hex_prefix(&colours.muted), subtle: strip_hex_prefix(&colours.subtle), emphasis: strip_hex_prefix(&colours.emphasis), primary: strip_hex_prefix(&colours.primary), secondary: strip_hex_prefix(&colours.secondary), tertiary: strip_hex_prefix(&colours.tertiary), error: strip_hex_prefix(&colours.error), warning: strip_hex_prefix(&colours.warning), success: strip_hex_prefix(&colours.success), border: strip_hex_prefix(&colours.border), link: strip_hex_prefix(&colours.link), highlight: strip_hex_prefix(&colours.highlight), } }) .unwrap_or_default(); let (light, dark) = if is_dark { (counterpart_values, scheme_values) } else { (scheme_values, counterpart_values) }; Some(Self { light, dark, font_body: String::new(), font_heading: String::new(), font_mono: String::new(), spacing_base: "16".to_string(), spacing_line_height: "1.6".to_string(), spacing_scale: "1.25".to_string(), light_code_theme: "rose-pine-dawn".to_string(), dark_code_theme: "rose-pine".to_string(), default_mode: scheme.variant.to_string(), }) } } fn strip_hex_prefix(s: &str) -> String { s.trim_start_matches('#') .trim_start_matches("0x") .trim_start_matches("0X") .to_uppercase() } fn mode_to_index(mode: &str) -> std::collections::HashSet { let idx = match mode { "light" => 1, "dark" => 2, _ => 0, }; std::collections::HashSet::from([idx]) } fn index_to_mode(idx: usize) -> &'static str { match idx { 1 => "light", 2 => "dark", _ => "auto", } } /// Props for ThemeEditor. #[derive(Props, Clone, PartialEq)] pub struct ThemeEditorProps { /// Current theme values (signal for reactivity). pub values: Signal, /// Callback on save. pub on_save: EventHandler, /// Callback on cancel. pub on_cancel: EventHandler<()>, /// Whether save is in progress. #[props(default = false)] pub saving: bool, } /// Full theme editor with all 16 colours, fonts, spacing. #[component] pub fn ThemeEditor(props: ThemeEditorProps) -> Element { let mut values = props.values; // Preview values derived from editor values. let mut preview_values = use_signal(|| values().to_inline_values()); // Sync preview when values change. use_effect(move || { let v = values(); preview_values.set(v.to_inline_values()); }); // Which variant is being edited (for preview). let mut editing_dark = use_signal(|| false); // Derived signals for light/dark schemes. let light_scheme = use_memo(move || values().light.clone()); let dark_scheme = use_memo(move || values().dark.clone()); rsx! { document::Stylesheet { href: THEME_EDITOR_CSS } div { class: "theme-editor-page", // Left column: controls. div { class: "theme-editor-controls", // Mode toggle. ModeSection { values } // Fonts section. FontsSection { values } // Spacing section. SpacingSection { values } // Code themes. CodeThemesSection { values } // Light scheme colours. ColourSchemeSection { title: "Light scheme", variant: "light", scheme: light_scheme, on_change: move |new_scheme: ColourSchemeValues| { values.write().light = new_scheme; }, on_focus: move |_| editing_dark.set(false), } // Dark scheme colours. ColourSchemeSection { title: "Dark scheme", variant: "dark", scheme: dark_scheme, on_change: move |new_scheme: ColourSchemeValues| { values.write().dark = new_scheme; }, on_focus: move |_| editing_dark.set(true), } // Actions. div { class: "theme-editor-actions", Button { variant: ButtonVariant::Primary, onclick: move |_| { props.on_save.call(values()); }, disabled: props.saving, if props.saving { "Saving..." } else { "Save" } } Button { variant: ButtonVariant::Ghost, onclick: move |_| { props.on_cancel.call(()); }, disabled: props.saving, "Cancel" } } } // Right column: preview. div { class: "theme-editor-preview", div { class: "theme-editor-preview-header", h3 { "Preview" } ToggleGroup { horizontal: true, default_pressed: std::collections::HashSet::from([0usize]), on_pressed_change: move |pressed: std::collections::HashSet| { if let Some(&idx) = pressed.iter().next() { editing_dark.set(idx == 1); } }, ToggleItem { index: 0usize, "Light" } ToggleItem { index: 1usize, "Dark" } } } ThemePreview { values: preview_values, dark: editing_dark(), } } } } } /// Colour scheme section with all 16 colours and its own preset selector. #[component] fn ColourSchemeSection( title: &'static str, variant: &'static str, scheme: ReadSignal, on_change: EventHandler, on_focus: EventHandler, ) -> Element { // Helper to create colour input with change handler. let colour_input = move |label: &'static str, value: String, setter: fn(&mut ColourSchemeValues, String)| { let on_change = on_change.clone(); let on_focus = on_focus.clone(); let scheme = scheme.clone(); rsx! { HexColourInput { label: Some(label.to_string()), value: value, onchange: move |val: String| { let mut new_scheme = scheme(); setter(&mut new_scheme, val); on_change.call(new_scheme); }, onfocus: move |e| on_focus.call(e), } } }; let s = scheme(); // Filter presets by variant. let presets: Vec<_> = BUILTIN_COLOUR_SCHEMES .iter() .filter(|p| p.variant == variant) .collect(); rsx! { div { class: "theme-editor-section", div { class: "theme-editor-section-header", h3 { "{title}" } Select:: { placeholder: "Custom", on_value_change: move |val: Option| { let Some(val) = val else { return }; let Some(preset) = BUILTIN_COLOUR_SCHEMES.iter().find(|p| p.id == val) else { return }; let colours = &preset.colours; on_change.call(ColourSchemeValues { base: strip_hex_prefix(&colours.base), surface: strip_hex_prefix(&colours.surface), overlay: strip_hex_prefix(&colours.overlay), text: strip_hex_prefix(&colours.text), muted: strip_hex_prefix(&colours.muted), subtle: strip_hex_prefix(&colours.subtle), emphasis: strip_hex_prefix(&colours.emphasis), primary: strip_hex_prefix(&colours.primary), secondary: strip_hex_prefix(&colours.secondary), tertiary: strip_hex_prefix(&colours.tertiary), error: strip_hex_prefix(&colours.error), warning: strip_hex_prefix(&colours.warning), success: strip_hex_prefix(&colours.success), border: strip_hex_prefix(&colours.border), link: strip_hex_prefix(&colours.link), highlight: strip_hex_prefix(&colours.highlight), }); }, SelectTrigger { SelectValue {} } SelectList { for (idx, preset) in presets.iter().enumerate() { SelectOption:: { index: idx, value: preset.id.to_string(), text_value: preset.name.to_string(), "{preset.name}" } } } } } // Background colours. div { class: "theme-editor-colour-group", span { class: "theme-editor-colour-group-label", "Background" } div { class: "theme-editor-colour-group-items", {colour_input("Base", s.base.clone(), |s, v| s.base = v)} {colour_input("Surface", s.surface.clone(), |s, v| s.surface = v)} {colour_input("Overlay", s.overlay.clone(), |s, v| s.overlay = v)} } } // Text colours. div { class: "theme-editor-colour-group", span { class: "theme-editor-colour-group-label", "Text" } div { class: "theme-editor-colour-group-items", {colour_input("Text", s.text.clone(), |s, v| s.text = v)} {colour_input("Muted", s.muted.clone(), |s, v| s.muted = v)} {colour_input("Subtle", s.subtle.clone(), |s, v| s.subtle = v)} {colour_input("Emphasis", s.emphasis.clone(), |s, v| s.emphasis = v)} } } // Accent colours. div { class: "theme-editor-colour-group", span { class: "theme-editor-colour-group-label", "Accents" } div { class: "theme-editor-colour-group-items", {colour_input("Primary", s.primary.clone(), |s, v| s.primary = v)} {colour_input("Secondary", s.secondary.clone(), |s, v| s.secondary = v)} {colour_input("Tertiary", s.tertiary.clone(), |s, v| s.tertiary = v)} {colour_input("Link", s.link.clone(), |s, v| s.link = v)} } } // Status colours. div { class: "theme-editor-colour-group", span { class: "theme-editor-colour-group-label", "Status" } div { class: "theme-editor-colour-group-items", {colour_input("Error", s.error.clone(), |s, v| s.error = v)} {colour_input("Warning", s.warning.clone(), |s, v| s.warning = v)} {colour_input("Success", s.success.clone(), |s, v| s.success = v)} } } // UI colours. div { class: "theme-editor-colour-group", span { class: "theme-editor-colour-group-label", "UI" } div { class: "theme-editor-colour-group-items", {colour_input("Border", s.border.clone(), |s, v| s.border = v)} {colour_input("Highlight", s.highlight.clone(), |s, v| s.highlight = v)} } } } } } /// Fonts section. #[component] fn FontsSection(values: Signal) -> Element { rsx! { div { class: "theme-editor-section", h3 { "Fonts" } div { class: "theme-editor-fonts", div { class: "theme-editor-font-field", label { "Body" } input { r#type: "text", value: "{values().font_body}", placeholder: "IBM Plex Serif", oninput: move |e| values.write().font_body = e.value(), } } div { class: "theme-editor-font-field", label { "Heading" } input { r#type: "text", value: "{values().font_heading}", placeholder: "IBM Plex Sans", oninput: move |e| values.write().font_heading = e.value(), } } div { class: "theme-editor-font-field", label { "Monospace" } input { r#type: "text", value: "{values().font_mono}", placeholder: "IBM Plex Mono", oninput: move |e| values.write().font_mono = e.value(), } } } } } } /// Spacing section. #[component] fn SpacingSection(values: Signal) -> Element { rsx! { div { class: "theme-editor-section", h3 { "Spacing" } div { class: "theme-editor-spacing", div { class: "theme-editor-spacing-field", label { "Base size (px)" } input { r#type: "number", value: "{values().spacing_base}", oninput: move |e| values.write().spacing_base = e.value(), } } div { class: "theme-editor-spacing-field", label { "Line height" } input { r#type: "text", value: "{values().spacing_line_height}", oninput: move |e| values.write().spacing_line_height = e.value(), } } div { class: "theme-editor-spacing-field", label { "Scale" } input { r#type: "text", value: "{values().spacing_scale}", oninput: move |e| values.write().spacing_scale = e.value(), } } } } } } /// Code themes section. #[component] fn CodeThemesSection(values: Signal) -> Element { let light_themes: Vec<_> = BUILTIN_CODE_THEMES .iter() .filter(|t| t.variant == "light") .collect(); let dark_themes: Vec<_> = BUILTIN_CODE_THEMES .iter() .filter(|t| t.variant == "dark") .collect(); rsx! { div { class: "theme-editor-section", h3 { "Code themes" } div { class: "theme-editor-code-themes", div { class: "theme-editor-code-theme", label { "Light" } Select:: { value: Some(values().light_code_theme.clone()), on_value_change: move |val: Option| { if let Some(val) = val { values.write().light_code_theme = val; } }, SelectTrigger { SelectValue {} } SelectList { for (idx, theme) in light_themes.iter().enumerate() { SelectOption:: { index: idx, value: theme.id.to_string(), text_value: theme.name.to_string(), "{theme.name}" } } } } } div { class: "theme-editor-code-theme", label { "Dark" } Select:: { value: Some(values().dark_code_theme.clone()), on_value_change: move |val: Option| { if let Some(val) = val { values.write().dark_code_theme = val; } }, SelectTrigger { SelectValue {} } SelectList { for (idx, theme) in dark_themes.iter().enumerate() { SelectOption:: { index: idx, value: theme.id.to_string(), text_value: theme.name.to_string(), "{theme.name}" } } } } } } } } } /// Mode section. #[component] fn ModeSection(values: Signal) -> Element { rsx! { div { class: "theme-editor-section", div { class: "theme-editor-mode", label { "Default mode:" } ToggleGroup { horizontal: true, default_pressed: mode_to_index(&values().default_mode), on_pressed_change: move |pressed: std::collections::HashSet| { if let Some(&idx) = pressed.iter().next() { values.write().default_mode = index_to_mode(idx).to_string(); } }, ToggleItem { index: 0usize, "Auto" } ToggleItem { index: 1usize, "Light" } ToggleItem { index: 2usize, "Dark" } } } } } }