//! Inline theme editor for notebook settings. //! //! Provides core 4 colour pickers (background, text, primary, link) plus code theme selector. use dioxus::prelude::*; use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; use crate::components::HexColourInput; use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; use crate::components::theme_preview::ThemePreview; use crate::components::toggle_group::{ToggleGroup, ToggleItem}; /// Strip leading # or 0x from hex colour strings. fn strip_hex_prefix(s: &str) -> String { s.trim_start_matches('#') .trim_start_matches("0x") .trim_start_matches("0X") .to_uppercase() } /// Convert mode string to toggle index. fn mode_to_index(mode: &str) -> std::collections::HashSet { let idx = match mode { "light" => 1, "dark" => 2, _ => 0, // "auto" or default }; std::collections::HashSet::from([idx]) } /// Convert toggle index to mode string. fn index_to_mode(idx: usize) -> &'static str { match idx { 1 => "light", 2 => "dark", _ => "auto", } } const INLINE_THEME_EDITOR_CSS: Asset = asset!("/assets/styling/inline-theme-editor.css"); /// Theme values being edited. #[derive(Debug, Clone, PartialEq, Default)] pub struct InlineThemeValues { // Primary colours (based on default_mode). pub background: String, pub text: String, pub primary: String, pub link: String, // Light-specific overrides (None = use generated from primary colours). pub light_background: Option, pub light_text: Option, pub light_primary: Option, pub light_link: Option, // Dark-specific overrides (None = use generated from primary colours). pub dark_background: Option, pub dark_text: Option, pub dark_primary: Option, pub dark_link: Option, // Code themes (separate for light/dark). pub light_code_theme: String, pub dark_code_theme: String, pub default_mode: String, // "light", "dark", or "auto" } impl InlineThemeValues { /// Get inputs for the light variant. pub fn light_inputs(&self) -> weaver_renderer::colour_gen::ThemeInputs { weaver_renderer::colour_gen::ThemeInputs { background: self .light_background .clone() .unwrap_or_else(|| self.background.clone()), text: self.light_text.clone().unwrap_or_else(|| self.text.clone()), primary: self .light_primary .clone() .unwrap_or_else(|| self.primary.clone()), link: self.light_link.clone().unwrap_or_else(|| self.link.clone()), } } /// Get inputs for the dark variant. pub fn dark_inputs(&self) -> weaver_renderer::colour_gen::ThemeInputs { weaver_renderer::colour_gen::ThemeInputs { background: self .dark_background .clone() .unwrap_or_else(|| self.background.clone()), text: self.dark_text.clone().unwrap_or_else(|| self.text.clone()), primary: self .dark_primary .clone() .unwrap_or_else(|| self.primary.clone()), link: self.dark_link.clone().unwrap_or_else(|| self.link.clone()), } } } /// Props for InlineThemeEditor. #[derive(Props, Clone, PartialEq)] pub struct InlineThemeEditorProps { /// Current theme values (signal for reactivity). pub values: Signal, /// Control advanced options visibility. /// None = auto (container query), Some(true) = always show, Some(false) = always hide. #[props(default)] pub show_advanced: Option, /// Show live preview of theme. #[props(default = false)] pub show_preview: bool, } /// Inline theme editor with core colour pickers and code theme selector. #[component] pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { let mut values = props.values; let advanced_class = match props.show_advanced { Some(true) => "inline-theme-editor-advanced force-show", Some(false) => "inline-theme-editor-advanced force-hide", None => "inline-theme-editor-advanced", }; rsx! { document::Stylesheet { href: INLINE_THEME_EDITOR_CSS } div { class: "inline-theme-editor", h4 { class: "inline-theme-editor-heading", "Theme" } // Main theme controls - two columns when wide. div { class: "inline-theme-editor-main", // Left column: colours and mode toggle. div { class: "inline-theme-editor-main-left", // Colour pickers (2x2 grid). div { class: "inline-theme-editor-colours", HexColourInput { label: Some("Background".to_string()), value: values().background.clone(), onchange: move |val| values.write().background = val, } HexColourInput { label: Some("Text".to_string()), value: values().text.clone(), onchange: move |val| values.write().text = val, } HexColourInput { label: Some("Primary".to_string()), value: values().primary.clone(), onchange: move |val| values.write().primary = val, } HexColourInput { label: Some("Link".to_string()), value: values().link.clone(), onchange: move |val| values.write().link = val, } } // Default mode toggle group. div { class: "inline-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" } } } } // Right column: preset and code theme dropdowns. div { class: "inline-theme-editor-main-right", // Preset selector. div { class: "inline-theme-editor-presets", label { "Preset:" } select { onchange: move |e: Event| { let preset_id = e.value(); if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) { let mut v = values.write(); v.background = strip_hex_prefix(&scheme.colours.base); v.text = strip_hex_prefix(&scheme.colours.text); v.primary = strip_hex_prefix(&scheme.colours.primary); v.link = strip_hex_prefix(&scheme.colours.link); v.default_mode = scheme.variant.to_string(); // Clear overrides when selecting preset. v.light_background = None; v.light_text = None; v.light_primary = None; v.light_link = None; v.dark_background = None; v.dark_text = None; v.dark_primary = None; v.dark_link = None; } }, option { value: "", "Custom" } for scheme in BUILTIN_COLOUR_SCHEMES.iter() { option { value: "{scheme.id}", "{scheme.name}" } } } } // Code theme selectors. div { class: "inline-theme-editor-code-theme", label { "Light code theme:" } select { value: "{values().light_code_theme}", onchange: move |e: Event| { values.write().light_code_theme = e.value(); }, for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") { option { value: "{theme.id}", "{theme.name}" } } } } div { class: "inline-theme-editor-code-theme", label { "Dark code theme:" } select { value: "{values().dark_code_theme}", onchange: move |e: Event| { values.write().dark_code_theme = e.value(); }, for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") { option { value: "{theme.id}", "{theme.name}" } } } } } } // Advanced section for per-variant colour customization. div { class: "{advanced_class}", Collapsible { CollapsibleTrigger { "Advanced colour options" } CollapsibleContent { div { class: "inline-theme-editor-advanced-content", // Light variant colours. div { class: "inline-theme-editor-variant", h5 { "Light variant" } div { class: "inline-theme-editor-colours", HexColourInput { label: Some("Background".to_string()), value: values().light_background.clone().unwrap_or_default(), placeholder: values().background.clone(), onchange: move |val: String| { values.write().light_background = if val.is_empty() { None } else { Some(val) }; }, } HexColourInput { label: Some("Text".to_string()), value: values().light_text.clone().unwrap_or_default(), placeholder: values().text.clone(), onchange: move |val: String| { values.write().light_text = if val.is_empty() { None } else { Some(val) }; }, } HexColourInput { label: Some("Primary".to_string()), value: values().light_primary.clone().unwrap_or_default(), placeholder: values().primary.clone(), onchange: move |val: String| { values.write().light_primary = if val.is_empty() { None } else { Some(val) }; }, } HexColourInput { label: Some("Link".to_string()), value: values().light_link.clone().unwrap_or_default(), placeholder: values().link.clone(), onchange: move |val: String| { values.write().light_link = if val.is_empty() { None } else { Some(val) }; }, } } } // Dark variant colours. div { class: "inline-theme-editor-variant", h5 { "Dark variant" } div { class: "inline-theme-editor-colours", HexColourInput { label: Some("Background".to_string()), value: values().dark_background.clone().unwrap_or_default(), placeholder: values().background.clone(), onchange: move |val: String| { values.write().dark_background = if val.is_empty() { None } else { Some(val) }; }, } HexColourInput { label: Some("Text".to_string()), value: values().dark_text.clone().unwrap_or_default(), placeholder: values().text.clone(), onchange: move |val: String| { values.write().dark_text = if val.is_empty() { None } else { Some(val) }; }, } HexColourInput { label: Some("Primary".to_string()), value: values().dark_primary.clone().unwrap_or_default(), placeholder: values().primary.clone(), onchange: move |val: String| { values.write().dark_primary = if val.is_empty() { None } else { Some(val) }; }, } HexColourInput { label: Some("Link".to_string()), value: values().dark_link.clone().unwrap_or_default(), placeholder: values().link.clone(), onchange: move |val: String| { values.write().dark_link = if val.is_empty() { None } else { Some(val) }; }, } } } } } } } // Live preview section. if props.show_preview { PreviewSection { values } } } } } /// Preview section with light/dark toggle. #[component] fn PreviewSection(values: Signal) -> Element { let mut preview_dark = use_signal(|| false); rsx! { div { class: "inline-theme-editor-preview", div { class: "inline-theme-editor-preview-header", h5 { "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() { preview_dark.set(idx == 1); } }, ToggleItem { index: 0usize, "Light" } ToggleItem { index: 1usize, "Dark" } } } ThemePreview { values, dark: preview_dark(), } } } }