1pub mod keys;
2pub(crate) mod registry;
3
4use fret_core::{Color, Corners, Px, TextStyle, window::ColorScheme};
5use serde::{Deserialize, Serialize};
6use std::{
7 collections::{HashMap, HashSet},
8 sync::{Arc, Mutex, OnceLock},
9};
10
11use crate::UiHost;
12use crate::theme_registry::{ThemeTokenKind, canonicalize_token_key};
13use crate::{ThemeColorKey, ThemeMetricKey, ThemeNamedColorKey};
14
15const FALLBACK_COLOR: Color = Color {
16 r: 1.0,
17 g: 0.0,
18 b: 1.0,
19 a: 1.0,
20};
21
22fn warn_invalid_default_theme_color_once(name: &str, value: &str) -> bool {
23 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
24
25 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
26 let mut seen = match seen.lock() {
27 Ok(guard) => guard,
28 Err(poisoned) => poisoned.into_inner(),
29 };
30
31 let k = format!("{name}:{value}");
32 if !seen.insert(k) {
33 return false;
34 }
35
36 tracing::warn!(
37 color_name = name,
38 color_value = value,
39 "invalid default theme color; using fallback"
40 );
41 true
42}
43
44fn parse_default_theme_hex_color(name: &str, value: &str) -> Color {
45 match parse_hex_srgb_to_linear(value) {
46 Some(color) => color,
47 None => {
48 if strict_theme_enabled() {
49 panic!("invalid default theme color {name}: {value}");
50 }
51 warn_invalid_default_theme_color_once(name, value);
52 FALLBACK_COLOR
53 }
54 }
55}
56
57fn fallback_easing() -> CubicBezier {
58 CubicBezier {
59 x1: 0.0,
60 y1: 0.0,
61 x2: 1.0,
62 y2: 1.0,
63 }
64}
65
66#[cfg(not(test))]
67fn strict_theme_enabled() -> bool {
68 static STRICT: OnceLock<bool> = OnceLock::new();
69 *STRICT.get_or_init(fret_runtime::strict_runtime::strict_runtime_enabled_from_env)
70}
71
72#[cfg(test)]
73thread_local! {
74 static STRICT_THEME_OVERRIDE: std::cell::Cell<Option<bool>> =
75 const { std::cell::Cell::new(None) };
76}
77
78#[cfg(test)]
79fn strict_theme_enabled() -> bool {
80 STRICT_THEME_OVERRIDE
81 .with(|cell| cell.get())
82 .unwrap_or_else(fret_runtime::strict_runtime::strict_runtime_enabled_from_env)
83}
84
85#[cfg(test)]
86struct StrictThemeGuard(Option<bool>);
87
88#[cfg(test)]
89fn strict_theme_for_tests(value: bool) -> StrictThemeGuard {
90 let prev = STRICT_THEME_OVERRIDE.with(|cell| {
91 let prev = cell.get();
92 cell.set(Some(value));
93 prev
94 });
95 StrictThemeGuard(prev)
96}
97
98#[cfg(test)]
99impl Drop for StrictThemeGuard {
100 fn drop(&mut self) {
101 STRICT_THEME_OVERRIDE.with(|cell| cell.set(self.0));
102 }
103}
104
105fn warn_missing_theme_token_once(kind: ThemeTokenKind, key: &str) -> bool {
106 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
107
108 let canonical = canonicalize_token_key(kind, key);
109 if canonical.is_empty() {
110 return false;
111 }
112
113 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
114 let mut seen = match seen.lock() {
115 Ok(guard) => guard,
116 Err(poisoned) => poisoned.into_inner(),
117 };
118
119 let k = format!("{kind:?}:{canonical}");
120 if !seen.insert(k) {
121 return false;
122 }
123
124 tracing::warn!(
125 token_kind = ?kind,
126 token_key = canonical,
127 "missing theme token; using fallback"
128 );
129 true
130}
131
132fn fallback_color_by_key(key: &str) -> Color {
133 default_theme().color_by_key(key).unwrap_or(FALLBACK_COLOR)
134}
135
136fn fallback_metric_by_key(key: &str) -> Px {
137 default_theme().metric_by_key(key).unwrap_or(Px(0.0))
138}
139
140fn fallback_corners_by_key(key: &str) -> Corners {
141 default_theme()
142 .corners_by_key(key)
143 .unwrap_or_else(|| Corners::all(Px(0.0)))
144}
145
146fn fallback_number_by_key(key: &str) -> f32 {
147 default_theme().number_by_key(key).unwrap_or(0.0)
148}
149
150fn fallback_duration_ms_by_key(key: &str) -> u32 {
151 default_theme().duration_ms_by_key(key).unwrap_or(0)
152}
153
154fn fallback_easing_by_key(key: &str) -> CubicBezier {
155 default_theme()
156 .easing_by_key(key)
157 .unwrap_or_else(fallback_easing)
158}
159
160fn fallback_text_style_by_key(key: &str) -> TextStyle {
161 default_theme().text_style_by_key(key).unwrap_or_default()
162}
163
164fn canonicalize_config_map<V: Clone>(
165 kind: ThemeTokenKind,
166 map: &HashMap<String, V>,
167) -> HashMap<String, V> {
168 let mut out: HashMap<String, V> = HashMap::new();
169
170 let mut keys: Vec<&String> = map.keys().collect();
173 keys.sort_by(|a, b| a.trim().cmp(b.trim()).then_with(|| a.cmp(b)));
174
175 for k in keys.iter().copied() {
177 let trimmed = k.trim();
178 if trimmed.is_empty() {
179 continue;
180 }
181 let canon = canonicalize_token_key(kind, trimmed);
182 if canon == trimmed {
183 if let Some(v) = map.get(k) {
186 out.insert(canon.to_string(), v.clone());
187 }
188 }
189 }
190
191 for k in keys.iter().copied() {
193 let trimmed = k.trim();
194 if trimmed.is_empty() {
195 continue;
196 }
197 let canon = canonicalize_token_key(kind, trimmed);
198 if canon == trimmed {
199 continue;
200 }
201 if let Some(v) = map.get(k) {
202 out.entry(canon.to_string()).or_insert_with(|| v.clone());
203 }
204 }
205
206 out
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
210pub struct CubicBezier {
211 pub x1: f32,
212 pub y1: f32,
213 pub x2: f32,
214 pub y2: f32,
215}
216
217fn default_color_tokens(colors: ThemeColors) -> HashMap<String, Color> {
218 let mut out = HashMap::from([
219 (
220 "color.surface.background".to_string(),
221 colors.surface_background,
222 ),
223 (
224 "color.panel.background".to_string(),
225 colors.panel_background,
226 ),
227 ("color.panel.border".to_string(), colors.panel_border),
228 ("color.text.primary".to_string(), colors.text_primary),
229 ("color.text.muted".to_string(), colors.text_muted),
230 ("color.text.disabled".to_string(), colors.text_disabled),
231 ("color.accent".to_string(), colors.accent),
232 (
233 "color.selection.background".to_string(),
234 colors.selection_background,
235 ),
236 (
237 "color.selection.inactive.background".to_string(),
238 colors.selection_inactive_background,
239 ),
240 (
241 "color.selection.window_inactive.background".to_string(),
242 colors.selection_window_inactive_background,
243 ),
244 (
245 "color.hover.background".to_string(),
246 colors.hover_background,
247 ),
248 ("color.focus.ring".to_string(), colors.focus_ring),
249 ("color.menu.background".to_string(), colors.menu_background),
250 ("color.menu.border".to_string(), colors.menu_border),
251 ("color.menu.item.hover".to_string(), colors.menu_item_hover),
252 (
253 "color.menu.item.selected".to_string(),
254 colors.menu_item_selected,
255 ),
256 ("color.list.background".to_string(), colors.list_background),
257 ("color.list.border".to_string(), colors.list_border),
258 ("color.list.row.hover".to_string(), colors.list_row_hover),
259 (
260 "color.list.row.selected".to_string(),
261 colors.list_row_selected,
262 ),
263 ("color.scrollbar.track".to_string(), colors.scrollbar_track),
264 ("color.scrollbar.thumb".to_string(), colors.scrollbar_thumb),
265 (
266 "color.scrollbar.thumb.hover".to_string(),
267 colors.scrollbar_thumb_hover,
268 ),
269 (
270 "color.viewport.selection.fill".to_string(),
271 colors.viewport_selection_fill,
272 ),
273 (
274 "color.viewport.selection.stroke".to_string(),
275 colors.viewport_selection_stroke,
276 ),
277 ("color.viewport.marker".to_string(), colors.viewport_marker),
278 (
279 "color.viewport.drag_line.pan".to_string(),
280 colors.viewport_drag_line_pan,
281 ),
282 (
283 "color.viewport.drag_line.orbit".to_string(),
284 colors.viewport_drag_line_orbit,
285 ),
286 (
287 "color.viewport.gizmo.x".to_string(),
288 colors.viewport_gizmo_x,
289 ),
290 (
291 "color.viewport.gizmo.y".to_string(),
292 colors.viewport_gizmo_y,
293 ),
294 (
295 "color.viewport.gizmo.handle.background".to_string(),
296 colors.viewport_gizmo_handle_background,
297 ),
298 (
299 "color.viewport.gizmo.handle.border".to_string(),
300 colors.viewport_gizmo_handle_border,
301 ),
302 (
303 "color.viewport.rotate_gizmo".to_string(),
304 colors.viewport_rotate_gizmo,
305 ),
306 ]);
307
308 out.insert(
311 "color.viewport.gizmo.z".to_string(),
312 Color::from_srgb_hex_rgb(0x33_80_ff),
313 );
314 out.insert(
315 "color.viewport.gizmo.hover".to_string(),
316 colors.viewport_rotate_gizmo,
317 );
318 out.insert(
319 "color.viewport.view_gizmo.face".to_string(),
320 Color {
321 a: 0.35,
322 ..Color::from_srgb_hex_rgb(0x38_38_3d)
323 },
324 );
325 out.insert(
326 "color.viewport.view_gizmo.edge".to_string(),
327 Color {
328 a: 0.90,
329 ..Color::from_srgb_hex_rgb(0xf2_f2_fa)
330 },
331 );
332
333 out.insert("background".to_string(), colors.surface_background);
335 out.insert("foreground".to_string(), colors.text_primary);
336 out.insert("border".to_string(), colors.panel_border);
337 out.insert("input".to_string(), colors.panel_border);
338 out.insert("ring".to_string(), colors.focus_ring);
339 out.insert(
340 "ring-offset-background".to_string(),
341 colors.surface_background,
342 );
343 out.insert(
345 "white".to_string(),
346 Color {
347 r: 1.0,
348 g: 1.0,
349 b: 1.0,
350 a: 1.0,
351 },
352 );
353 out.insert(
354 "black".to_string(),
355 Color {
356 r: 0.0,
357 g: 0.0,
358 b: 0.0,
359 a: 1.0,
360 },
361 );
362
363 out.insert("card".to_string(), colors.panel_background);
364 out.insert("card-foreground".to_string(), colors.text_primary);
365
366 out.insert("popover".to_string(), colors.menu_background);
367 out.insert("popover-foreground".to_string(), colors.text_primary);
368 out.insert("popover.border".to_string(), colors.menu_border);
369
370 out.insert("muted".to_string(), colors.panel_background);
371 out.insert("muted-foreground".to_string(), colors.text_muted);
372
373 out.insert("accent".to_string(), colors.hover_background);
374 out.insert("accent-foreground".to_string(), colors.text_primary);
375
376 out.insert("primary".to_string(), colors.accent);
377 out.insert("primary-foreground".to_string(), colors.text_primary);
378
379 out.insert("secondary".to_string(), colors.panel_background);
380 out.insert("secondary-foreground".to_string(), colors.text_primary);
381
382 out.insert("destructive".to_string(), colors.viewport_gizmo_x);
383 out.insert("destructive-foreground".to_string(), colors.text_primary);
384
385 out.insert(
390 "chart-1".to_string(),
391 parse_default_theme_hex_color("chart-1", "#93C5FD"),
392 );
393 out.insert(
394 "chart-2".to_string(),
395 parse_default_theme_hex_color("chart-2", "#3B82F6"),
396 );
397 out.insert(
398 "chart-3".to_string(),
399 parse_default_theme_hex_color("chart-3", "#2563EB"),
400 );
401 out.insert(
402 "chart-4".to_string(),
403 parse_default_theme_hex_color("chart-4", "#1D4ED8"),
404 );
405 out.insert(
406 "chart-5".to_string(),
407 parse_default_theme_hex_color("chart-5", "#1E40AF"),
408 );
409
410 out.insert("sidebar".to_string(), colors.panel_background);
411 out.insert("sidebar-foreground".to_string(), colors.text_primary);
412 out.insert("sidebar-primary".to_string(), colors.accent);
413 out.insert(
414 "sidebar-primary-foreground".to_string(),
415 colors.text_primary,
416 );
417 out.insert("sidebar-accent".to_string(), colors.hover_background);
418 out.insert("sidebar-accent-foreground".to_string(), colors.text_primary);
419 out.insert("sidebar-border".to_string(), colors.panel_border);
420 out.insert("sidebar-ring".to_string(), colors.focus_ring);
421
422 out.insert(
424 "selection.background".to_string(),
425 colors.selection_background,
426 );
427 out.insert(
428 "selection.inactive.background".to_string(),
429 colors.selection_inactive_background,
430 );
431 out.insert(
432 "selection.window_inactive.background".to_string(),
433 colors.selection_window_inactive_background,
434 );
435 out.insert("input.background".to_string(), colors.panel_background);
436 out.insert("input.foreground".to_string(), colors.text_primary);
437 out.insert("caret".to_string(), colors.text_primary);
438 out.insert("scrollbar.background".to_string(), colors.scrollbar_track);
439 out.insert(
441 "scrollbar.track.background".to_string(),
442 colors.scrollbar_track,
443 );
444 out.insert(
445 "scrollbar.thumb.background".to_string(),
446 colors.scrollbar_thumb,
447 );
448 out.insert(
449 "scrollbar.thumb.hover.background".to_string(),
450 colors.scrollbar_thumb_hover,
451 );
452
453 out
454}
455
456fn default_metric_tokens(metrics: ThemeMetrics) -> HashMap<String, Px> {
457 let mut out = HashMap::from([
458 ("metric.radius.sm".to_string(), metrics.radius_sm),
459 ("metric.radius.md".to_string(), metrics.radius_md),
460 ("metric.radius.lg".to_string(), metrics.radius_lg),
461 ("metric.padding.sm".to_string(), metrics.padding_sm),
462 ("metric.padding.md".to_string(), metrics.padding_md),
463 (
464 "metric.scrollbar.width".to_string(),
465 metrics.scrollbar_width,
466 ),
467 ("metric.font.size".to_string(), metrics.font_size),
468 ("metric.font.mono_size".to_string(), metrics.mono_font_size),
469 (
470 "metric.font.line_height".to_string(),
471 metrics.font_line_height,
472 ),
473 (
474 "metric.font.mono_line_height".to_string(),
475 metrics.mono_font_line_height,
476 ),
477 ]);
478
479 out.insert("radius".to_string(), metrics.radius_sm);
481 out.insert("radius.lg".to_string(), metrics.radius_md);
482 out.insert("font.size".to_string(), metrics.font_size);
483 out.insert("mono_font.size".to_string(), metrics.mono_font_size);
484 out.insert("font.line_height".to_string(), metrics.font_line_height);
485 out.insert(
486 "mono_font.line_height".to_string(),
487 metrics.mono_font_line_height,
488 );
489
490 out.insert("component.text.sm_px".to_string(), metrics.font_size);
496 out.insert(
497 "component.text.sm_line_height".to_string(),
498 metrics.font_line_height,
499 );
500 out.insert(
501 "component.text.xs_px".to_string(),
502 Px((metrics.font_size.0 - 1.0).max(1.0)),
503 );
504 out.insert(
505 "component.text.xs_line_height".to_string(),
506 metrics.font_line_height,
507 );
508 out.insert(
509 "component.text.base_px".to_string(),
510 Px(metrics.font_size.0 + 1.0),
511 );
512 out.insert(
513 "component.text.base_line_height".to_string(),
514 metrics.font_line_height,
515 );
516 out.insert(
517 "component.text.prose_px".to_string(),
518 Px(metrics.font_size.0 + 3.0),
519 );
520 out.insert(
521 "component.text.prose_line_height".to_string(),
522 Px((metrics.font_line_height.0 + 8.0).max(metrics.font_size.0 + 10.0)),
523 );
524
525 out.insert("metric.gap.sm".to_string(), metrics.padding_sm);
527 out.insert("component.size.sm.icon_button.size".to_string(), Px(32.0));
528 out.insert("component.size.md.icon_button.size".to_string(), Px(36.0));
529 out.insert("component.size.lg.icon_button.size".to_string(), Px(40.0));
530
531 out.insert("metric.size.sm".to_string(), Px(32.0));
534 out.insert("metric.size.md".to_string(), Px(36.0));
535 out.insert("metric.size.lg".to_string(), Px(40.0));
536
537 let code_block_max_height =
542 Px((metrics.mono_font_line_height.0 * 16.0).max(metrics.mono_font_size.0 * 18.0));
543 out.insert(
544 "fret.markdown.code_block.max_height".to_string(),
545 code_block_max_height,
546 );
547
548 out
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(default)]
553pub struct ThemeConfig {
554 pub name: String,
555 pub author: Option<String>,
556 pub url: Option<String>,
557 pub color_scheme: Option<ColorScheme>,
562 pub colors: HashMap<String, String>,
563 pub metrics: HashMap<String, f32>,
564 pub corners: HashMap<String, Corners>,
565 pub numbers: HashMap<String, f32>,
566 pub durations_ms: HashMap<String, u32>,
567 pub easings: HashMap<String, CubicBezier>,
568 pub text_styles: HashMap<String, TextStyle>,
569}
570
571impl Default for ThemeConfig {
572 fn default() -> Self {
573 Self {
574 name: "Default".to_string(),
575 author: None,
576 url: None,
577 color_scheme: None,
578 colors: HashMap::new(),
579 metrics: HashMap::new(),
580 corners: HashMap::new(),
581 numbers: HashMap::new(),
582 durations_ms: HashMap::new(),
583 easings: HashMap::new(),
584 text_styles: HashMap::new(),
585 }
586 }
587}
588
589impl ThemeConfig {
590 pub fn from_slice(bytes: &[u8]) -> Result<Self, serde_json::Error> {
591 serde_json::from_slice(bytes)
592 }
593}
594
595#[derive(Debug, Clone, Copy)]
596pub struct ThemeMetrics {
597 pub radius_sm: Px,
598 pub radius_md: Px,
599 pub radius_lg: Px,
600 pub padding_sm: Px,
601 pub padding_md: Px,
602 pub scrollbar_width: Px,
603 pub font_size: Px,
604 pub mono_font_size: Px,
605 pub font_line_height: Px,
606 pub mono_font_line_height: Px,
607}
608
609#[derive(Debug, Clone, Copy)]
610pub struct ThemeColors {
611 pub surface_background: Color,
612 pub panel_background: Color,
613 pub panel_border: Color,
614
615 pub text_primary: Color,
616 pub text_muted: Color,
617 pub text_disabled: Color,
618
619 pub accent: Color,
620 pub selection_background: Color,
621 pub selection_inactive_background: Color,
622 pub selection_window_inactive_background: Color,
623 pub hover_background: Color,
624 pub focus_ring: Color,
625
626 pub menu_background: Color,
627 pub menu_border: Color,
628 pub menu_item_hover: Color,
629 pub menu_item_selected: Color,
630
631 pub list_background: Color,
632 pub list_border: Color,
633 pub list_row_hover: Color,
634 pub list_row_selected: Color,
635
636 pub scrollbar_track: Color,
637 pub scrollbar_thumb: Color,
638 pub scrollbar_thumb_hover: Color,
639
640 pub viewport_selection_fill: Color,
641 pub viewport_selection_stroke: Color,
642 pub viewport_marker: Color,
643 pub viewport_drag_line_pan: Color,
644 pub viewport_drag_line_orbit: Color,
645 pub viewport_gizmo_x: Color,
646 pub viewport_gizmo_y: Color,
647 pub viewport_gizmo_handle_background: Color,
648 pub viewport_gizmo_handle_border: Color,
649 pub viewport_rotate_gizmo: Color,
650}
651
652#[derive(Debug, Clone)]
653pub struct ThemeSnapshot {
654 pub colors: ThemeColors,
655 pub metrics: ThemeMetrics,
656 pub color_scheme: Option<ColorScheme>,
657 pub revision: u64,
658 color_tokens: Arc<HashMap<String, Color>>,
659 metric_tokens: Arc<HashMap<String, Px>>,
660}
661
662impl ThemeSnapshot {
663 pub fn from_baseline(colors: ThemeColors, metrics: ThemeMetrics, revision: u64) -> Self {
664 Self {
665 colors,
666 metrics,
667 color_scheme: None,
668 revision,
669 color_tokens: Arc::new(default_color_tokens(colors)),
670 metric_tokens: Arc::new(default_metric_tokens(metrics)),
671 }
672 }
673
674 pub fn color_by_key(&self, key: &str) -> Option<Color> {
675 let key = canonicalize_token_key(ThemeTokenKind::Color, key);
676 self.color_tokens.get(key).copied()
677 }
678
679 pub fn color_required(&self, key: &str) -> Color {
680 if let Some(v) = self.color_by_key(key) {
681 return v;
682 }
683
684 if strict_theme_enabled() {
685 panic!("missing theme color token {key}");
686 }
687 warn_missing_theme_token_once(ThemeTokenKind::Color, key);
688 fallback_color_by_key(key)
689 }
690
691 pub fn color_token(&self, key: &str) -> Color {
693 self.color_required(key)
694 }
695
696 pub fn named_color(&self, key: ThemeNamedColorKey) -> Color {
698 self.color_token(key.canonical_name())
699 }
700
701 pub fn metric_by_key(&self, key: &str) -> Option<Px> {
702 let key = canonicalize_token_key(ThemeTokenKind::Metric, key);
703 self.metric_tokens.get(key).copied()
704 }
705
706 pub fn metric_required(&self, key: &str) -> Px {
707 if let Some(v) = self.metric_by_key(key) {
708 return v;
709 }
710
711 if strict_theme_enabled() {
712 panic!("missing theme metric token {key}");
713 }
714 warn_missing_theme_token_once(ThemeTokenKind::Metric, key);
715 fallback_metric_by_key(key)
716 }
717
718 pub fn metric_token(&self, key: &str) -> Px {
720 self.metric_required(key)
721 }
722}
723
724#[derive(Debug, Clone)]
725pub struct Theme {
726 pub name: String,
727 pub author: Option<String>,
728 pub url: Option<String>,
729 pub color_scheme: Option<ColorScheme>,
730 pub colors: ThemeColors,
731 pub metrics: ThemeMetrics,
732 extra_colors: Arc<HashMap<String, Color>>,
733 extra_metrics: Arc<HashMap<String, Px>>,
734 extra_corners: HashMap<String, Corners>,
735 extra_numbers: HashMap<String, f32>,
736 extra_durations_ms: HashMap<String, u32>,
737 extra_easings: HashMap<String, CubicBezier>,
738 extra_text_styles: HashMap<String, TextStyle>,
739 configured_colors: HashSet<String>,
740 configured_metrics: HashSet<String>,
741 configured_corners: HashSet<String>,
742 configured_numbers: HashSet<String>,
743 configured_durations_ms: HashSet<String>,
744 configured_easings: HashSet<String>,
745 configured_text_styles: HashSet<String>,
746 revision: u64,
747}
748
749impl Theme {
750 pub fn revision(&self) -> u64 {
751 self.revision
752 }
753
754 pub fn color(&self, key: ThemeColorKey) -> Color {
755 let name = key.canonical_name();
756 if let Some(v) = self.color_by_key(name) {
757 return v;
758 }
759
760 if strict_theme_enabled() {
761 panic!("missing core theme color key {}", name);
762 }
763 warn_missing_theme_token_once(ThemeTokenKind::Color, name);
764 fallback_color_by_key(name)
765 }
766
767 pub fn metric(&self, key: ThemeMetricKey) -> Px {
768 let name = key.canonical_name();
769 if let Some(v) = self.metric_by_key(name) {
770 return v;
771 }
772
773 if strict_theme_enabled() {
774 panic!("missing core theme metric key {}", name);
775 }
776 warn_missing_theme_token_once(ThemeTokenKind::Metric, name);
777 fallback_metric_by_key(name)
778 }
779
780 pub fn color_by_key(&self, key: &str) -> Option<Color> {
781 let key = canonicalize_token_key(ThemeTokenKind::Color, key);
782 self.extra_colors.get(key).copied()
783 }
784
785 pub fn color_required(&self, key: &str) -> Color {
786 if let Some(v) = self.color_by_key(key) {
787 return v;
788 }
789
790 if strict_theme_enabled() {
791 panic!("missing theme color token {key}");
792 }
793 warn_missing_theme_token_once(ThemeTokenKind::Color, key);
794 fallback_color_by_key(key)
795 }
796
797 pub fn color_token(&self, key: &str) -> Color {
799 self.color_required(key)
800 }
801
802 pub fn syntax_color(&self, highlight: &str) -> Option<Color> {
810 let mut cur = Some(highlight);
811 while let Some(name) = cur {
812 let mut key = String::with_capacity("color.syntax.".len() + name.len());
813 key.push_str("color.syntax.");
814 key.push_str(name);
815 if let Some(c) = self.color_by_key(key.as_str()) {
816 return Some(c);
817 }
818 cur = name.rsplit_once('.').map(|(prefix, _)| prefix);
819 }
820
821 let fallback = highlight.split('.').next().unwrap_or(highlight);
822 match fallback {
823 "comment" => Some(self.color_token("muted-foreground")),
824 "keyword" | "operator" => Some(self.color_token("primary")),
825 "property" | "variable" => Some(self.color_token("foreground")),
826 "punctuation" => Some(self.color_token("muted-foreground")),
827
828 "string" => Some(self.color_token("foreground")),
829 "number" | "boolean" | "constant" => Some(self.color_token("primary")),
830 "type" | "constructor" | "function" => Some(self.color_token("foreground")),
831 _ => None,
832 }
833 }
834
835 pub fn named_color(&self, key: ThemeNamedColorKey) -> Color {
837 self.color_token(key.canonical_name())
838 }
839
840 pub fn metric_by_key(&self, key: &str) -> Option<Px> {
841 let key = canonicalize_token_key(ThemeTokenKind::Metric, key);
842 self.extra_metrics.get(key).copied()
843 }
844
845 pub fn metric_required(&self, key: &str) -> Px {
846 if let Some(v) = self.metric_by_key(key) {
847 return v;
848 }
849
850 if strict_theme_enabled() {
851 panic!("missing theme metric token {key}");
852 }
853 warn_missing_theme_token_once(ThemeTokenKind::Metric, key);
854 fallback_metric_by_key(key)
855 }
856
857 pub fn metric_token(&self, key: &str) -> Px {
859 self.metric_required(key)
860 }
861
862 pub fn corners_by_key(&self, key: &str) -> Option<Corners> {
863 let key = canonicalize_token_key(ThemeTokenKind::Corners, key);
864 self.extra_corners
865 .get(key)
866 .copied()
867 .or_else(|| self.metric_by_key(key).map(Corners::all))
868 }
869
870 pub fn corners_required(&self, key: &str) -> Corners {
871 if let Some(v) = self.corners_by_key(key) {
872 return v;
873 }
874
875 if strict_theme_enabled() {
876 panic!("missing theme corners token {key}");
877 }
878 warn_missing_theme_token_once(ThemeTokenKind::Corners, key);
879 fallback_corners_by_key(key)
880 }
881
882 pub fn corners_token(&self, key: &str) -> Corners {
884 self.corners_required(key)
885 }
886
887 pub fn number_by_key(&self, key: &str) -> Option<f32> {
888 let key = canonicalize_token_key(ThemeTokenKind::Number, key);
889 self.extra_numbers.get(key).copied()
890 }
891
892 pub fn number_required(&self, key: &str) -> f32 {
893 if let Some(v) = self.number_by_key(key) {
894 return v;
895 }
896
897 if strict_theme_enabled() {
898 panic!("missing theme number token {key}");
899 }
900 warn_missing_theme_token_once(ThemeTokenKind::Number, key);
901 fallback_number_by_key(key)
902 }
903
904 pub fn number_token(&self, key: &str) -> f32 {
906 self.number_required(key)
907 }
908
909 pub fn duration_ms_by_key(&self, key: &str) -> Option<u32> {
910 let key = canonicalize_token_key(ThemeTokenKind::DurationMs, key);
911 self.extra_durations_ms.get(key).copied()
912 }
913
914 pub fn duration_ms_required(&self, key: &str) -> u32 {
915 if let Some(v) = self.duration_ms_by_key(key) {
916 return v;
917 }
918
919 if strict_theme_enabled() {
920 panic!("missing theme duration_ms token {key}");
921 }
922 warn_missing_theme_token_once(ThemeTokenKind::DurationMs, key);
923 fallback_duration_ms_by_key(key)
924 }
925
926 pub fn duration_ms_token(&self, key: &str) -> u32 {
928 self.duration_ms_required(key)
929 }
930
931 pub fn easing_by_key(&self, key: &str) -> Option<CubicBezier> {
932 let key = canonicalize_token_key(ThemeTokenKind::Easing, key);
933 self.extra_easings.get(key).copied()
934 }
935
936 pub fn easing_required(&self, key: &str) -> CubicBezier {
937 if let Some(v) = self.easing_by_key(key) {
938 return v;
939 }
940
941 if strict_theme_enabled() {
942 panic!("missing theme easing token {key}");
943 }
944 warn_missing_theme_token_once(ThemeTokenKind::Easing, key);
945 fallback_easing_by_key(key)
946 }
947
948 pub fn easing_token(&self, key: &str) -> CubicBezier {
950 self.easing_required(key)
951 }
952
953 pub fn text_style_by_key(&self, key: &str) -> Option<TextStyle> {
954 let key = canonicalize_token_key(ThemeTokenKind::TextStyle, key);
955 self.extra_text_styles.get(key).cloned()
956 }
957
958 pub fn text_style_required(&self, key: &str) -> TextStyle {
959 if let Some(v) = self.text_style_by_key(key) {
960 return v;
961 }
962
963 if strict_theme_enabled() {
964 panic!("missing theme text_style token {key}");
965 }
966 warn_missing_theme_token_once(ThemeTokenKind::TextStyle, key);
967 fallback_text_style_by_key(key)
968 }
969
970 pub fn text_style_token(&self, key: &str) -> TextStyle {
972 self.text_style_required(key)
973 }
974
975 pub fn color_key_configured(&self, key: &str) -> bool {
976 let key = canonicalize_token_key(ThemeTokenKind::Color, key);
977 self.configured_colors.contains(key)
978 }
979
980 pub fn metric_key_configured(&self, key: &str) -> bool {
981 let key = canonicalize_token_key(ThemeTokenKind::Metric, key);
982 self.configured_metrics.contains(key)
983 }
984
985 pub fn corners_key_configured(&self, key: &str) -> bool {
986 let key = canonicalize_token_key(ThemeTokenKind::Corners, key);
987 self.configured_corners.contains(key)
988 }
989
990 pub fn number_key_configured(&self, key: &str) -> bool {
991 let key = canonicalize_token_key(ThemeTokenKind::Number, key);
992 self.configured_numbers.contains(key)
993 }
994
995 pub fn duration_ms_key_configured(&self, key: &str) -> bool {
996 let key = canonicalize_token_key(ThemeTokenKind::DurationMs, key);
997 self.configured_durations_ms.contains(key)
998 }
999
1000 pub fn easing_key_configured(&self, key: &str) -> bool {
1001 let key = canonicalize_token_key(ThemeTokenKind::Easing, key);
1002 self.configured_easings.contains(key)
1003 }
1004
1005 pub fn text_style_key_configured(&self, key: &str) -> bool {
1006 let key = canonicalize_token_key(ThemeTokenKind::TextStyle, key);
1007 self.configured_text_styles.contains(key)
1008 }
1009
1010 pub fn snapshot(&self) -> ThemeSnapshot {
1011 ThemeSnapshot {
1012 colors: self.colors,
1013 metrics: self.metrics,
1014 color_scheme: self.color_scheme,
1015 revision: self.revision,
1016 color_tokens: self.extra_colors.clone(),
1017 metric_tokens: self.extra_metrics.clone(),
1018 }
1019 }
1020
1021 pub fn global<H: UiHost>(app: &H) -> &Theme {
1022 if let Some(theme) = app.global::<Theme>() {
1023 theme
1024 } else {
1025 default_theme()
1026 }
1027 }
1028
1029 pub fn with_global_mut<H: UiHost, R>(app: &mut H, f: impl FnOnce(&mut Theme) -> R) -> R {
1030 app.with_global_mut(|| default_theme().clone(), |theme, _app| f(theme))
1031 }
1032
1033 pub fn apply_config(&mut self, cfg: &ThemeConfig) {
1034 self.name = cfg.name.clone();
1035 self.author = cfg.author.clone();
1036 self.url = cfg.url.clone();
1037
1038 assert_no_legacy_theme_keys(cfg);
1039
1040 let cfg_colors = canonicalize_config_map(ThemeTokenKind::Color, &cfg.colors);
1041 let cfg_metrics = canonicalize_config_map(ThemeTokenKind::Metric, &cfg.metrics);
1042 let cfg_corners = canonicalize_config_map(ThemeTokenKind::Corners, &cfg.corners);
1043 let cfg_numbers = canonicalize_config_map(ThemeTokenKind::Number, &cfg.numbers);
1044 let cfg_durations_ms =
1045 canonicalize_config_map(ThemeTokenKind::DurationMs, &cfg.durations_ms);
1046 let cfg_easings = canonicalize_config_map(ThemeTokenKind::Easing, &cfg.easings);
1047 let cfg_text_styles = canonicalize_config_map(ThemeTokenKind::TextStyle, &cfg.text_styles);
1048
1049 let mut changed = false;
1050
1051 if let Some(scheme) = cfg.color_scheme
1052 && self.color_scheme != Some(scheme)
1053 {
1054 self.color_scheme = Some(scheme);
1055 changed = true;
1056 }
1057
1058 let mut next_numbers = HashMap::new();
1059 let mut next_durations_ms = HashMap::new();
1060 let mut next_easings = HashMap::new();
1061 let mut next_text_styles = HashMap::new();
1062 let mut next_corners = HashMap::new();
1063
1064 macro_rules! apply_semantic_color {
1065 ($key:literal, $set:expr) => {
1066 if let Some(v) = cfg_colors.get($key) {
1067 if let Some(c) = parse_color_to_linear(v) {
1068 $set(c);
1069 }
1070 }
1071 };
1072 }
1073
1074 macro_rules! apply_metric {
1075 ($key:literal, $field:expr) => {
1076 if let Some(v) = cfg_metrics.get($key).copied() {
1077 let px = Px(v);
1078 if $field != px {
1079 $field = px;
1080 changed = true;
1081 }
1082 }
1083 };
1084 }
1085
1086 macro_rules! apply_semantic_metric {
1087 ($key:literal, $set:expr) => {
1088 if let Some(v) = cfg_metrics.get($key).copied() {
1089 let px = Px(v);
1090 $set(px);
1091 }
1092 };
1093 }
1094
1095 apply_semantic_color!("background", |c| {
1097 if self.colors.surface_background != c {
1098 self.colors.surface_background = c;
1099 changed = true;
1100 }
1101 });
1102 apply_semantic_color!("foreground", |c| {
1103 if self.colors.text_primary != c {
1104 self.colors.text_primary = c;
1105 changed = true;
1106 }
1107 });
1108 apply_semantic_color!("border", |c| {
1109 if self.colors.panel_border != c {
1110 self.colors.panel_border = c;
1111 changed = true;
1112 }
1113 if self.colors.menu_border != c {
1114 self.colors.menu_border = c;
1115 changed = true;
1116 }
1117 if self.colors.list_border != c {
1118 self.colors.list_border = c;
1119 changed = true;
1120 }
1121 });
1122 apply_semantic_color!("input", |c| {
1123 if !cfg_colors.contains_key("border") {
1124 if self.colors.panel_border != c {
1125 self.colors.panel_border = c;
1126 changed = true;
1127 }
1128 if self.colors.menu_border != c {
1129 self.colors.menu_border = c;
1130 changed = true;
1131 }
1132 if self.colors.list_border != c {
1133 self.colors.list_border = c;
1134 changed = true;
1135 }
1136 }
1137 });
1138 apply_semantic_color!("ring", |c| {
1139 if self.colors.focus_ring != c {
1140 self.colors.focus_ring = c;
1141 changed = true;
1142 }
1143 });
1144 apply_semantic_color!("card", |c| {
1145 if self.colors.panel_background != c {
1146 self.colors.panel_background = c;
1147 changed = true;
1148 }
1149 if !cfg_colors.contains_key("fret.list.background")
1150 && !cfg_colors.contains_key("color.list.background")
1151 && self.colors.list_background != c
1152 {
1153 self.colors.list_background = c;
1154 changed = true;
1155 }
1156 });
1157 apply_semantic_color!("popover", |c| {
1158 if self.colors.menu_background != c {
1159 self.colors.menu_background = c;
1160 changed = true;
1161 }
1162 });
1163 apply_semantic_color!("muted-foreground", |c| {
1164 if self.colors.text_muted != c {
1165 self.colors.text_muted = c;
1166 changed = true;
1167 }
1168 });
1169 apply_semantic_color!("accent", |c| {
1170 if self.colors.hover_background != c {
1171 self.colors.hover_background = c;
1172 changed = true;
1173 }
1174 if !cfg_colors.contains_key("fret.menu.item.hover")
1175 && !cfg_colors.contains_key("color.menu.item.hover")
1176 && self.colors.menu_item_hover != c
1177 {
1178 self.colors.menu_item_hover = c;
1179 changed = true;
1180 }
1181 if !cfg_colors.contains_key("fret.list.row.hover")
1182 && !cfg_colors.contains_key("color.list.row.hover")
1183 && self.colors.list_row_hover != c
1184 {
1185 self.colors.list_row_hover = c;
1186 changed = true;
1187 }
1188 });
1189 apply_semantic_color!("primary", |c| {
1190 if self.colors.accent != c {
1191 self.colors.accent = c;
1192 changed = true;
1193 }
1194 if !cfg_colors.contains_key("selection")
1195 && !cfg_colors.contains_key("selection.background")
1196 && !cfg_colors.contains_key("color.selection.background")
1197 {
1198 let selection = with_alpha(c, 0.4);
1199 if self.colors.selection_background != selection {
1200 self.colors.selection_background = selection;
1201 changed = true;
1202 }
1203 }
1204 if !cfg_colors.contains_key("selection.inactive.background")
1205 && !cfg_colors.contains_key("color.selection.inactive.background")
1206 {
1207 let inactive = with_alpha(c, 0.24);
1208 if self.colors.selection_inactive_background != inactive {
1209 self.colors.selection_inactive_background = inactive;
1210 changed = true;
1211 }
1212 }
1213 if !cfg_colors.contains_key("selection.window_inactive.background")
1214 && !cfg_colors.contains_key("color.selection.window_inactive.background")
1215 {
1216 let inactive = with_alpha(c, 0.16);
1217 if self.colors.selection_window_inactive_background != inactive {
1218 self.colors.selection_window_inactive_background = inactive;
1219 changed = true;
1220 }
1221 }
1222 });
1223
1224 macro_rules! apply_baseline_color {
1225 ($key:literal, $field:expr) => {
1226 if let Some(v) = cfg_colors.get($key) {
1227 if let Some(c) = parse_color_to_linear(v) {
1228 if $field != c {
1229 $field = c;
1230 changed = true;
1231 }
1232 }
1233 }
1234 };
1235 }
1236
1237 apply_baseline_color!("color.surface.background", self.colors.surface_background);
1239 apply_baseline_color!("color.panel.background", self.colors.panel_background);
1240 apply_baseline_color!("color.panel.border", self.colors.panel_border);
1241 apply_baseline_color!("color.text.primary", self.colors.text_primary);
1242 apply_baseline_color!("color.text.muted", self.colors.text_muted);
1243 apply_baseline_color!("color.text.disabled", self.colors.text_disabled);
1244 apply_baseline_color!("color.accent", self.colors.accent);
1245 if !cfg_colors.contains_key("color.selection.background") {
1246 apply_baseline_color!("selection.background", self.colors.selection_background);
1247 }
1248 if !cfg_colors.contains_key("color.selection.inactive.background") {
1249 apply_baseline_color!(
1250 "selection.inactive.background",
1251 self.colors.selection_inactive_background
1252 );
1253 }
1254 if !cfg_colors.contains_key("color.selection.window_inactive.background") {
1255 apply_baseline_color!(
1256 "selection.window_inactive.background",
1257 self.colors.selection_window_inactive_background
1258 );
1259 }
1260 apply_baseline_color!(
1261 "color.selection.background",
1262 self.colors.selection_background
1263 );
1264 apply_baseline_color!(
1265 "color.selection.inactive.background",
1266 self.colors.selection_inactive_background
1267 );
1268 apply_baseline_color!(
1269 "color.selection.window_inactive.background",
1270 self.colors.selection_window_inactive_background
1271 );
1272 apply_baseline_color!("color.hover.background", self.colors.hover_background);
1273 apply_baseline_color!("color.focus.ring", self.colors.focus_ring);
1274 apply_baseline_color!("color.menu.background", self.colors.menu_background);
1275 apply_baseline_color!("color.menu.border", self.colors.menu_border);
1276 apply_baseline_color!("color.menu.item.hover", self.colors.menu_item_hover);
1277 apply_baseline_color!("color.menu.item.selected", self.colors.menu_item_selected);
1278 apply_baseline_color!("color.list.background", self.colors.list_background);
1279 apply_baseline_color!("color.list.border", self.colors.list_border);
1280 apply_baseline_color!("color.list.row.hover", self.colors.list_row_hover);
1281 apply_baseline_color!("color.list.row.selected", self.colors.list_row_selected);
1282 apply_baseline_color!("color.scrollbar.track", self.colors.scrollbar_track);
1283 apply_baseline_color!("color.scrollbar.thumb", self.colors.scrollbar_thumb);
1284 apply_baseline_color!(
1285 "color.scrollbar.thumb.hover",
1286 self.colors.scrollbar_thumb_hover
1287 );
1288 apply_baseline_color!(
1289 "color.viewport.selection.fill",
1290 self.colors.viewport_selection_fill
1291 );
1292 apply_baseline_color!(
1293 "color.viewport.selection.stroke",
1294 self.colors.viewport_selection_stroke
1295 );
1296 apply_baseline_color!("color.viewport.marker", self.colors.viewport_marker);
1297 apply_baseline_color!(
1298 "color.viewport.drag_line.pan",
1299 self.colors.viewport_drag_line_pan
1300 );
1301 apply_baseline_color!(
1302 "color.viewport.drag_line.orbit",
1303 self.colors.viewport_drag_line_orbit
1304 );
1305 apply_baseline_color!("color.viewport.gizmo.x", self.colors.viewport_gizmo_x);
1306 apply_baseline_color!("color.viewport.gizmo.y", self.colors.viewport_gizmo_y);
1307 apply_baseline_color!(
1308 "color.viewport.gizmo.handle.background",
1309 self.colors.viewport_gizmo_handle_background
1310 );
1311 apply_baseline_color!(
1312 "color.viewport.gizmo.handle.border",
1313 self.colors.viewport_gizmo_handle_border
1314 );
1315 apply_baseline_color!(
1316 "color.viewport.rotate_gizmo",
1317 self.colors.viewport_rotate_gizmo
1318 );
1319
1320 apply_semantic_metric!("radius", |px| {
1321 if self.metrics.radius_lg != px {
1322 self.metrics.radius_lg = px;
1323 changed = true;
1324 }
1325 let md = Px((px.0 - 2.0).max(0.0));
1326 let sm = Px((px.0 - 4.0).max(0.0));
1327 if self.metrics.radius_md != md {
1328 self.metrics.radius_md = md;
1329 changed = true;
1330 }
1331 if self.metrics.radius_sm != sm {
1332 self.metrics.radius_sm = sm;
1333 changed = true;
1334 }
1335 });
1336 apply_semantic_metric!("font.size", |px| {
1337 if self.metrics.font_size != px {
1338 self.metrics.font_size = px;
1339 changed = true;
1340 }
1341 });
1342 apply_semantic_metric!("mono_font.size", |px| {
1343 if self.metrics.mono_font_size != px {
1344 self.metrics.mono_font_size = px;
1345 changed = true;
1346 }
1347 });
1348 apply_semantic_metric!("font.line_height", |px| {
1349 if self.metrics.font_line_height != px {
1350 self.metrics.font_line_height = px;
1351 changed = true;
1352 }
1353 });
1354 apply_semantic_metric!("mono_font.line_height", |px| {
1355 if self.metrics.mono_font_line_height != px {
1356 self.metrics.mono_font_line_height = px;
1357 changed = true;
1358 }
1359 });
1360
1361 apply_metric!("metric.radius.sm", self.metrics.radius_sm);
1362 apply_metric!("metric.radius.md", self.metrics.radius_md);
1363 apply_metric!("metric.radius.lg", self.metrics.radius_lg);
1364 apply_metric!("metric.padding.sm", self.metrics.padding_sm);
1365 apply_metric!("metric.padding.md", self.metrics.padding_md);
1366 apply_metric!("metric.scrollbar.width", self.metrics.scrollbar_width);
1367 apply_metric!("metric.font.size", self.metrics.font_size);
1368 apply_metric!("metric.font.mono_size", self.metrics.mono_font_size);
1369 apply_metric!("metric.font.line_height", self.metrics.font_line_height);
1370 apply_metric!(
1371 "metric.font.mono_line_height",
1372 self.metrics.mono_font_line_height
1373 );
1374
1375 if !cfg_metrics.contains_key("metric.font.size")
1378 && let Some(v) = cfg_metrics.get("font.size").copied()
1379 {
1380 let px = Px(v);
1381 if self.metrics.font_size != px {
1382 self.metrics.font_size = px;
1383 changed = true;
1384 }
1385 }
1386 if !cfg_metrics.contains_key("metric.font.mono_size")
1387 && let Some(v) = cfg_metrics.get("mono_font.size").copied()
1388 {
1389 let px = Px(v);
1390 if self.metrics.mono_font_size != px {
1391 self.metrics.mono_font_size = px;
1392 changed = true;
1393 }
1394 }
1395 if !cfg_metrics.contains_key("metric.font.line_height")
1396 && let Some(v) = cfg_metrics.get("font.line_height").copied()
1397 {
1398 let px = Px(v);
1399 if self.metrics.font_line_height != px {
1400 self.metrics.font_line_height = px;
1401 changed = true;
1402 }
1403 }
1404 if !cfg_metrics.contains_key("metric.font.mono_line_height")
1405 && let Some(v) = cfg_metrics.get("mono_font.line_height").copied()
1406 {
1407 let px = Px(v);
1408 if self.metrics.mono_font_line_height != px {
1409 self.metrics.mono_font_line_height = px;
1410 changed = true;
1411 }
1412 }
1413
1414 let mut next_colors = default_color_tokens(self.colors);
1415 let mut next_metrics = default_metric_tokens(self.metrics);
1416
1417 for (k, v) in &cfg_colors {
1418 if let Some(c) = parse_color_to_linear(v) {
1419 next_colors.insert(k.clone(), c);
1420 }
1421 }
1422
1423 for (k, v) in &cfg_metrics {
1424 next_metrics.insert(k.clone(), Px(*v));
1425 }
1426
1427 for (k, v) in &cfg_numbers {
1428 next_numbers.insert(k.clone(), *v);
1429 }
1430
1431 for (k, v) in &cfg_durations_ms {
1432 next_durations_ms.insert(k.clone(), *v);
1433 }
1434
1435 for (k, v) in &cfg_easings {
1436 next_easings.insert(k.clone(), *v);
1437 }
1438
1439 for (k, v) in &cfg_text_styles {
1440 next_text_styles.insert(k.clone(), v.clone());
1441 }
1442
1443 for (k, v) in &cfg_corners {
1444 next_corners.insert(k.clone(), *v);
1445 }
1446
1447 next_colors.insert(
1450 "color.surface.background".to_string(),
1451 self.colors.surface_background,
1452 );
1453 next_colors.insert(
1454 "color.panel.background".to_string(),
1455 self.colors.panel_background,
1456 );
1457 next_colors.insert("color.panel.border".to_string(), self.colors.panel_border);
1458 next_colors.insert("color.text.primary".to_string(), self.colors.text_primary);
1459 next_colors.insert("color.text.muted".to_string(), self.colors.text_muted);
1460 next_colors.insert("color.text.disabled".to_string(), self.colors.text_disabled);
1461 next_colors.insert("color.accent".to_string(), self.colors.accent);
1462 next_colors.insert(
1463 "color.selection.background".to_string(),
1464 self.colors.selection_background,
1465 );
1466 next_colors.insert(
1467 "color.selection.inactive.background".to_string(),
1468 self.colors.selection_inactive_background,
1469 );
1470 next_colors.insert(
1471 "color.selection.window_inactive.background".to_string(),
1472 self.colors.selection_window_inactive_background,
1473 );
1474 next_colors.insert(
1475 "color.hover.background".to_string(),
1476 self.colors.hover_background,
1477 );
1478 next_colors.insert("color.focus.ring".to_string(), self.colors.focus_ring);
1479 next_colors.insert(
1480 "color.menu.background".to_string(),
1481 self.colors.menu_background,
1482 );
1483 next_colors.insert("color.menu.border".to_string(), self.colors.menu_border);
1484 next_colors.insert(
1485 "color.menu.item.hover".to_string(),
1486 self.colors.menu_item_hover,
1487 );
1488 next_colors.insert(
1489 "color.menu.item.selected".to_string(),
1490 self.colors.menu_item_selected,
1491 );
1492 next_colors.insert(
1493 "color.list.background".to_string(),
1494 self.colors.list_background,
1495 );
1496 next_colors.insert("color.list.border".to_string(), self.colors.list_border);
1497 next_colors.insert(
1498 "color.list.row.hover".to_string(),
1499 self.colors.list_row_hover,
1500 );
1501 next_colors.insert(
1502 "color.list.row.selected".to_string(),
1503 self.colors.list_row_selected,
1504 );
1505 next_colors.insert(
1506 "color.scrollbar.track".to_string(),
1507 self.colors.scrollbar_track,
1508 );
1509 next_colors.insert(
1510 "color.scrollbar.thumb".to_string(),
1511 self.colors.scrollbar_thumb,
1512 );
1513 next_colors.insert(
1514 "color.scrollbar.thumb.hover".to_string(),
1515 self.colors.scrollbar_thumb_hover,
1516 );
1517 next_colors.insert(
1518 "color.viewport.selection.fill".to_string(),
1519 self.colors.viewport_selection_fill,
1520 );
1521 next_colors.insert(
1522 "color.viewport.selection.stroke".to_string(),
1523 self.colors.viewport_selection_stroke,
1524 );
1525 next_colors.insert(
1526 "color.viewport.marker".to_string(),
1527 self.colors.viewport_marker,
1528 );
1529 next_colors.insert(
1530 "color.viewport.drag_line.pan".to_string(),
1531 self.colors.viewport_drag_line_pan,
1532 );
1533 next_colors.insert(
1534 "color.viewport.drag_line.orbit".to_string(),
1535 self.colors.viewport_drag_line_orbit,
1536 );
1537 next_colors.insert(
1538 "color.viewport.gizmo.x".to_string(),
1539 self.colors.viewport_gizmo_x,
1540 );
1541 next_colors.insert(
1542 "color.viewport.gizmo.y".to_string(),
1543 self.colors.viewport_gizmo_y,
1544 );
1545 next_colors.insert(
1546 "color.viewport.gizmo.handle.background".to_string(),
1547 self.colors.viewport_gizmo_handle_background,
1548 );
1549 next_colors.insert(
1550 "color.viewport.gizmo.handle.border".to_string(),
1551 self.colors.viewport_gizmo_handle_border,
1552 );
1553 next_colors.insert(
1554 "color.viewport.rotate_gizmo".to_string(),
1555 self.colors.viewport_rotate_gizmo,
1556 );
1557
1558 next_colors.insert("background".to_string(), self.colors.surface_background);
1564 next_colors.insert("foreground".to_string(), self.colors.text_primary);
1565 next_colors.insert("border".to_string(), self.colors.panel_border);
1566 next_colors.insert("input".to_string(), self.colors.panel_border);
1567 next_colors.insert("ring".to_string(), self.colors.focus_ring);
1568 next_colors.insert(
1569 "ring-offset-background".to_string(),
1570 self.colors.surface_background,
1571 );
1572 next_colors.insert("card".to_string(), self.colors.panel_background);
1573 next_colors.insert("popover".to_string(), self.colors.menu_background);
1574 next_colors.insert("muted-foreground".to_string(), self.colors.text_muted);
1575 next_colors.insert("accent".to_string(), self.colors.hover_background);
1576 next_colors.insert("primary".to_string(), self.colors.accent);
1577
1578 next_metrics.insert("metric.radius.sm".to_string(), self.metrics.radius_sm);
1579 next_metrics.insert("metric.radius.md".to_string(), self.metrics.radius_md);
1580 next_metrics.insert("metric.radius.lg".to_string(), self.metrics.radius_lg);
1581 next_metrics.insert("metric.padding.sm".to_string(), self.metrics.padding_sm);
1582 next_metrics.insert("metric.padding.md".to_string(), self.metrics.padding_md);
1583 next_metrics.insert(
1584 "metric.scrollbar.width".to_string(),
1585 self.metrics.scrollbar_width,
1586 );
1587 next_metrics.insert("metric.font.size".to_string(), self.metrics.font_size);
1588 next_metrics.insert(
1589 "metric.font.mono_size".to_string(),
1590 self.metrics.mono_font_size,
1591 );
1592 next_metrics.insert(
1593 "metric.font.line_height".to_string(),
1594 self.metrics.font_line_height,
1595 );
1596 next_metrics.insert(
1597 "metric.font.mono_line_height".to_string(),
1598 self.metrics.mono_font_line_height,
1599 );
1600
1601 next_metrics.insert("radius".to_string(), self.metrics.radius_lg);
1602 next_metrics.insert("radius.sm".to_string(), self.metrics.radius_sm);
1603 next_metrics.insert("radius.md".to_string(), self.metrics.radius_md);
1604 next_metrics.insert("radius.lg".to_string(), self.metrics.radius_lg);
1605 next_metrics.insert("font.size".to_string(), self.metrics.font_size);
1606 next_metrics.insert("mono_font.size".to_string(), self.metrics.mono_font_size);
1607 next_metrics.insert(
1608 "font.line_height".to_string(),
1609 self.metrics.font_line_height,
1610 );
1611 next_metrics.insert(
1612 "mono_font.line_height".to_string(),
1613 self.metrics.mono_font_line_height,
1614 );
1615
1616 let next_configured_colors: HashSet<String> = cfg_colors.keys().cloned().collect();
1617 if self.configured_colors != next_configured_colors {
1618 self.configured_colors = next_configured_colors;
1619 changed = true;
1620 }
1621 let next_configured_metrics: HashSet<String> = cfg_metrics.keys().cloned().collect();
1622 if self.configured_metrics != next_configured_metrics {
1623 self.configured_metrics = next_configured_metrics;
1624 changed = true;
1625 }
1626 let next_configured_corners: HashSet<String> = cfg_corners.keys().cloned().collect();
1627 if self.configured_corners != next_configured_corners {
1628 self.configured_corners = next_configured_corners;
1629 changed = true;
1630 }
1631
1632 let next_colors = Arc::new(next_colors);
1633 let next_metrics = Arc::new(next_metrics);
1634
1635 if self.extra_colors != next_colors {
1636 self.extra_colors = next_colors;
1637 changed = true;
1638 }
1639 if self.extra_metrics != next_metrics {
1640 self.extra_metrics = next_metrics;
1641 changed = true;
1642 }
1643 if self.extra_corners != next_corners {
1644 self.extra_corners = next_corners;
1645 changed = true;
1646 }
1647
1648 let next_configured_numbers: HashSet<String> = cfg_numbers.keys().cloned().collect();
1649 if self.configured_numbers != next_configured_numbers {
1650 self.configured_numbers = next_configured_numbers;
1651 changed = true;
1652 }
1653
1654 let next_configured_durations_ms: HashSet<String> =
1655 cfg_durations_ms.keys().cloned().collect();
1656 if self.configured_durations_ms != next_configured_durations_ms {
1657 self.configured_durations_ms = next_configured_durations_ms;
1658 changed = true;
1659 }
1660
1661 let next_configured_easings: HashSet<String> = cfg_easings.keys().cloned().collect();
1662 if self.configured_easings != next_configured_easings {
1663 self.configured_easings = next_configured_easings;
1664 changed = true;
1665 }
1666
1667 let next_configured_text_styles: HashSet<String> =
1668 cfg_text_styles.keys().cloned().collect();
1669 if self.configured_text_styles != next_configured_text_styles {
1670 self.configured_text_styles = next_configured_text_styles;
1671 changed = true;
1672 }
1673
1674 if self.extra_numbers != next_numbers {
1675 self.extra_numbers = next_numbers;
1676 changed = true;
1677 }
1678 if self.extra_durations_ms != next_durations_ms {
1679 self.extra_durations_ms = next_durations_ms;
1680 changed = true;
1681 }
1682 if self.extra_easings != next_easings {
1683 self.extra_easings = next_easings;
1684 changed = true;
1685 }
1686 if self.extra_text_styles != next_text_styles {
1687 self.extra_text_styles = next_text_styles;
1688 changed = true;
1689 }
1690
1691 if changed {
1692 self.revision = self.revision.saturating_add(1);
1693 }
1694 }
1695
1696 pub fn extend_tokens_from_config(&mut self, cfg: &ThemeConfig) {
1703 let mut changed = false;
1704
1705 let colors = canonicalize_config_map(ThemeTokenKind::Color, &cfg.colors);
1706 for (key, v) in &colors {
1707 if let Some(c) = parse_color_to_linear(v) {
1708 match self.extra_colors.get(key.as_str()).copied() {
1709 Some(prev) if prev == c => {}
1710 _ => {
1711 Arc::make_mut(&mut self.extra_colors).insert(key.to_string(), c);
1712 changed = true;
1713 }
1714 }
1715 }
1716 }
1717
1718 let metrics = canonicalize_config_map(ThemeTokenKind::Metric, &cfg.metrics);
1719 for (key, v) in &metrics {
1720 let px = Px(*v);
1721 match self.extra_metrics.get(key.as_str()).copied() {
1722 Some(prev) if prev == px => {}
1723 _ => {
1724 Arc::make_mut(&mut self.extra_metrics).insert(key.to_string(), px);
1725 changed = true;
1726 }
1727 }
1728 }
1729
1730 let corners = canonicalize_config_map(ThemeTokenKind::Corners, &cfg.corners);
1731 for (key, v) in &corners {
1732 match self.extra_corners.get(key.as_str()).copied() {
1733 Some(prev) if prev == *v => {}
1734 _ => {
1735 self.extra_corners.insert(key.to_string(), *v);
1736 changed = true;
1737 }
1738 }
1739 }
1740
1741 let numbers = canonicalize_config_map(ThemeTokenKind::Number, &cfg.numbers);
1742 for (key, v) in &numbers {
1743 match self.extra_numbers.get(key.as_str()).copied() {
1744 Some(prev) if (prev - *v).abs() < 1e-6 => {}
1745 _ => {
1746 self.extra_numbers.insert(key.to_string(), *v);
1747 changed = true;
1748 }
1749 }
1750 }
1751
1752 let durations_ms = canonicalize_config_map(ThemeTokenKind::DurationMs, &cfg.durations_ms);
1753 for (key, v) in &durations_ms {
1754 match self.extra_durations_ms.get(key.as_str()).copied() {
1755 Some(prev) if prev == *v => {}
1756 _ => {
1757 self.extra_durations_ms.insert(key.to_string(), *v);
1758 changed = true;
1759 }
1760 }
1761 }
1762
1763 let easings = canonicalize_config_map(ThemeTokenKind::Easing, &cfg.easings);
1764 for (key, v) in &easings {
1765 match self.extra_easings.get(key.as_str()).copied() {
1766 Some(prev) if prev == *v => {}
1767 _ => {
1768 self.extra_easings.insert(key.to_string(), *v);
1769 changed = true;
1770 }
1771 }
1772 }
1773
1774 let text_styles = canonicalize_config_map(ThemeTokenKind::TextStyle, &cfg.text_styles);
1775 for (key, v) in &text_styles {
1776 match self.extra_text_styles.get(key.as_str()) {
1777 Some(prev) if prev == v => {}
1778 _ => {
1779 self.extra_text_styles.insert(key.to_string(), v.clone());
1780 changed = true;
1781 }
1782 }
1783 }
1784
1785 if changed {
1786 self.revision = self.revision.saturating_add(1);
1787 }
1788 }
1789
1790 pub fn apply_config_patch(&mut self, cfg: &ThemeConfig) {
1799 assert_no_legacy_theme_keys(cfg);
1800
1801 let mut changed = false;
1802
1803 if let Some(scheme) = cfg.color_scheme
1804 && self.color_scheme != Some(scheme)
1805 {
1806 self.color_scheme = Some(scheme);
1807 changed = true;
1808 }
1809
1810 let colors = canonicalize_config_map(ThemeTokenKind::Color, &cfg.colors);
1811 for (key, v) in &colors {
1812 self.configured_colors.insert(key.to_string());
1813 let Some(c) = parse_color_to_linear(v) else {
1814 continue;
1815 };
1816 match self.extra_colors.get(key.as_str()).copied() {
1817 Some(prev) if prev == c => {}
1818 _ => {
1819 Arc::make_mut(&mut self.extra_colors).insert(key.to_string(), c);
1820 changed = true;
1821 }
1822 }
1823 }
1824
1825 let metrics = canonicalize_config_map(ThemeTokenKind::Metric, &cfg.metrics);
1826 for (key, v) in &metrics {
1827 self.configured_metrics.insert(key.to_string());
1828 let px = Px(*v);
1829 match self.extra_metrics.get(key.as_str()).copied() {
1830 Some(prev) if prev == px => {}
1831 _ => {
1832 Arc::make_mut(&mut self.extra_metrics).insert(key.to_string(), px);
1833 changed = true;
1834 }
1835 }
1836 }
1837
1838 let corners = canonicalize_config_map(ThemeTokenKind::Corners, &cfg.corners);
1839 for (key, v) in &corners {
1840 self.configured_corners.insert(key.to_string());
1841 match self.extra_corners.get(key.as_str()).copied() {
1842 Some(prev) if prev == *v => {}
1843 _ => {
1844 self.extra_corners.insert(key.to_string(), *v);
1845 changed = true;
1846 }
1847 }
1848 }
1849
1850 let numbers = canonicalize_config_map(ThemeTokenKind::Number, &cfg.numbers);
1851 for (key, v) in &numbers {
1852 self.configured_numbers.insert(key.to_string());
1853 match self.extra_numbers.get(key.as_str()).copied() {
1854 Some(prev) if (prev - *v).abs() < 1e-6 => {}
1855 _ => {
1856 self.extra_numbers.insert(key.to_string(), *v);
1857 changed = true;
1858 }
1859 }
1860 }
1861
1862 let durations_ms = canonicalize_config_map(ThemeTokenKind::DurationMs, &cfg.durations_ms);
1863 for (key, v) in &durations_ms {
1864 self.configured_durations_ms.insert(key.to_string());
1865 match self.extra_durations_ms.get(key.as_str()).copied() {
1866 Some(prev) if prev == *v => {}
1867 _ => {
1868 self.extra_durations_ms.insert(key.to_string(), *v);
1869 changed = true;
1870 }
1871 }
1872 }
1873
1874 let easings = canonicalize_config_map(ThemeTokenKind::Easing, &cfg.easings);
1875 for (key, v) in &easings {
1876 self.configured_easings.insert(key.to_string());
1877 match self.extra_easings.get(key.as_str()).copied() {
1878 Some(prev) if prev == *v => {}
1879 _ => {
1880 self.extra_easings.insert(key.to_string(), *v);
1881 changed = true;
1882 }
1883 }
1884 }
1885
1886 let text_styles = canonicalize_config_map(ThemeTokenKind::TextStyle, &cfg.text_styles);
1887 for (key, v) in &text_styles {
1888 self.configured_text_styles.insert(key.to_string());
1889 match self.extra_text_styles.get(key.as_str()) {
1890 Some(prev) if prev == v => {}
1891 _ => {
1892 self.extra_text_styles.insert(key.to_string(), v.clone());
1893 changed = true;
1894 }
1895 }
1896 }
1897
1898 if changed {
1899 self.revision = self.revision.saturating_add(1);
1900 }
1901 }
1902}
1903
1904fn default_theme() -> &'static Theme {
1905 static DEFAULT: OnceLock<Theme> = OnceLock::new();
1906 DEFAULT.get_or_init(|| {
1907 let metrics = ThemeMetrics {
1908 radius_sm: Px(6.0),
1909 radius_md: Px(8.0),
1910 radius_lg: Px(10.0),
1911 padding_sm: Px(8.0),
1912 padding_md: Px(10.0),
1913 scrollbar_width: Px(10.0),
1914 font_size: Px(13.0),
1915 mono_font_size: Px(13.0),
1916 font_line_height: Px(16.0),
1917 mono_font_line_height: Px(16.0),
1918 };
1919 let colors = ThemeColors {
1920 surface_background: parse_default_theme_hex_color("surface_background", "#24272E"),
1921 panel_background: parse_default_theme_hex_color("panel_background", "#2B3038"),
1922 panel_border: parse_default_theme_hex_color("panel_border", "#3A424D"),
1923 text_primary: parse_default_theme_hex_color("text_primary", "#D7DEE9"),
1924 text_muted: parse_default_theme_hex_color("text_muted", "#AAB3C2"),
1925 text_disabled: parse_default_theme_hex_color("text_disabled", "#7D8798"),
1926 accent: parse_default_theme_hex_color("accent", "#3D8BFF"),
1927 selection_background: parse_default_theme_hex_color(
1928 "selection_background",
1929 "#3D8BFF66",
1930 ),
1931 selection_inactive_background: parse_default_theme_hex_color(
1932 "selection_inactive_background",
1933 "#3D8BFF3D",
1934 ),
1935 selection_window_inactive_background: parse_default_theme_hex_color(
1936 "selection_window_inactive_background",
1937 "#3D8BFF24",
1938 ),
1939 hover_background: parse_default_theme_hex_color("hover_background", "#363C46"),
1940 focus_ring: parse_default_theme_hex_color("focus_ring", "#3D8BFFCC"),
1941 menu_background: parse_default_theme_hex_color("menu_background", "#2B3038"),
1942 menu_border: parse_default_theme_hex_color("menu_border", "#3A424D"),
1943 menu_item_hover: parse_default_theme_hex_color("menu_item_hover", "#363C46"),
1944 menu_item_selected: parse_default_theme_hex_color("menu_item_selected", "#3D8BFF66"),
1945 list_background: parse_default_theme_hex_color("list_background", "#2B3038"),
1946 list_border: parse_default_theme_hex_color("list_border", "#3A424D"),
1947 list_row_hover: parse_default_theme_hex_color("list_row_hover", "#363C46"),
1948 list_row_selected: parse_default_theme_hex_color("list_row_selected", "#3D8BFF66"),
1949 scrollbar_track: parse_default_theme_hex_color("scrollbar_track", "#1C1F25"),
1950 scrollbar_thumb: parse_default_theme_hex_color("scrollbar_thumb", "#4C5666"),
1951 scrollbar_thumb_hover: parse_default_theme_hex_color(
1952 "scrollbar_thumb_hover",
1953 "#5A687D",
1954 ),
1955
1956 viewport_selection_fill: parse_default_theme_hex_color(
1957 "viewport_selection_fill",
1958 "#3D8BFF29",
1959 ),
1960 viewport_selection_stroke: parse_default_theme_hex_color(
1961 "viewport_selection_stroke",
1962 "#3D8BFFCC",
1963 ),
1964 viewport_marker: parse_default_theme_hex_color("viewport_marker", "#3D8BFFFF"),
1965 viewport_drag_line_pan: parse_default_theme_hex_color(
1966 "viewport_drag_line_pan",
1967 "#33E684D9",
1968 ),
1969 viewport_drag_line_orbit: parse_default_theme_hex_color(
1970 "viewport_drag_line_orbit",
1971 "#FFC44AD9",
1972 ),
1973 viewport_gizmo_x: parse_default_theme_hex_color("viewport_gizmo_x", "#E74C3CFF"),
1974 viewport_gizmo_y: parse_default_theme_hex_color("viewport_gizmo_y", "#2ECC71FF"),
1975 viewport_gizmo_handle_background: parse_default_theme_hex_color(
1976 "viewport_gizmo_handle_background",
1977 "#1E2229FF",
1978 ),
1979 viewport_gizmo_handle_border: parse_default_theme_hex_color(
1980 "viewport_gizmo_handle_border",
1981 "#D7DEE9FF",
1982 ),
1983 viewport_rotate_gizmo: parse_default_theme_hex_color(
1984 "viewport_rotate_gizmo",
1985 "#FFC44AFF",
1986 ),
1987 };
1988
1989 Theme {
1990 name: "Fret Default (Dark)".to_string(),
1991 author: Some("Fret".to_string()),
1992 url: None,
1993 color_scheme: Some(ColorScheme::Dark),
1994 revision: 1,
1995 metrics,
1996 colors,
1997 extra_colors: Arc::new(default_color_tokens(colors)),
1998 extra_metrics: Arc::new(default_metric_tokens(metrics)),
1999 extra_corners: HashMap::new(),
2000 extra_numbers: HashMap::new(),
2001 extra_durations_ms: HashMap::new(),
2002 extra_easings: HashMap::new(),
2003 extra_text_styles: HashMap::new(),
2004 configured_colors: HashSet::new(),
2005 configured_metrics: HashSet::new(),
2006 configured_corners: HashSet::new(),
2007 configured_numbers: HashSet::new(),
2008 configured_durations_ms: HashSet::new(),
2009 configured_easings: HashSet::new(),
2010 configured_text_styles: HashSet::new(),
2011 }
2012 })
2013}
2014
2015fn parse_color_to_linear(s: &str) -> Option<Color> {
2016 parse_hex_srgb_to_linear(s)
2017 .or_else(|| parse_hsl_tokens_to_linear(s))
2018 .or_else(|| parse_oklch_to_linear(s))
2019}
2020
2021fn parse_hex_srgb_to_linear(s: &str) -> Option<Color> {
2022 let s = s.trim();
2023 let hex = s.strip_prefix('#').unwrap_or(s);
2024 let (r, g, b, a) = match hex.len() {
2025 6 => {
2026 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
2027 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
2028 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
2029 (r, g, b, 255)
2030 }
2031 8 => {
2032 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
2033 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
2034 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
2035 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
2036 (r, g, b, a)
2037 }
2038 _ => return None,
2039 };
2040
2041 Some(Color {
2042 r: srgb_channel_to_linear(r),
2043 g: srgb_channel_to_linear(g),
2044 b: srgb_channel_to_linear(b),
2045 a: a as f32 / 255.0,
2046 })
2047}
2048
2049fn srgb_channel_to_linear(u: u8) -> f32 {
2050 let c = u as f32 / 255.0;
2051 srgb_f32_to_linear(c)
2052}
2053
2054fn srgb_f32_to_linear(c: f32) -> f32 {
2055 if c <= 0.04045 {
2056 c / 12.92
2057 } else {
2058 ((c + 0.055) / 1.055).powf(2.4)
2059 }
2060}
2061
2062fn parse_hsl_tokens_to_linear(s: &str) -> Option<Color> {
2063 let s = s.trim();
2064 if s.is_empty() {
2065 return None;
2066 }
2067
2068 let inner = s
2069 .strip_prefix("hsl(")
2070 .and_then(|rest| rest.strip_suffix(')'))
2071 .unwrap_or(s);
2072
2073 let parts: Vec<&str> = inner
2075 .split(|c: char| c.is_whitespace() || c == ',')
2076 .filter(|p| !p.is_empty())
2077 .collect();
2078 if parts.len() != 3 {
2079 return None;
2080 }
2081
2082 let h_deg: f32 = parts[0].parse().ok()?;
2083 let s_pct: f32 = parts[1].trim_end_matches('%').parse().ok()?;
2084 let l_pct: f32 = parts[2].trim_end_matches('%').parse().ok()?;
2085
2086 let h = (h_deg % 360.0 + 360.0) % 360.0 / 360.0;
2087 let s = (s_pct / 100.0).clamp(0.0, 1.0);
2088 let l = (l_pct / 100.0).clamp(0.0, 1.0);
2089
2090 let (r_srgb, g_srgb, b_srgb) = hsl_to_srgb(h, s, l);
2091 Some(Color {
2092 r: srgb_f32_to_linear(r_srgb.clamp(0.0, 1.0)),
2093 g: srgb_f32_to_linear(g_srgb.clamp(0.0, 1.0)),
2094 b: srgb_f32_to_linear(b_srgb.clamp(0.0, 1.0)),
2095 a: 1.0,
2096 })
2097}
2098
2099fn hsl_to_srgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
2100 if s == 0.0 {
2101 return (l, l, l);
2102 }
2103
2104 fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
2105 if t < 0.0 {
2106 t += 1.0;
2107 }
2108 if t > 1.0 {
2109 t -= 1.0;
2110 }
2111 if t < 1.0 / 6.0 {
2112 return p + (q - p) * 6.0 * t;
2113 }
2114 if t < 1.0 / 2.0 {
2115 return q;
2116 }
2117 if t < 2.0 / 3.0 {
2118 return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
2119 }
2120 p
2121 }
2122
2123 let q = if l < 0.5 {
2124 l * (1.0 + s)
2125 } else {
2126 l + s - l * s
2127 };
2128 let p = 2.0 * l - q;
2129 (
2130 hue_to_rgb(p, q, h + 1.0 / 3.0),
2131 hue_to_rgb(p, q, h),
2132 hue_to_rgb(p, q, h - 1.0 / 3.0),
2133 )
2134}
2135
2136#[allow(clippy::excessive_precision)]
2137fn parse_oklch_to_linear(s: &str) -> Option<Color> {
2138 let s = s.trim();
2139 let inner = s.strip_prefix("oklch(")?.strip_suffix(')')?.trim();
2140
2141 let (main, alpha_part) = if let Some((l, r)) = inner.split_once('/') {
2143 (l.trim(), Some(r.trim()))
2144 } else {
2145 (inner, None)
2146 };
2147
2148 let parts: Vec<&str> = main
2149 .split(|c: char| c.is_whitespace() || c == ',')
2150 .filter(|p| !p.is_empty())
2151 .collect();
2152 if parts.len() != 3 {
2153 return None;
2154 }
2155
2156 let l: f32 = parts[0].parse().ok()?;
2157 let c: f32 = parts[1].parse().ok()?;
2158 let h_deg: f32 = parts[2].parse().ok()?;
2159
2160 let alpha = if let Some(a) = alpha_part {
2161 if let Some(pct) = a.trim_end_matches('%').parse::<f32>().ok()
2162 && a.trim_end().ends_with('%')
2163 {
2164 (pct / 100.0).clamp(0.0, 1.0)
2165 } else {
2166 a.parse::<f32>().ok()?.clamp(0.0, 1.0)
2167 }
2168 } else {
2169 1.0
2170 };
2171
2172 let h_rad = h_deg.to_radians();
2174 let a = c * h_rad.cos();
2175 let b = c * h_rad.sin();
2176
2177 let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
2179 let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
2180 let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
2181
2182 let l3 = l_ * l_ * l_;
2183 let m3 = m_ * m_ * m_;
2184 let s3 = s_ * s_ * s_;
2185
2186 let r_lin = 4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3;
2187 let g_lin = -1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3;
2188 let b_lin = -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3;
2189
2190 Some(Color {
2191 r: r_lin.clamp(0.0, 1.0),
2192 g: g_lin.clamp(0.0, 1.0),
2193 b: b_lin.clamp(0.0, 1.0),
2194 a: alpha,
2195 })
2196}
2197
2198fn with_alpha(mut color: Color, alpha: f32) -> Color {
2199 color.a = alpha;
2200 color
2201}
2202
2203fn assert_no_legacy_theme_keys(_cfg: &ThemeConfig) {
2204 }
2206
2207#[cfg(test)]
2208mod tests {
2209 use super::parse_color_to_linear;
2210 use super::{CubicBezier, Theme, ThemeConfig};
2211 use crate::{ThemeColorKey, ThemeMetricKey, ThemeNamedColorKey};
2212 use fret_core::{Corners, FontId, FontWeight, Px, TextSlant, TextStyle};
2213 use std::collections::HashMap;
2214
2215 #[test]
2216 fn syntax_color_resolves_prefix_fallback_tokens() {
2217 let mut host = crate::test_host::TestHost::default();
2218 Theme::with_global_mut(&mut host, |theme| {
2219 let mut colors = HashMap::<String, String>::new();
2220 colors.insert("color.syntax.keyword".to_string(), "#ff0000".to_string());
2221 theme.apply_config(&ThemeConfig {
2222 name: "test".to_string(),
2223 colors,
2224 ..Default::default()
2225 });
2226
2227 let exact = theme
2228 .syntax_color("keyword")
2229 .expect("exact token should resolve");
2230 let prefixed = theme
2231 .syntax_color("keyword.operator")
2232 .expect("prefixed tag should resolve via prefix fallback");
2233 assert_eq!(exact, prefixed);
2234 });
2235 }
2236
2237 #[test]
2238 fn shadcn_semantic_palette_aliases_exist_on_default_theme() {
2239 let host = crate::test_host::TestHost::default();
2240 let theme = Theme::global(&host);
2241
2242 for key in [
2243 "background",
2244 "foreground",
2245 "border",
2246 "input",
2247 "input.border",
2248 "ring",
2249 "ring-offset-background",
2250 "card",
2251 "card.background",
2252 "card-foreground",
2253 "card.foreground",
2254 "primary",
2255 "primary.background",
2256 "primary-foreground",
2257 "primary.foreground",
2258 "secondary",
2259 "secondary.background",
2260 "secondary-foreground",
2261 "secondary.foreground",
2262 "destructive",
2263 "destructive.background",
2264 "destructive-foreground",
2265 "destructive.foreground",
2266 "muted",
2267 "muted-foreground",
2268 "input.background",
2269 "input.foreground",
2270 "accent",
2271 "accent-foreground",
2272 "popover.background",
2273 "popover.foreground",
2274 "popover-foreground",
2275 "popover.border",
2276 "chart-1",
2278 "chart-2",
2279 "chart-3",
2280 "chart-4",
2281 "chart-5",
2282 "chart.1",
2283 "chart.2",
2284 "chart.3",
2285 "chart.4",
2286 "chart.5",
2287 "sidebar",
2288 "sidebar.background",
2289 "sidebar-background",
2290 "sidebar-foreground",
2291 "sidebar.foreground",
2292 "sidebar-primary",
2293 "sidebar.primary",
2294 "sidebar-primary-foreground",
2295 "sidebar.primary.foreground",
2296 "sidebar-accent",
2297 "sidebar.accent",
2298 "sidebar-accent-foreground",
2299 "sidebar.accent.foreground",
2300 "sidebar-border",
2301 "sidebar.border",
2302 "sidebar-ring",
2303 "sidebar.ring",
2304 ] {
2305 assert!(theme.color_by_key(key).is_some(), "missing alias {key}");
2306 }
2307 }
2308
2309 #[test]
2310 fn theme_snapshot_includes_configured_color_tokens() {
2311 let host = crate::test_host::TestHost::default();
2312 let mut theme = Theme::global(&host).clone();
2313
2314 let mut cfg = ThemeConfig::default();
2315 cfg.colors
2316 .insert("muted".to_string(), "#ff0000".to_string());
2317 theme.apply_config(&cfg);
2318
2319 let snapshot = theme.snapshot();
2320 assert_eq!(theme.color_token("muted"), snapshot.color_token("muted"));
2321 }
2322
2323 #[test]
2324 fn theme_snapshot_matches_theme_for_common_semantic_tokens() {
2325 let host = crate::test_host::TestHost::default();
2326 let theme = Theme::global(&host);
2327 let snapshot = theme.snapshot();
2328
2329 for key in [
2330 "background",
2331 "foreground",
2332 "border",
2333 "card",
2334 "card-foreground",
2335 "muted",
2336 "muted-foreground",
2337 "accent",
2338 "accent-foreground",
2339 "primary",
2340 "primary-foreground",
2341 "popover",
2342 "popover-foreground",
2343 "chart-1",
2344 "sidebar",
2345 "sidebar-foreground",
2346 ] {
2347 assert_eq!(
2348 theme.color_token(key),
2349 snapshot.color_token(key),
2350 "key={key}"
2351 );
2352 }
2353
2354 for key in ["metric.size.sm", "metric.size.md", "metric.size.lg"] {
2355 assert_eq!(
2356 theme.metric_token(key),
2357 snapshot.metric_token(key),
2358 "key={key}"
2359 );
2360 }
2361 }
2362
2363 #[test]
2364 fn missing_theme_token_diagnostics_warn_once_per_key() {
2365 let key = format!("color.__missing_theme_token_test__{}", line!());
2367 assert!(super::warn_missing_theme_token_once(
2368 crate::theme_registry::ThemeTokenKind::Color,
2369 &key
2370 ));
2371 assert!(!super::warn_missing_theme_token_once(
2372 crate::theme_registry::ThemeTokenKind::Color,
2373 &key
2374 ));
2375 }
2376
2377 #[test]
2378 fn shadcn_legacy_size_metrics_exist_on_default_theme() {
2379 let host = crate::test_host::TestHost::default();
2380 let theme = Theme::global(&host);
2381
2382 for key in ["metric.size.sm", "metric.size.md", "metric.size.lg"] {
2383 assert!(theme.metric_by_key(key).is_some(), "missing metric {key}");
2384 }
2385 }
2386
2387 #[test]
2388 fn shadcn_legacy_size_metrics_exist_on_default_snapshot() {
2389 let host = crate::test_host::TestHost::default();
2390 let theme = Theme::global(&host);
2391 let snap = theme.snapshot();
2392
2393 for key in ["metric.size.sm", "metric.size.md", "metric.size.lg"] {
2394 assert!(
2395 snap.metric_by_key(key).is_some(),
2396 "missing snapshot metric {key}"
2397 );
2398 }
2399 }
2400
2401 #[test]
2402 fn shadcn_component_text_metrics_exist_on_default_theme() {
2403 let host = crate::test_host::TestHost::default();
2404 let theme = Theme::global(&host);
2405
2406 for key in [
2407 "component.text.sm_px",
2408 "component.text.sm_line_height",
2409 "component.text.base_px",
2410 "component.text.base_line_height",
2411 ] {
2412 assert!(theme.metric_by_key(key).is_some(), "missing metric {key}");
2413 }
2414 }
2415
2416 #[test]
2417 fn semantic_keys_backfill_typed_baseline_colors_when_missing() {
2418 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2419
2420 let mut colors = HashMap::new();
2421 colors.insert("background".to_string(), "#000000".to_string());
2422 colors.insert("foreground".to_string(), "#ffffff".to_string());
2423 colors.insert("border".to_string(), "#ff0000".to_string());
2424 colors.insert("ring".to_string(), "#00ff00".to_string());
2425 colors.insert("primary".to_string(), "#0000ff".to_string());
2426 colors.insert("muted-foreground".to_string(), "#00ffff".to_string());
2427 let cfg = ThemeConfig {
2428 name: "Semantic Only".to_string(),
2429 colors,
2430 ..Default::default()
2431 };
2432
2433 theme.apply_config(&cfg);
2435
2436 let bg = theme.color_by_key("background").expect("background");
2437 let fg = theme.color_by_key("foreground").expect("foreground");
2438 let border = theme.color_by_key("border").expect("border");
2439 let ring = theme.color_by_key("ring").expect("ring");
2440 let primary = theme.color_by_key("primary").expect("primary");
2441 let muted_fg = theme
2442 .color_by_key("muted-foreground")
2443 .expect("muted-foreground");
2444
2445 assert_eq!(theme.colors.surface_background, bg);
2446 assert_eq!(theme.colors.text_primary, fg);
2447 assert_eq!(theme.colors.panel_border, border);
2448 assert_eq!(theme.colors.focus_ring, ring);
2449 assert_eq!(theme.colors.accent, primary);
2450 assert_eq!(theme.colors.text_muted, muted_fg);
2451
2452 assert_eq!(theme.color_by_key("color.surface.background"), Some(bg));
2455 assert_eq!(theme.color_by_key("color.text.primary"), Some(fg));
2456 assert_eq!(theme.color_by_key("color.panel.border"), Some(border));
2457 assert_eq!(theme.color_by_key("color.focus.ring"), Some(ring));
2458 assert_eq!(theme.color_by_key("color.accent"), Some(primary));
2459 assert_eq!(theme.color_by_key("color.text.muted"), Some(muted_fg));
2460 }
2461
2462 #[test]
2463 fn baseline_dotted_keys_update_typed_theme_colors() {
2464 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2465
2466 let mut colors = HashMap::new();
2467 colors.insert(
2468 "color.surface.background".to_string(),
2469 "#010203".to_string(),
2470 );
2471 colors.insert("color.text.primary".to_string(), "#AABBCC".to_string());
2472 colors.insert("color.panel.border".to_string(), "#112233".to_string());
2473 let cfg = ThemeConfig {
2474 name: "Baseline Dotted".to_string(),
2475 colors,
2476 ..Default::default()
2477 };
2478 theme.apply_config(&cfg);
2479
2480 let bg = theme
2481 .color_by_key("color.surface.background")
2482 .expect("color.surface.background");
2483 let fg = theme
2484 .color_by_key("color.text.primary")
2485 .expect("color.text.primary");
2486 let border = theme
2487 .color_by_key("color.panel.border")
2488 .expect("color.panel.border");
2489
2490 assert_eq!(theme.colors.surface_background, bg);
2491 assert_eq!(theme.colors.text_primary, fg);
2492 assert_eq!(theme.colors.panel_border, border);
2493
2494 assert_eq!(theme.color_by_key("background"), Some(bg));
2496 assert_eq!(theme.color_by_key("foreground"), Some(fg));
2497 assert_eq!(theme.color_by_key("border"), Some(border));
2498 }
2499
2500 #[test]
2501 fn semantic_keys_backfill_panel_border_from_input_when_border_missing() {
2502 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2503
2504 let mut colors = HashMap::new();
2505 colors.insert("background".to_string(), "#000000".to_string());
2506 colors.insert("foreground".to_string(), "#ffffff".to_string());
2507 colors.insert("input".to_string(), "#ff0000".to_string());
2508 let cfg = ThemeConfig {
2509 name: "Semantic Input Border".to_string(),
2510 colors,
2511 ..Default::default()
2512 };
2513
2514 theme.apply_config(&cfg);
2515
2516 let input = theme.color_by_key("input").expect("input");
2517 assert_eq!(theme.colors.panel_border, input);
2518 assert_eq!(theme.colors.menu_border, input);
2519 assert_eq!(theme.colors.list_border, input);
2520 }
2521
2522 #[test]
2523 fn parse_color_supports_shadcn_hsl_tokens() {
2524 let white = parse_color_to_linear("0 0% 100%").expect("hsl tokens");
2525 assert!((white.r - 1.0).abs() < 1e-6);
2526 assert!((white.g - 1.0).abs() < 1e-6);
2527 assert!((white.b - 1.0).abs() < 1e-6);
2528 assert!((white.a - 1.0).abs() < 1e-6);
2529 }
2530
2531 #[test]
2532 fn parse_color_supports_shadcn_oklch_tokens_with_alpha() {
2533 let c = parse_color_to_linear("oklch(1 0 0 / 10%)").expect("oklch");
2534 assert!((c.r - 1.0).abs() < 1e-6);
2535 assert!((c.g - 1.0).abs() < 1e-6);
2536 assert!((c.b - 1.0).abs() < 1e-6);
2537 assert!((c.a - 0.1).abs() < 1e-6);
2538 }
2539
2540 #[test]
2541 fn named_colors_exist_on_default_snapshot() {
2542 let host = crate::test_host::TestHost::default();
2543 let theme = Theme::global(&host);
2544 let snap = theme.snapshot();
2545
2546 let white = snap.named_color(ThemeNamedColorKey::White);
2547 assert!((white.r - 1.0).abs() < 1e-6);
2548 assert!((white.g - 1.0).abs() < 1e-6);
2549 assert!((white.b - 1.0).abs() < 1e-6);
2550 assert!((white.a - 1.0).abs() < 1e-6);
2551
2552 let black = snap.named_color(ThemeNamedColorKey::Black);
2553 assert!((black.r - 0.0).abs() < 1e-6);
2554 assert!((black.g - 0.0).abs() < 1e-6);
2555 assert!((black.b - 0.0).abs() < 1e-6);
2556 assert!((black.a - 1.0).abs() < 1e-6);
2557 }
2558
2559 #[test]
2560 fn typed_theme_keys_resolve_via_semantic_palette() {
2561 let host = crate::test_host::TestHost::default();
2562 let theme = Theme::global(&host);
2563
2564 assert_eq!(
2565 theme.color(ThemeColorKey::PopoverForeground),
2566 theme
2567 .color_by_key("popover-foreground")
2568 .expect("popover-foreground")
2569 );
2570 assert_eq!(
2571 theme.metric(ThemeMetricKey::Radius),
2572 theme.metric_by_key("radius").expect("radius")
2573 );
2574 }
2575
2576 #[test]
2577 fn theme_config_v2_parses_additional_token_kinds() {
2578 let cfg = ThemeConfig::from_slice(
2579 br#"{
2580 "name": "md3",
2581 "numbers": { "md.sys.state.hover.state-layer-opacity": 0.08 },
2582 "durations_ms": { "md.sys.motion.duration.short3": 150 },
2583 "easings": { "md.sys.motion.easing.emphasized.accelerate": { "x1": 0.3, "y1": 0.0, "x2": 0.8, "y2": 0.15 } },
2584 "corners": { "md.sys.shape.corner.extra-small.top": { "top_left": 4, "top_right": 4, "bottom_right": 0, "bottom_left": 0 } },
2585 "text_styles": {
2586 "md.sys.typescale.body-medium": { "font": "ui", "size": 14, "weight": 400, "slant": "normal" }
2587 }
2588}"#,
2589 )
2590 .expect("valid theme config");
2591
2592 assert_eq!(cfg.name, "md3");
2593 assert_eq!(
2594 cfg.numbers
2595 .get("md.sys.state.hover.state-layer-opacity")
2596 .copied(),
2597 Some(0.08)
2598 );
2599 assert_eq!(
2600 cfg.durations_ms
2601 .get("md.sys.motion.duration.short3")
2602 .copied(),
2603 Some(150)
2604 );
2605 assert_eq!(
2606 cfg.easings
2607 .get("md.sys.motion.easing.emphasized.accelerate")
2608 .copied(),
2609 Some(CubicBezier {
2610 x1: 0.3,
2611 y1: 0.0,
2612 x2: 0.8,
2613 y2: 0.15
2614 })
2615 );
2616 assert_eq!(
2617 cfg.corners
2618 .get("md.sys.shape.corner.extra-small.top")
2619 .copied(),
2620 Some(Corners {
2621 top_left: Px(4.0),
2622 top_right: Px(4.0),
2623 bottom_right: Px(0.0),
2624 bottom_left: Px(0.0),
2625 })
2626 );
2627 assert!(cfg.text_styles.contains_key("md.sys.typescale.body-medium"));
2628 }
2629
2630 #[test]
2631 fn required_accessors_do_not_panic_when_tokens_are_missing_by_default() {
2632 let _guard = super::strict_theme_for_tests(false);
2633 let host = crate::test_host::TestHost::default();
2634 let theme = Theme::global(&host);
2635
2636 let _ = theme.color_required("missing.color.token");
2637 let _ = theme.metric_required("missing.metric.token");
2638 let _ = theme.corners_required("missing.corners.token");
2639 let _ = theme.number_required("missing.number.token");
2640 let _ = theme.duration_ms_required("missing.duration.token");
2641 let _ = theme.easing_required("missing.easing.token");
2642 let _ = theme.text_style_required("missing.text_style.token");
2643
2644 let snap = theme.snapshot();
2645 let _ = snap.color_required("missing.color.token");
2646 let _ = snap.metric_required("missing.metric.token");
2647 }
2648
2649 #[test]
2650 fn required_accessors_panic_in_strict_runtime_mode() {
2651 let _guard = super::strict_theme_for_tests(true);
2652 let host = crate::test_host::TestHost::default();
2653 let theme = Theme::global(&host);
2654
2655 let result = std::panic::catch_unwind(|| {
2656 let _ = theme.color_required("missing.color.token");
2657 });
2658 assert!(result.is_err());
2659 }
2660
2661 #[test]
2662 fn theme_apply_config_updates_extended_token_maps_and_revision() {
2663 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2664 let before = theme.revision();
2665
2666 theme.apply_config(&ThemeConfig {
2667 name: "md3".to_string(),
2668 corners: HashMap::from([(
2669 "c".to_string(),
2670 Corners {
2671 top_left: Px(1.0),
2672 top_right: Px(2.0),
2673 bottom_right: Px(3.0),
2674 bottom_left: Px(4.0),
2675 },
2676 )]),
2677 numbers: HashMap::from([("n".to_string(), 1.25)]),
2678 durations_ms: HashMap::from([("d".to_string(), 120)]),
2679 easings: HashMap::from([(
2680 "e".to_string(),
2681 CubicBezier {
2682 x1: 0.2,
2683 y1: 0.0,
2684 x2: 0.0,
2685 y2: 1.0,
2686 },
2687 )]),
2688 text_styles: HashMap::from([(
2689 "t".to_string(),
2690 TextStyle {
2691 font: FontId::ui(),
2692 size: Px(14.0),
2693 weight: FontWeight::NORMAL,
2694 slant: TextSlant::Normal,
2695 line_height: Some(Px(20.0)),
2696 line_height_em: None,
2697 line_height_policy: Default::default(),
2698 letter_spacing_em: None,
2699 features: Vec::new(),
2700 axes: Vec::new(),
2701 vertical_placement: fret_core::TextVerticalPlacement::CenterMetricsBox,
2702 leading_distribution: Default::default(),
2703 strut_style: None,
2704 },
2705 )]),
2706 ..ThemeConfig::default()
2707 });
2708
2709 assert_eq!(
2710 theme.corners_by_key("c"),
2711 Some(Corners {
2712 top_left: Px(1.0),
2713 top_right: Px(2.0),
2714 bottom_right: Px(3.0),
2715 bottom_left: Px(4.0),
2716 })
2717 );
2718 assert_eq!(theme.number_by_key("n"), Some(1.25));
2719 assert_eq!(theme.duration_ms_by_key("d"), Some(120));
2720 assert_eq!(
2721 theme.easing_by_key("e"),
2722 Some(CubicBezier {
2723 x1: 0.2,
2724 y1: 0.0,
2725 x2: 0.0,
2726 y2: 1.0
2727 })
2728 );
2729 assert!(theme.text_style_by_key("t").is_some());
2730
2731 assert!(theme.corners_key_configured("c"));
2732 assert!(theme.number_key_configured("n"));
2733 assert!(theme.duration_ms_key_configured("d"));
2734 assert!(theme.easing_key_configured("e"));
2735 assert!(theme.text_style_key_configured("t"));
2736
2737 assert!(theme.revision() > before);
2738 }
2739
2740 #[test]
2741 fn extend_tokens_from_config_preserves_configured_sets() {
2742 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2743
2744 theme.apply_config(&ThemeConfig {
2745 name: "Base".to_string(),
2746 metrics: HashMap::from([("metric.radius.sm".to_string(), 11.0)]),
2747 corners: HashMap::from([(
2748 "base.corners".to_string(),
2749 Corners {
2750 top_left: Px(1.0),
2751 top_right: Px(1.0),
2752 bottom_right: Px(1.0),
2753 bottom_left: Px(1.0),
2754 },
2755 )]),
2756 ..ThemeConfig::default()
2757 });
2758 assert!(theme.metric_key_configured("metric.radius.sm"));
2759 assert!(theme.corners_key_configured("base.corners"));
2760
2761 let before = theme.revision();
2762 theme.extend_tokens_from_config(&ThemeConfig {
2763 name: "Extras".to_string(),
2764 metrics: HashMap::from([("md.sys.shape.corner.full".to_string(), 9999.0)]),
2765 corners: HashMap::from([(
2766 "md.sys.shape.corner.extra-small.top".to_string(),
2767 Corners {
2768 top_left: Px(4.0),
2769 top_right: Px(4.0),
2770 bottom_right: Px(0.0),
2771 bottom_left: Px(0.0),
2772 },
2773 )]),
2774 numbers: HashMap::from([("md.sys.state.hover.state-layer-opacity".to_string(), 0.08)]),
2775 ..ThemeConfig::default()
2776 });
2777
2778 assert!(theme.metric_key_configured("metric.radius.sm"));
2779 assert!(theme.corners_key_configured("base.corners"));
2780 assert_eq!(
2781 theme.metric_by_key("md.sys.shape.corner.full"),
2782 Some(Px(9999.0))
2783 );
2784 assert!(
2785 theme
2786 .corners_by_key("md.sys.shape.corner.extra-small.top")
2787 .is_some()
2788 );
2789 assert_eq!(
2790 theme.number_by_key("md.sys.state.hover.state-layer-opacity"),
2791 Some(0.08)
2792 );
2793 assert!(theme.revision() > before);
2794 }
2795
2796 #[test]
2797 fn apply_config_patch_preserves_existing_extra_colors() {
2798 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2799
2800 theme.extend_tokens_from_config(&ThemeConfig {
2801 name: "Base".to_string(),
2802 colors: HashMap::from([("primary".to_string(), "#ff0000".to_string())]),
2803 ..ThemeConfig::default()
2804 });
2805 assert_eq!(
2806 theme.color_by_key("primary"),
2807 Some(fret_core::Color {
2808 r: 1.0,
2809 g: 0.0,
2810 b: 0.0,
2811 a: 1.0,
2812 })
2813 );
2814
2815 theme.apply_config_patch(&ThemeConfig {
2816 name: "Patch".to_string(),
2817 metrics: HashMap::from([("metric.padding.sm".to_string(), 7.0)]),
2818 ..ThemeConfig::default()
2819 });
2820
2821 assert_eq!(
2822 theme.color_by_key("primary"),
2823 Some(fret_core::Color {
2824 r: 1.0,
2825 g: 0.0,
2826 b: 0.0,
2827 a: 1.0,
2828 }),
2829 "expected shadcn-style palette tokens to remain intact after metric patches"
2830 );
2831 assert_eq!(theme.metric_by_key("metric.padding.sm"), Some(Px(7.0)));
2832 }
2833
2834 #[test]
2835 fn apply_config_prefers_canonical_keys_over_aliases() {
2836 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2837
2838 theme.apply_config(&ThemeConfig {
2839 name: "Cfg".to_string(),
2840 colors: HashMap::from([
2841 ("primary-foreground".to_string(), "#ffffff".to_string()),
2842 ("primary.foreground".to_string(), "#000000".to_string()),
2843 ]),
2844 ..ThemeConfig::default()
2845 });
2846
2847 assert!(theme.color_key_configured("primary-foreground"));
2848 assert!(theme.color_key_configured("primary.foreground"));
2849 assert_eq!(
2850 theme.color_by_key("primary-foreground"),
2851 parse_color_to_linear("#ffffff")
2852 );
2853 }
2854
2855 #[test]
2856 fn apply_config_patch_prefers_canonical_keys_over_aliases() {
2857 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2858
2859 theme.apply_config_patch(&ThemeConfig {
2860 name: "Patch".to_string(),
2861 colors: HashMap::from([
2862 ("primary-foreground".to_string(), "#ffffff".to_string()),
2863 ("primary.foreground".to_string(), "#000000".to_string()),
2864 ]),
2865 ..ThemeConfig::default()
2866 });
2867
2868 assert!(theme.color_key_configured("primary-foreground"));
2869 assert!(theme.color_key_configured("primary.foreground"));
2870 assert_eq!(
2871 theme.color_by_key("primary-foreground"),
2872 parse_color_to_linear("#ffffff")
2873 );
2874 }
2875
2876 #[test]
2877 fn extend_tokens_from_config_prefers_canonical_keys_over_aliases_without_touching_configured() {
2878 let mut theme = Theme::global(&crate::test_host::TestHost::default()).clone();
2879
2880 theme.apply_config(&ThemeConfig {
2881 name: "Base".to_string(),
2882 metrics: HashMap::from([("metric.padding.sm".to_string(), 7.0)]),
2883 ..ThemeConfig::default()
2884 });
2885 assert!(theme.metric_key_configured("metric.padding.sm"));
2886 assert!(!theme.color_key_configured("primary-foreground"));
2887
2888 theme.extend_tokens_from_config(&ThemeConfig {
2889 name: "Extras".to_string(),
2890 colors: HashMap::from([
2891 ("primary-foreground".to_string(), "#ffffff".to_string()),
2892 ("primary.foreground".to_string(), "#000000".to_string()),
2893 ]),
2894 ..ThemeConfig::default()
2895 });
2896
2897 assert!(theme.metric_key_configured("metric.padding.sm"));
2898 assert!(!theme.color_key_configured("primary-foreground"));
2899 assert_eq!(
2900 theme.color_by_key("primary-foreground"),
2901 parse_color_to_linear("#ffffff")
2902 );
2903 }
2904}