at main 608 lines 24 kB view raw
1//! Full theme editor component with all 16 colours, fonts, spacing. 2 3use dioxus::prelude::*; 4use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; 5 6use crate::components::HexColourInput; 7use crate::components::button::{Button, ButtonVariant}; 8use crate::components::inline_theme_editor::InlineThemeValues; 9use crate::components::select::{Select, SelectList, SelectOption, SelectTrigger, SelectValue}; 10use crate::components::theme_preview::ThemePreview; 11use crate::components::toggle_group::{ToggleGroup, ToggleItem}; 12 13const THEME_EDITOR_CSS: Asset = asset!("/assets/styling/theme-editor.css"); 14 15/// Full theme values for the theme editor. 16#[derive(Debug, Clone, PartialEq, Default)] 17pub struct ThemeEditorValues { 18 // Light scheme colours (all 16). 19 pub light: ColourSchemeValues, 20 // Dark scheme colours (all 16). 21 pub dark: ColourSchemeValues, 22 // Fonts. 23 pub font_body: String, 24 pub font_heading: String, 25 pub font_mono: String, 26 // Spacing. 27 pub spacing_base: String, 28 pub spacing_line_height: String, 29 pub spacing_scale: String, 30 // Code themes. 31 pub light_code_theme: String, 32 pub dark_code_theme: String, 33 // Default mode. 34 pub default_mode: String, 35} 36 37/// All 16 colour values for a single scheme. 38#[derive(Debug, Clone, PartialEq, Default)] 39pub struct ColourSchemeValues { 40 pub base: String, 41 pub surface: String, 42 pub overlay: String, 43 pub text: String, 44 pub muted: String, 45 pub subtle: String, 46 pub emphasis: String, 47 pub primary: String, 48 pub secondary: String, 49 pub tertiary: String, 50 pub error: String, 51 pub warning: String, 52 pub success: String, 53 pub border: String, 54 pub link: String, 55 pub highlight: String, 56} 57 58impl ThemeEditorValues { 59 /// Convert to InlineThemeValues for preview (uses primary 4 colours from light scheme). 60 pub fn to_inline_values(&self) -> InlineThemeValues { 61 InlineThemeValues { 62 background: self.light.base.clone(), 63 text: self.light.text.clone(), 64 primary: self.light.primary.clone(), 65 link: self.light.link.clone(), 66 light_background: Some(self.light.base.clone()), 67 light_text: Some(self.light.text.clone()), 68 light_primary: Some(self.light.primary.clone()), 69 light_link: Some(self.light.link.clone()), 70 dark_background: Some(self.dark.base.clone()), 71 dark_text: Some(self.dark.text.clone()), 72 dark_primary: Some(self.dark.primary.clone()), 73 dark_link: Some(self.dark.link.clone()), 74 light_code_theme: self.light_code_theme.clone(), 75 dark_code_theme: self.dark_code_theme.clone(), 76 default_mode: self.default_mode.clone(), 77 } 78 } 79 80 /// Create from a preset colour scheme. 81 pub fn from_preset(scheme_id: &str) -> Option<Self> { 82 let scheme = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == scheme_id)?; 83 let colours = &scheme.colours; 84 85 let scheme_values = ColourSchemeValues { 86 base: strip_hex_prefix(&colours.base), 87 surface: strip_hex_prefix(&colours.surface), 88 overlay: strip_hex_prefix(&colours.overlay), 89 text: strip_hex_prefix(&colours.text), 90 muted: strip_hex_prefix(&colours.muted), 91 subtle: strip_hex_prefix(&colours.subtle), 92 emphasis: strip_hex_prefix(&colours.emphasis), 93 primary: strip_hex_prefix(&colours.primary), 94 secondary: strip_hex_prefix(&colours.secondary), 95 tertiary: strip_hex_prefix(&colours.tertiary), 96 error: strip_hex_prefix(&colours.error), 97 warning: strip_hex_prefix(&colours.warning), 98 success: strip_hex_prefix(&colours.success), 99 border: strip_hex_prefix(&colours.border), 100 link: strip_hex_prefix(&colours.link), 101 highlight: strip_hex_prefix(&colours.highlight), 102 }; 103 104 // Find counterpart scheme for dark/light. 105 let is_dark = scheme.variant == "dark"; 106 let counterpart_id = if is_dark { 107 "rose-pine-dawn" 108 } else { 109 "rose-pine" 110 }; 111 let counterpart = BUILTIN_COLOUR_SCHEMES 112 .iter() 113 .find(|s| s.id == counterpart_id); 114 115 let counterpart_values = counterpart 116 .map(|c| { 117 let colours = &c.colours; 118 ColourSchemeValues { 119 base: strip_hex_prefix(&colours.base), 120 surface: strip_hex_prefix(&colours.surface), 121 overlay: strip_hex_prefix(&colours.overlay), 122 text: strip_hex_prefix(&colours.text), 123 muted: strip_hex_prefix(&colours.muted), 124 subtle: strip_hex_prefix(&colours.subtle), 125 emphasis: strip_hex_prefix(&colours.emphasis), 126 primary: strip_hex_prefix(&colours.primary), 127 secondary: strip_hex_prefix(&colours.secondary), 128 tertiary: strip_hex_prefix(&colours.tertiary), 129 error: strip_hex_prefix(&colours.error), 130 warning: strip_hex_prefix(&colours.warning), 131 success: strip_hex_prefix(&colours.success), 132 border: strip_hex_prefix(&colours.border), 133 link: strip_hex_prefix(&colours.link), 134 highlight: strip_hex_prefix(&colours.highlight), 135 } 136 }) 137 .unwrap_or_default(); 138 139 let (light, dark) = if is_dark { 140 (counterpart_values, scheme_values) 141 } else { 142 (scheme_values, counterpart_values) 143 }; 144 145 Some(Self { 146 light, 147 dark, 148 font_body: String::new(), 149 font_heading: String::new(), 150 font_mono: String::new(), 151 spacing_base: "16".to_string(), 152 spacing_line_height: "1.6".to_string(), 153 spacing_scale: "1.25".to_string(), 154 light_code_theme: "rose-pine-dawn".to_string(), 155 dark_code_theme: "rose-pine".to_string(), 156 default_mode: scheme.variant.to_string(), 157 }) 158 } 159} 160 161fn strip_hex_prefix(s: &str) -> String { 162 s.trim_start_matches('#') 163 .trim_start_matches("0x") 164 .trim_start_matches("0X") 165 .to_uppercase() 166} 167 168fn mode_to_index(mode: &str) -> std::collections::HashSet<usize> { 169 let idx = match mode { 170 "light" => 1, 171 "dark" => 2, 172 _ => 0, 173 }; 174 std::collections::HashSet::from([idx]) 175} 176 177fn index_to_mode(idx: usize) -> &'static str { 178 match idx { 179 1 => "light", 180 2 => "dark", 181 _ => "auto", 182 } 183} 184 185/// Props for ThemeEditor. 186#[derive(Props, Clone, PartialEq)] 187pub struct ThemeEditorProps { 188 /// Current theme values (signal for reactivity). 189 pub values: Signal<ThemeEditorValues>, 190 /// Callback on save. 191 pub on_save: EventHandler<ThemeEditorValues>, 192 /// Callback on cancel. 193 pub on_cancel: EventHandler<()>, 194 /// Whether save is in progress. 195 #[props(default = false)] 196 pub saving: bool, 197} 198 199/// Full theme editor with all 16 colours, fonts, spacing. 200#[component] 201pub fn ThemeEditor(props: ThemeEditorProps) -> Element { 202 let mut values = props.values; 203 204 // Preview values derived from editor values. 205 let mut preview_values = use_signal(|| values().to_inline_values()); 206 207 // Sync preview when values change. 208 use_effect(move || { 209 let v = values(); 210 preview_values.set(v.to_inline_values()); 211 }); 212 213 // Which variant is being edited (for preview). 214 let mut editing_dark = use_signal(|| false); 215 216 // Derived signals for light/dark schemes. 217 let light_scheme = use_memo(move || values().light.clone()); 218 let dark_scheme = use_memo(move || values().dark.clone()); 219 220 rsx! { 221 document::Stylesheet { href: THEME_EDITOR_CSS } 222 223 div { class: "theme-editor-page", 224 // Left column: controls. 225 div { class: "theme-editor-controls", 226 // Mode toggle. 227 ModeSection { values } 228 229 // Fonts section. 230 FontsSection { values } 231 232 // Spacing section. 233 SpacingSection { values } 234 235 // Code themes. 236 CodeThemesSection { values } 237 238 // Light scheme colours. 239 ColourSchemeSection { 240 title: "Light scheme", 241 variant: "light", 242 scheme: light_scheme, 243 on_change: move |new_scheme: ColourSchemeValues| { 244 values.write().light = new_scheme; 245 }, 246 on_focus: move |_| editing_dark.set(false), 247 } 248 249 // Dark scheme colours. 250 ColourSchemeSection { 251 title: "Dark scheme", 252 variant: "dark", 253 scheme: dark_scheme, 254 on_change: move |new_scheme: ColourSchemeValues| { 255 values.write().dark = new_scheme; 256 }, 257 on_focus: move |_| editing_dark.set(true), 258 } 259 260 // Actions. 261 div { class: "theme-editor-actions", 262 Button { 263 variant: ButtonVariant::Primary, 264 onclick: move |_| { 265 props.on_save.call(values()); 266 }, 267 disabled: props.saving, 268 if props.saving { "Saving..." } else { "Save" } 269 } 270 Button { 271 variant: ButtonVariant::Ghost, 272 onclick: move |_| { 273 props.on_cancel.call(()); 274 }, 275 disabled: props.saving, 276 "Cancel" 277 } 278 } 279 } 280 281 // Right column: preview. 282 div { class: "theme-editor-preview", 283 div { class: "theme-editor-preview-header", 284 h3 { "Preview" } 285 ToggleGroup { 286 horizontal: true, 287 default_pressed: std::collections::HashSet::from([0usize]), 288 on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 289 if let Some(&idx) = pressed.iter().next() { 290 editing_dark.set(idx == 1); 291 } 292 }, 293 ToggleItem { index: 0usize, "Light" } 294 ToggleItem { index: 1usize, "Dark" } 295 } 296 } 297 ThemePreview { 298 values: preview_values, 299 dark: editing_dark(), 300 } 301 } 302 } 303 } 304} 305 306/// Colour scheme section with all 16 colours and its own preset selector. 307#[component] 308fn ColourSchemeSection( 309 title: &'static str, 310 variant: &'static str, 311 scheme: ReadSignal<ColourSchemeValues>, 312 on_change: EventHandler<ColourSchemeValues>, 313 on_focus: EventHandler<FocusEvent>, 314) -> Element { 315 // Helper to create colour input with change handler. 316 let colour_input = 317 move |label: &'static str, value: String, setter: fn(&mut ColourSchemeValues, String)| { 318 let on_change = on_change.clone(); 319 let on_focus = on_focus.clone(); 320 let scheme = scheme.clone(); 321 rsx! { 322 HexColourInput { 323 label: Some(label.to_string()), 324 value: value, 325 onchange: move |val: String| { 326 let mut new_scheme = scheme(); 327 setter(&mut new_scheme, val); 328 on_change.call(new_scheme); 329 }, 330 onfocus: move |e| on_focus.call(e), 331 } 332 } 333 }; 334 335 let s = scheme(); 336 337 // Filter presets by variant. 338 let presets: Vec<_> = BUILTIN_COLOUR_SCHEMES 339 .iter() 340 .filter(|p| p.variant == variant) 341 .collect(); 342 343 rsx! { 344 div { class: "theme-editor-section", 345 div { class: "theme-editor-section-header", 346 h3 { "{title}" } 347 Select::<String> { 348 placeholder: "Custom", 349 on_value_change: move |val: Option<String>| { 350 let Some(val) = val else { return }; 351 let Some(preset) = BUILTIN_COLOUR_SCHEMES.iter().find(|p| p.id == val) else { return }; 352 let colours = &preset.colours; 353 on_change.call(ColourSchemeValues { 354 base: strip_hex_prefix(&colours.base), 355 surface: strip_hex_prefix(&colours.surface), 356 overlay: strip_hex_prefix(&colours.overlay), 357 text: strip_hex_prefix(&colours.text), 358 muted: strip_hex_prefix(&colours.muted), 359 subtle: strip_hex_prefix(&colours.subtle), 360 emphasis: strip_hex_prefix(&colours.emphasis), 361 primary: strip_hex_prefix(&colours.primary), 362 secondary: strip_hex_prefix(&colours.secondary), 363 tertiary: strip_hex_prefix(&colours.tertiary), 364 error: strip_hex_prefix(&colours.error), 365 warning: strip_hex_prefix(&colours.warning), 366 success: strip_hex_prefix(&colours.success), 367 border: strip_hex_prefix(&colours.border), 368 link: strip_hex_prefix(&colours.link), 369 highlight: strip_hex_prefix(&colours.highlight), 370 }); 371 }, 372 SelectTrigger { 373 SelectValue {} 374 } 375 SelectList { 376 for (idx, preset) in presets.iter().enumerate() { 377 SelectOption::<String> { 378 index: idx, 379 value: preset.id.to_string(), 380 text_value: preset.name.to_string(), 381 "{preset.name}" 382 } 383 } 384 } 385 } 386 } 387 388 // Background colours. 389 div { class: "theme-editor-colour-group", 390 span { class: "theme-editor-colour-group-label", "Background" } 391 div { class: "theme-editor-colour-group-items", 392 {colour_input("Base", s.base.clone(), |s, v| s.base = v)} 393 {colour_input("Surface", s.surface.clone(), |s, v| s.surface = v)} 394 {colour_input("Overlay", s.overlay.clone(), |s, v| s.overlay = v)} 395 } 396 } 397 398 // Text colours. 399 div { class: "theme-editor-colour-group", 400 span { class: "theme-editor-colour-group-label", "Text" } 401 div { class: "theme-editor-colour-group-items", 402 {colour_input("Text", s.text.clone(), |s, v| s.text = v)} 403 {colour_input("Muted", s.muted.clone(), |s, v| s.muted = v)} 404 {colour_input("Subtle", s.subtle.clone(), |s, v| s.subtle = v)} 405 {colour_input("Emphasis", s.emphasis.clone(), |s, v| s.emphasis = v)} 406 } 407 } 408 409 // Accent colours. 410 div { class: "theme-editor-colour-group", 411 span { class: "theme-editor-colour-group-label", "Accents" } 412 div { class: "theme-editor-colour-group-items", 413 {colour_input("Primary", s.primary.clone(), |s, v| s.primary = v)} 414 {colour_input("Secondary", s.secondary.clone(), |s, v| s.secondary = v)} 415 {colour_input("Tertiary", s.tertiary.clone(), |s, v| s.tertiary = v)} 416 {colour_input("Link", s.link.clone(), |s, v| s.link = v)} 417 } 418 } 419 420 // Status colours. 421 div { class: "theme-editor-colour-group", 422 span { class: "theme-editor-colour-group-label", "Status" } 423 div { class: "theme-editor-colour-group-items", 424 {colour_input("Error", s.error.clone(), |s, v| s.error = v)} 425 {colour_input("Warning", s.warning.clone(), |s, v| s.warning = v)} 426 {colour_input("Success", s.success.clone(), |s, v| s.success = v)} 427 } 428 } 429 430 // UI colours. 431 div { class: "theme-editor-colour-group", 432 span { class: "theme-editor-colour-group-label", "UI" } 433 div { class: "theme-editor-colour-group-items", 434 {colour_input("Border", s.border.clone(), |s, v| s.border = v)} 435 {colour_input("Highlight", s.highlight.clone(), |s, v| s.highlight = v)} 436 } 437 } 438 } 439 } 440} 441 442/// Fonts section. 443#[component] 444fn FontsSection(values: Signal<ThemeEditorValues>) -> Element { 445 rsx! { 446 div { class: "theme-editor-section", 447 h3 { "Fonts" } 448 div { class: "theme-editor-fonts", 449 div { class: "theme-editor-font-field", 450 label { "Body" } 451 input { 452 r#type: "text", 453 value: "{values().font_body}", 454 placeholder: "IBM Plex Serif", 455 oninput: move |e| values.write().font_body = e.value(), 456 } 457 } 458 div { class: "theme-editor-font-field", 459 label { "Heading" } 460 input { 461 r#type: "text", 462 value: "{values().font_heading}", 463 placeholder: "IBM Plex Sans", 464 oninput: move |e| values.write().font_heading = e.value(), 465 } 466 } 467 div { class: "theme-editor-font-field", 468 label { "Monospace" } 469 input { 470 r#type: "text", 471 value: "{values().font_mono}", 472 placeholder: "IBM Plex Mono", 473 oninput: move |e| values.write().font_mono = e.value(), 474 } 475 } 476 } 477 } 478 } 479} 480 481/// Spacing section. 482#[component] 483fn SpacingSection(values: Signal<ThemeEditorValues>) -> Element { 484 rsx! { 485 div { class: "theme-editor-section", 486 h3 { "Spacing" } 487 div { class: "theme-editor-spacing", 488 div { class: "theme-editor-spacing-field", 489 label { "Base size (px)" } 490 input { 491 r#type: "number", 492 value: "{values().spacing_base}", 493 oninput: move |e| values.write().spacing_base = e.value(), 494 } 495 } 496 div { class: "theme-editor-spacing-field", 497 label { "Line height" } 498 input { 499 r#type: "text", 500 value: "{values().spacing_line_height}", 501 oninput: move |e| values.write().spacing_line_height = e.value(), 502 } 503 } 504 div { class: "theme-editor-spacing-field", 505 label { "Scale" } 506 input { 507 r#type: "text", 508 value: "{values().spacing_scale}", 509 oninput: move |e| values.write().spacing_scale = e.value(), 510 } 511 } 512 } 513 } 514 } 515} 516 517/// Code themes section. 518#[component] 519fn CodeThemesSection(values: Signal<ThemeEditorValues>) -> Element { 520 let light_themes: Vec<_> = BUILTIN_CODE_THEMES 521 .iter() 522 .filter(|t| t.variant == "light") 523 .collect(); 524 let dark_themes: Vec<_> = BUILTIN_CODE_THEMES 525 .iter() 526 .filter(|t| t.variant == "dark") 527 .collect(); 528 529 rsx! { 530 div { class: "theme-editor-section", 531 h3 { "Code themes" } 532 div { class: "theme-editor-code-themes", 533 div { class: "theme-editor-code-theme", 534 label { "Light" } 535 Select::<String> { 536 value: Some(values().light_code_theme.clone()), 537 on_value_change: move |val: Option<String>| { 538 if let Some(val) = val { 539 values.write().light_code_theme = val; 540 } 541 }, 542 SelectTrigger { 543 SelectValue {} 544 } 545 SelectList { 546 for (idx, theme) in light_themes.iter().enumerate() { 547 SelectOption::<String> { 548 index: idx, 549 value: theme.id.to_string(), 550 text_value: theme.name.to_string(), 551 "{theme.name}" 552 } 553 } 554 } 555 } 556 } 557 div { class: "theme-editor-code-theme", 558 label { "Dark" } 559 Select::<String> { 560 value: Some(values().dark_code_theme.clone()), 561 on_value_change: move |val: Option<String>| { 562 if let Some(val) = val { 563 values.write().dark_code_theme = val; 564 } 565 }, 566 SelectTrigger { 567 SelectValue {} 568 } 569 SelectList { 570 for (idx, theme) in dark_themes.iter().enumerate() { 571 SelectOption::<String> { 572 index: idx, 573 value: theme.id.to_string(), 574 text_value: theme.name.to_string(), 575 "{theme.name}" 576 } 577 } 578 } 579 } 580 } 581 } 582 } 583 } 584} 585 586/// Mode section. 587#[component] 588fn ModeSection(values: Signal<ThemeEditorValues>) -> Element { 589 rsx! { 590 div { class: "theme-editor-section", 591 div { class: "theme-editor-mode", 592 label { "Default mode:" } 593 ToggleGroup { 594 horizontal: true, 595 default_pressed: mode_to_index(&values().default_mode), 596 on_pressed_change: move |pressed: std::collections::HashSet<usize>| { 597 if let Some(&idx) = pressed.iter().next() { 598 values.write().default_mode = index_to_mode(idx).to_string(); 599 } 600 }, 601 ToggleItem { index: 0usize, "Auto" } 602 ToggleItem { index: 1usize, "Light" } 603 ToggleItem { index: 2usize, "Dark" } 604 } 605 } 606 } 607 } 608}