at main 374 lines 17 kB view raw
1//! Inline theme editor for notebook settings. 2//! 3//! Provides core 4 colour pickers (background, text, primary, link) plus code theme selector. 4 5use dioxus::prelude::*; 6use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; 7 8use crate::components::HexColourInput; 9use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; 10use crate::components::theme_preview::ThemePreview; 11use crate::components::toggle_group::{ToggleGroup, ToggleItem}; 12 13/// Strip leading # or 0x from hex colour strings. 14fn strip_hex_prefix(s: &str) -> String { 15 s.trim_start_matches('#') 16 .trim_start_matches("0x") 17 .trim_start_matches("0X") 18 .to_uppercase() 19} 20 21/// Convert mode string to toggle index. 22fn mode_to_index(mode: &str) -> std::collections::HashSet<usize> { 23 let idx = match mode { 24 "light" => 1, 25 "dark" => 2, 26 _ => 0, // "auto" or default 27 }; 28 std::collections::HashSet::from([idx]) 29} 30 31/// Convert toggle index to mode string. 32fn index_to_mode(idx: usize) -> &'static str { 33 match idx { 34 1 => "light", 35 2 => "dark", 36 _ => "auto", 37 } 38} 39 40const INLINE_THEME_EDITOR_CSS: Asset = asset!("/assets/styling/inline-theme-editor.css"); 41 42/// Theme values being edited. 43#[derive(Debug, Clone, PartialEq, Default)] 44pub struct InlineThemeValues { 45 // Primary colours (based on default_mode). 46 pub background: String, 47 pub text: String, 48 pub primary: String, 49 pub link: String, 50 51 // Light-specific overrides (None = use generated from primary colours). 52 pub light_background: Option<String>, 53 pub light_text: Option<String>, 54 pub light_primary: Option<String>, 55 pub light_link: Option<String>, 56 57 // Dark-specific overrides (None = use generated from primary colours). 58 pub dark_background: Option<String>, 59 pub dark_text: Option<String>, 60 pub dark_primary: Option<String>, 61 pub dark_link: Option<String>, 62 63 // Code themes (separate for light/dark). 64 pub light_code_theme: String, 65 pub dark_code_theme: String, 66 67 pub default_mode: String, // "light", "dark", or "auto" 68} 69 70impl InlineThemeValues { 71 /// Get inputs for the light variant. 72 pub fn light_inputs(&self) -> weaver_renderer::colour_gen::ThemeInputs { 73 weaver_renderer::colour_gen::ThemeInputs { 74 background: self 75 .light_background 76 .clone() 77 .unwrap_or_else(|| self.background.clone()), 78 text: self.light_text.clone().unwrap_or_else(|| self.text.clone()), 79 primary: self 80 .light_primary 81 .clone() 82 .unwrap_or_else(|| self.primary.clone()), 83 link: self.light_link.clone().unwrap_or_else(|| self.link.clone()), 84 } 85 } 86 87 /// Get inputs for the dark variant. 88 pub fn dark_inputs(&self) -> weaver_renderer::colour_gen::ThemeInputs { 89 weaver_renderer::colour_gen::ThemeInputs { 90 background: self 91 .dark_background 92 .clone() 93 .unwrap_or_else(|| self.background.clone()), 94 text: self.dark_text.clone().unwrap_or_else(|| self.text.clone()), 95 primary: self 96 .dark_primary 97 .clone() 98 .unwrap_or_else(|| self.primary.clone()), 99 link: self.dark_link.clone().unwrap_or_else(|| self.link.clone()), 100 } 101 } 102} 103 104/// Props for InlineThemeEditor. 105#[derive(Props, Clone, PartialEq)] 106pub struct InlineThemeEditorProps { 107 /// Current theme values (signal for reactivity). 108 pub values: Signal<InlineThemeValues>, 109 /// Control advanced options visibility. 110 /// None = auto (container query), Some(true) = always show, Some(false) = always hide. 111 #[props(default)] 112 pub show_advanced: Option<bool>, 113 /// Show live preview of theme. 114 #[props(default = false)] 115 pub show_preview: bool, 116} 117 118/// Inline theme editor with core colour pickers and code theme selector. 119#[component] 120pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { 121 let mut values = props.values; 122 123 let advanced_class = match props.show_advanced { 124 Some(true) => "inline-theme-editor-advanced force-show", 125 Some(false) => "inline-theme-editor-advanced force-hide", 126 None => "inline-theme-editor-advanced", 127 }; 128 129 rsx! { 130 document::Stylesheet { href: INLINE_THEME_EDITOR_CSS } 131 132 div { class: "inline-theme-editor", 133 h4 { class: "inline-theme-editor-heading", "Theme" } 134 135 // Main theme controls - two columns when wide. 136 div { class: "inline-theme-editor-main", 137 // Left column: colours and mode toggle. 138 div { class: "inline-theme-editor-main-left", 139 // Colour pickers (2x2 grid). 140 div { class: "inline-theme-editor-colours", 141 HexColourInput { 142 label: Some("Background".to_string()), 143 value: values().background.clone(), 144 onchange: move |val| values.write().background = val, 145 } 146 HexColourInput { 147 label: Some("Text".to_string()), 148 value: values().text.clone(), 149 onchange: move |val| values.write().text = val, 150 } 151 HexColourInput { 152 label: Some("Primary".to_string()), 153 value: values().primary.clone(), 154 onchange: move |val| values.write().primary = val, 155 } 156 HexColourInput { 157 label: Some("Link".to_string()), 158 value: values().link.clone(), 159 onchange: move |val| values.write().link = val, 160 } 161 } 162 163 // Default mode toggle group. 164 div { class: "inline-theme-editor-mode", 165 label { "Default mode:" } 166 ToggleGroup { 167 horizontal: true, 168 default_pressed: mode_to_index(&values().default_mode), 169 on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 170 if let Some(&idx) = pressed.iter().next() { 171 values.write().default_mode = index_to_mode(idx).to_string(); 172 } 173 }, 174 ToggleItem { index: 0usize, "Auto" } 175 ToggleItem { index: 1usize, "Light" } 176 ToggleItem { index: 2usize, "Dark" } 177 } 178 } 179 } 180 181 // Right column: preset and code theme dropdowns. 182 div { class: "inline-theme-editor-main-right", 183 // Preset selector. 184 div { class: "inline-theme-editor-presets", 185 label { "Preset:" } 186 select { 187 onchange: move |e: Event<FormData>| { 188 let preset_id = e.value(); 189 if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) { 190 let mut v = values.write(); 191 v.background = strip_hex_prefix(&scheme.colours.base); 192 v.text = strip_hex_prefix(&scheme.colours.text); 193 v.primary = strip_hex_prefix(&scheme.colours.primary); 194 v.link = strip_hex_prefix(&scheme.colours.link); 195 v.default_mode = scheme.variant.to_string(); 196 // Clear overrides when selecting preset. 197 v.light_background = None; 198 v.light_text = None; 199 v.light_primary = None; 200 v.light_link = None; 201 v.dark_background = None; 202 v.dark_text = None; 203 v.dark_primary = None; 204 v.dark_link = None; 205 } 206 }, 207 option { value: "", "Custom" } 208 for scheme in BUILTIN_COLOUR_SCHEMES.iter() { 209 option { 210 value: "{scheme.id}", 211 "{scheme.name}" 212 } 213 } 214 } 215 } 216 217 // Code theme selectors. 218 div { class: "inline-theme-editor-code-theme", 219 label { "Light code theme:" } 220 select { 221 value: "{values().light_code_theme}", 222 onchange: move |e: Event<FormData>| { 223 values.write().light_code_theme = e.value(); 224 }, 225 for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") { 226 option { 227 value: "{theme.id}", 228 "{theme.name}" 229 } 230 } 231 } 232 } 233 div { class: "inline-theme-editor-code-theme", 234 label { "Dark code theme:" } 235 select { 236 value: "{values().dark_code_theme}", 237 onchange: move |e: Event<FormData>| { 238 values.write().dark_code_theme = e.value(); 239 }, 240 for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") { 241 option { 242 value: "{theme.id}", 243 "{theme.name}" 244 } 245 } 246 } 247 } 248 } 249 } 250 251 // Advanced section for per-variant colour customization. 252 div { class: "{advanced_class}", 253 Collapsible { 254 CollapsibleTrigger { "Advanced colour options" } 255 CollapsibleContent { 256 div { class: "inline-theme-editor-advanced-content", 257 // Light variant colours. 258 div { class: "inline-theme-editor-variant", 259 h5 { "Light variant" } 260 div { class: "inline-theme-editor-colours", 261 HexColourInput { 262 label: Some("Background".to_string()), 263 value: values().light_background.clone().unwrap_or_default(), 264 placeholder: values().background.clone(), 265 onchange: move |val: String| { 266 values.write().light_background = if val.is_empty() { None } else { Some(val) }; 267 }, 268 } 269 HexColourInput { 270 label: Some("Text".to_string()), 271 value: values().light_text.clone().unwrap_or_default(), 272 placeholder: values().text.clone(), 273 onchange: move |val: String| { 274 values.write().light_text = if val.is_empty() { None } else { Some(val) }; 275 }, 276 } 277 HexColourInput { 278 label: Some("Primary".to_string()), 279 value: values().light_primary.clone().unwrap_or_default(), 280 placeholder: values().primary.clone(), 281 onchange: move |val: String| { 282 values.write().light_primary = if val.is_empty() { None } else { Some(val) }; 283 }, 284 } 285 HexColourInput { 286 label: Some("Link".to_string()), 287 value: values().light_link.clone().unwrap_or_default(), 288 placeholder: values().link.clone(), 289 onchange: move |val: String| { 290 values.write().light_link = if val.is_empty() { None } else { Some(val) }; 291 }, 292 } 293 } 294 } 295 296 // Dark variant colours. 297 div { class: "inline-theme-editor-variant", 298 h5 { "Dark variant" } 299 div { class: "inline-theme-editor-colours", 300 HexColourInput { 301 label: Some("Background".to_string()), 302 value: values().dark_background.clone().unwrap_or_default(), 303 placeholder: values().background.clone(), 304 onchange: move |val: String| { 305 values.write().dark_background = if val.is_empty() { None } else { Some(val) }; 306 }, 307 } 308 HexColourInput { 309 label: Some("Text".to_string()), 310 value: values().dark_text.clone().unwrap_or_default(), 311 placeholder: values().text.clone(), 312 onchange: move |val: String| { 313 values.write().dark_text = if val.is_empty() { None } else { Some(val) }; 314 }, 315 } 316 HexColourInput { 317 label: Some("Primary".to_string()), 318 value: values().dark_primary.clone().unwrap_or_default(), 319 placeholder: values().primary.clone(), 320 onchange: move |val: String| { 321 values.write().dark_primary = if val.is_empty() { None } else { Some(val) }; 322 }, 323 } 324 HexColourInput { 325 label: Some("Link".to_string()), 326 value: values().dark_link.clone().unwrap_or_default(), 327 placeholder: values().link.clone(), 328 onchange: move |val: String| { 329 values.write().dark_link = if val.is_empty() { None } else { Some(val) }; 330 }, 331 } 332 } 333 } 334 } 335 } 336 } 337 } 338 339 // Live preview section. 340 if props.show_preview { 341 PreviewSection { values } 342 } 343 } 344 } 345} 346 347/// Preview section with light/dark toggle. 348#[component] 349fn PreviewSection(values: Signal<InlineThemeValues>) -> Element { 350 let mut preview_dark = use_signal(|| false); 351 352 rsx! { 353 div { class: "inline-theme-editor-preview", 354 div { class: "inline-theme-editor-preview-header", 355 h5 { "Preview" } 356 ToggleGroup { 357 horizontal: true, 358 default_pressed: std::collections::HashSet::from([0usize]), 359 on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 360 if let Some(&idx) = pressed.iter().next() { 361 preview_dark.set(idx == 1); 362 } 363 }, 364 ToggleItem { index: 0usize, "Light" } 365 ToggleItem { index: 1usize, "Dark" } 366 } 367 } 368 ThemePreview { 369 values, 370 dark: preview_dark(), 371 } 372 } 373 } 374}