atproto blogging
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}