1use std::fmt;
12
13use gpui::{px, Hsla, Rgba};
14use herogpui_core::{oklcha, Color};
15use serde::{Deserialize, Serialize};
16
17use crate::{Appearance, Theme, ThemeBuilder};
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct ThemeDocument {
26 pub id: String,
28 pub base: Appearance,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub appearance: Option<Appearance>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub radius: Option<f32>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub field_radius: Option<f32>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub border_width: Option<f32>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub disabled_opacity: Option<f32>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub cursor_interactive: Option<gpui::CursorStyle>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub tabs_hover_opacity: Option<f32>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub tooltip_cooldown_ms: Option<u64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub long_press_ms: Option<u64>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub hover_fade_ms: Option<u64>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub tooltip_delay_ms: Option<u64>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub tooltip_close_delay_ms: Option<u64>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub background: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub foreground: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub muted: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub border: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub separator: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub focus: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub link: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub backdrop: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub surface: Option<ColorPair>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub surface_levels: Option<SurfaceLevels>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub overlay: Option<ColorPair>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub segment: Option<ColorPair>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub accent: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub roles: Option<Roles>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub field: Option<ColorPair>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub field_placeholder: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub field_border: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub vibrant_palette: Option<bool>,
125}
126
127#[derive(Clone, Debug, Deserialize, Serialize)]
129#[serde(deny_unknown_fields)]
130pub struct ColorPair {
131 pub background: String,
133 pub foreground: String,
135}
136
137#[derive(Clone, Debug, Deserialize, Serialize)]
139#[serde(deny_unknown_fields)]
140pub struct SurfaceLevels {
141 pub secondary: String,
143 pub tertiary: String,
145}
146
147#[derive(Clone, Debug, Default, Deserialize, Serialize)]
149#[serde(deny_unknown_fields)]
150pub struct Roles {
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub default: Option<RoleOverride>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub accent: Option<RoleOverride>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub success: Option<RoleOverride>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub warning: Option<RoleOverride>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub danger: Option<RoleOverride>,
166}
167
168#[derive(Clone, Debug, Deserialize, Serialize)]
170#[serde(deny_unknown_fields)]
171pub struct RoleOverride {
172 pub color: String,
174 pub foreground: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub hover: Option<String>,
181}
182
183#[derive(Debug)]
185pub enum ThemeDocumentError {
186 Json(serde_json::Error),
188 Color {
190 field: String,
192 value: String,
194 detail: String,
196 },
197 AccentConflict,
199}
200
201impl fmt::Display for ThemeDocumentError {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 match self {
204 Self::Json(err) => write!(f, "theme document: {err}"),
205 Self::Color {
206 field,
207 value,
208 detail,
209 } => {
210 write!(
211 f,
212 "theme document: {field} value {value:?} is not a colour ({detail})"
213 )
214 }
215 Self::AccentConflict => write!(
216 f,
217 "theme document: `accent` and `roles.accent` cannot both be set"
218 ),
219 }
220 }
221}
222
223impl std::error::Error for ThemeDocumentError {
224 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
225 match self {
226 Self::Json(err) => Some(err),
227 Self::Color { .. } | Self::AccentConflict => None,
228 }
229 }
230}
231
232impl From<serde_json::Error> for ThemeDocumentError {
233 fn from(err: serde_json::Error) -> Self {
234 Self::Json(err)
235 }
236}
237
238impl ThemeDocument {
239 pub fn from_json(json: &str) -> Result<Self, ThemeDocumentError> {
241 Ok(serde_json::from_str(json)?)
242 }
243
244 pub fn theme_from_json(json: &str) -> Result<Theme, ThemeDocumentError> {
246 Self::from_json(json)?.to_theme()
247 }
248
249 pub fn to_json(&self) -> Result<String, ThemeDocumentError> {
251 Ok(serde_json::to_string_pretty(self)?)
252 }
253
254 pub fn to_theme(&self) -> Result<Theme, ThemeDocumentError> {
256 let base = match self.base {
257 Appearance::Light => Theme::light(),
258 Appearance::Dark => Theme::dark(),
259 };
260 let mut builder = Theme::builder(self.id.clone(), base);
261 if let Some(appearance) = self.appearance {
262 builder = builder.appearance(appearance);
263 }
264 if let Some(radius) = self.radius {
265 builder = builder.radius(px(radius));
266 }
267 if let Some(radius) = self.field_radius {
268 builder = builder.field_radius(px(radius));
269 }
270 if let Some(width) = self.border_width {
271 builder = builder.border_width(px(width));
272 }
273 if let Some(opacity) = self.disabled_opacity {
274 builder = builder.disabled_opacity(opacity);
275 }
276 if let Some(cursor) = self.cursor_interactive {
277 builder = builder.cursor_interactive(cursor);
278 }
279 if let Some(opacity) = self.tabs_hover_opacity {
280 builder = builder.tabs_hover_opacity(opacity);
281 }
282 if let Some(ms) = self.tooltip_cooldown_ms {
283 builder = builder.tooltip_cooldown_ms(ms);
284 }
285 if let Some(ms) = self.long_press_ms {
286 builder = builder.long_press_ms(ms);
287 }
288 if let Some(ms) = self.hover_fade_ms {
289 builder = builder.hover_fade_ms(ms);
290 }
291 if let Some(ms) = self.tooltip_delay_ms {
292 builder = builder.tooltip_delay_ms(ms);
293 }
294 if let Some(vibrant) = self.vibrant_palette {
295 builder = builder.vibrant_palette(vibrant);
296 }
297 if let Some(ms) = self.tooltip_close_delay_ms {
298 builder = builder.tooltip_close_delay_ms(ms);
299 }
300 builder = apply_color(
301 builder,
302 "background",
303 self.background.as_deref(),
304 ThemeBuilder::background,
305 )?;
306 builder = apply_color(
307 builder,
308 "foreground",
309 self.foreground.as_deref(),
310 ThemeBuilder::foreground,
311 )?;
312 builder = apply_color(builder, "muted", self.muted.as_deref(), ThemeBuilder::muted)?;
313 builder = apply_color(
314 builder,
315 "border",
316 self.border.as_deref(),
317 ThemeBuilder::border,
318 )?;
319 builder = apply_color(
320 builder,
321 "separator",
322 self.separator.as_deref(),
323 ThemeBuilder::separator,
324 )?;
325 builder = apply_color(builder, "focus", self.focus.as_deref(), ThemeBuilder::focus)?;
326 builder = apply_color(builder, "link", self.link.as_deref(), ThemeBuilder::link)?;
327 builder = apply_color(
328 builder,
329 "backdrop",
330 self.backdrop.as_deref(),
331 ThemeBuilder::backdrop,
332 )?;
333 if let Some(pair) = &self.surface {
334 builder = builder.surface(
335 parse_color("surface.background", &pair.background)?,
336 parse_color("surface.foreground", &pair.foreground)?,
337 );
338 }
339 if let Some(levels) = &self.surface_levels {
340 builder = builder.surface_levels(
341 parse_color("surface_levels.secondary", &levels.secondary)?,
342 parse_color("surface_levels.tertiary", &levels.tertiary)?,
343 );
344 }
345 if let Some(pair) = &self.overlay {
346 builder = builder.overlay(
347 parse_color("overlay.background", &pair.background)?,
348 parse_color("overlay.foreground", &pair.foreground)?,
349 );
350 }
351 if let Some(pair) = &self.segment {
352 builder = builder.segment(
353 parse_color("segment.background", &pair.background)?,
354 parse_color("segment.foreground", &pair.foreground)?,
355 );
356 }
357 if self.accent.is_some() && self.roles.as_ref().is_some_and(|r| r.accent.is_some()) {
358 return Err(ThemeDocumentError::AccentConflict);
359 }
360 if let Some(accent) = &self.accent {
361 builder = builder.accent(parse_color("accent", accent)?);
362 }
363 if let Some(roles) = &self.roles {
364 builder = apply_role(builder, Color::Default, roles.default.as_ref())?;
365 builder = apply_role(builder, Color::Accent, roles.accent.as_ref())?;
366 builder = apply_role(builder, Color::Success, roles.success.as_ref())?;
367 builder = apply_role(builder, Color::Warning, roles.warning.as_ref())?;
368 builder = apply_role(builder, Color::Danger, roles.danger.as_ref())?;
369 }
370 if let Some(pair) = &self.field {
371 builder = builder.field(
372 parse_color("field.background", &pair.background)?,
373 parse_color("field.foreground", &pair.foreground)?,
374 );
375 }
376 builder = apply_color(
377 builder,
378 "field_placeholder",
379 self.field_placeholder.as_deref(),
380 ThemeBuilder::field_placeholder,
381 )?;
382 builder = apply_color(
383 builder,
384 "field_border",
385 self.field_border.as_deref(),
386 ThemeBuilder::field_border,
387 )?;
388 Ok(builder.build())
389 }
390}
391
392fn apply_color(
393 builder: ThemeBuilder,
394 field: &str,
395 raw: Option<&str>,
396 apply: fn(ThemeBuilder, Hsla) -> ThemeBuilder,
397) -> Result<ThemeBuilder, ThemeDocumentError> {
398 match raw {
399 Some(value) => Ok(apply(builder, parse_color(field, value)?)),
400 None => Ok(builder),
401 }
402}
403
404fn apply_role(
405 builder: ThemeBuilder,
406 slot: Color,
407 role: Option<&RoleOverride>,
408) -> Result<ThemeBuilder, ThemeDocumentError> {
409 let name = slot.token();
410 match role {
411 Some(role) => {
412 let builder = builder.role(
413 slot,
414 parse_color(&format!("roles.{name}.color"), &role.color)?,
415 parse_color(&format!("roles.{name}.foreground"), &role.foreground)?,
416 );
417 match &role.hover {
418 Some(hover) => {
419 Ok(builder
420 .role_hover(slot, parse_color(&format!("roles.{name}.hover"), hover)?))
421 }
422 None => Ok(builder),
423 }
424 }
425 None => Ok(builder),
426 }
427}
428
429fn parse_color(field: &str, raw: &str) -> Result<Hsla, ThemeDocumentError> {
431 let value = raw.trim();
432 if let Some(hex) = value.strip_prefix('#') {
433 return parse_hex(field, value, hex);
434 }
435 if let Some(inner) = value
436 .strip_prefix("oklch(")
437 .and_then(|rest| rest.strip_suffix(')'))
438 {
439 return parse_oklch(field, value, inner);
440 }
441 if let Some(inner) = value
442 .strip_prefix("oklcha(")
443 .and_then(|rest| rest.strip_suffix(')'))
444 {
445 return parse_oklch(field, value, inner);
446 }
447 Err(ThemeDocumentError::Color {
448 field: field.to_owned(),
449 value: value.to_owned(),
450 detail: "expected oklch(...), oklcha(...) or #hex".into(),
451 })
452}
453
454fn parse_oklch(field: &str, raw: &str, inner: &str) -> Result<Hsla, ThemeDocumentError> {
455 let normalized = inner.replace('/', " ");
456 let parts: Vec<&str> = normalized.split_whitespace().collect();
457 if parts.len() < 3 || parts.len() > 4 {
458 return Err(ThemeDocumentError::Color {
459 field: field.to_owned(),
460 value: raw.to_owned(),
461 detail: "oklch takes L C H, optionally / alpha".into(),
462 });
463 }
464 let l = parse_component(field, raw, parts[0], true)?;
465 let c = parse_component(field, raw, parts[1], false)?;
466 let h = parse_component(field, raw, parts[2], false)?;
467 let a = match parts.get(3) {
468 Some(part) => parse_component(field, raw, part, false)?,
469 None => 1.0,
470 };
471 Ok(oklcha(l, c, h, a))
472}
473
474fn parse_component(
475 field: &str,
476 raw: &str,
477 part: &str,
478 lightness: bool,
479) -> Result<f32, ThemeDocumentError> {
480 let percent = part.ends_with('%');
481 let number = part.trim_end_matches('%');
482 let value: f32 = number
484 .parse()
485 .ok()
486 .filter(|v: &f32| v.is_finite())
487 .ok_or_else(|| ThemeDocumentError::Color {
488 field: field.to_owned(),
489 value: raw.to_owned(),
490 detail: format!("cannot parse {part:?} as a finite number"),
491 })?;
492 if percent || (lightness && value > 1.0) {
493 Ok(value / 100.0)
494 } else {
495 Ok(value)
496 }
497}
498
499fn parse_hex(field: &str, raw: &str, hex: &str) -> Result<Hsla, ThemeDocumentError> {
500 let hex = hex.trim();
501 let fail = |detail: &str| ThemeDocumentError::Color {
502 field: field.to_owned(),
503 value: raw.to_owned(),
504 detail: detail.into(),
505 };
506 let nibble = |ch: u8| match ch {
507 b'0'..=b'9' => Ok(ch - b'0'),
508 b'a'..=b'f' => Ok(ch - b'a' + 10),
509 b'A'..=b'F' => Ok(ch - b'A' + 10),
510 _ => Err(fail("hex digit is not 0-9A-F")),
511 };
512 let byte =
513 |hi: u8, lo: u8| -> Result<u8, ThemeDocumentError> { Ok((nibble(hi)? << 4) | nibble(lo)?) };
514 let bytes = hex.as_bytes();
515 let (r, g, b, a) = match bytes {
516 [r, g, b] => (nibble(*r)? * 17, nibble(*g)? * 17, nibble(*b)? * 17, 255),
517 [r, g, b, a] => (
518 nibble(*r)? * 17,
519 nibble(*g)? * 17,
520 nibble(*b)? * 17,
521 nibble(*a)? * 17,
522 ),
523 [r1, r2, g1, g2, b1, b2] => (byte(*r1, *r2)?, byte(*g1, *g2)?, byte(*b1, *b2)?, 255),
524 [r1, r2, g1, g2, b1, b2, a1, a2] => (
525 byte(*r1, *r2)?,
526 byte(*g1, *g2)?,
527 byte(*b1, *b2)?,
528 byte(*a1, *a2)?,
529 ),
530 _ => return Err(fail("hex is #RGB, #RGBA, #RRGGBB or #RRGGBBAA")),
531 };
532 Ok(Hsla::from(Rgba {
533 r: r as f32 / 255.0,
534 g: g as f32 / 255.0,
535 b: b as f32 / 255.0,
536 a: a as f32 / 255.0,
537 }))
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use herogpui_core::{oklch, with_alpha};
544
545 #[test]
549 fn non_finite_color_components_are_rejected() {
550 for raw in [
551 "oklch(NaN 0.1 250)",
552 "oklch(0.5 inf 250)",
553 "oklch(0.5 0.1 -infinity)",
554 "oklch(0.5 0.1 250 / nan)",
555 ] {
556 let json = format!(r#"{{ "id": "x", "base": "light", "accent": "{raw}" }}"#);
557 let err = ThemeDocument::theme_from_json(&json).unwrap_err();
558 assert!(
559 matches!(err, ThemeDocumentError::Color { .. }),
560 "{raw}: {err}"
561 );
562 }
563 }
564
565 #[test]
566 fn an_empty_document_is_the_named_base_with_a_new_id() {
567 let theme =
568 ThemeDocument::theme_from_json(r#"{ "id": "brand", "base": "light" }"#).unwrap();
569 let base = Theme::light();
570 assert_eq!(theme.id.as_ref(), "brand");
571 assert_eq!(theme.appearance, Appearance::Light);
572 assert_eq!(theme.colors.background, base.colors.background);
573 assert_eq!(theme.colors.accent.color, base.colors.accent.color);
574 assert_eq!(theme.layout.radius, base.layout.radius);
575 }
576
577 #[test]
578 fn overrides_go_through_the_builder_so_derived_mixes_stay_live() {
579 let accent = oklch(0.55, 0.23, 295.0);
580 let via_builder = Theme::builder("violet", Theme::light())
581 .accent(accent)
582 .foreground(oklch(0.30, 0.05, 120.0))
583 .build();
584 let via_json = ThemeDocument::theme_from_json(
585 r#"{
586 "id": "violet",
587 "base": "light",
588 "accent": "oklch(0.55 0.23 295)",
589 "foreground": "oklch(0.30 0.05 120)"
590 }"#,
591 )
592 .unwrap();
593 assert_eq!(
594 via_json.colors.accent.color,
595 via_builder.colors.accent.color
596 );
597 assert_eq!(
598 via_json.colors.accent.foreground,
599 via_builder.colors.accent.foreground
600 );
601 assert_eq!(via_json.colors.scrollbar, via_builder.colors.scrollbar);
602 assert_eq!(
603 via_json.colors.scrollbar,
604 with_alpha(oklch(0.30, 0.05, 120.0), 0.15)
605 );
606 assert!((via_json.colors.accent.soft().a - 0.15).abs() < 1e-4);
607 }
608
609 #[test]
610 fn unknown_keys_are_rejected() {
611 let err =
612 ThemeDocument::from_json(r##"{ "id": "x", "base": "light", "primary": "#f00" }"##)
613 .unwrap_err();
614 let message = err.to_string();
615 assert!(
616 message.contains("primary") || message.contains("unknown"),
617 "{message}"
618 );
619 }
620
621 #[test]
624 fn an_unknown_role_is_rejected() {
625 let err = ThemeDocument::from_json(
626 r##"{ "id": "x", "base": "light",
627 "roles": { "sucess": { "color": "#0f0", "foreground": "#fff" } } }"##,
628 )
629 .unwrap_err();
630 let message = err.to_string();
631 assert!(message.contains("sucess"), "{message}");
632 assert!(matches!(err, ThemeDocumentError::Json(_)), "{err:?}");
633 }
634
635 #[test]
636 fn accent_and_roles_accent_cannot_both_be_set() {
637 let err = ThemeDocument::theme_from_json(
638 r##"{
639 "id": "x",
640 "base": "light",
641 "accent": "#006FEE",
642 "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } }
643 }"##,
644 )
645 .unwrap_err();
646 assert!(matches!(err, ThemeDocumentError::AccentConflict));
647 }
648
649 #[test]
650 fn hex_and_percent_lightness_parse() {
651 let theme = ThemeDocument::theme_from_json(
652 r##"{
653 "id": "x",
654 "base": "dark",
655 "background": "#111",
656 "link": "oklch(55% 0.2 250 / 0.9)"
657 }"##,
658 )
659 .unwrap();
660 assert_eq!(theme.id.as_ref(), "x");
661 assert_eq!(theme.appearance, Appearance::Dark);
662 assert!((theme.colors.link.a - 0.9).abs() < 1e-4);
663 }
664
665 #[test]
666 fn customisation_tokens_apply_from_json_through_the_builder() {
667 let json = r#"{
668 "id": "x",
669 "base": "light",
670 "tabs_hover_opacity": 0.2,
671 "tooltip_cooldown_ms": 250,
672 "long_press_ms": 350,
673 "hover_fade_ms": 0,
674 "tooltip_delay_ms": 50,
675 "tooltip_close_delay_ms": 75
676 }"#;
677 let theme = ThemeDocument::theme_from_json(json).unwrap();
678 assert!((theme.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
679 assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
680 assert_eq!(theme.layout.long_press_ms, 350);
681 assert_eq!(theme.layout.hover_fade_ms, 0);
682 assert_eq!(theme.layout.tooltip_delay_ms, 50);
683 assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
684
685 let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
688 let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
689 assert!((again.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
690 assert_eq!(again.layout.tooltip_cooldown_ms, 250);
691 assert_eq!(again.layout.long_press_ms, 350);
692 assert_eq!(again.layout.hover_fade_ms, 0);
693 assert_eq!(again.layout.tooltip_delay_ms, 50);
694 assert_eq!(again.layout.tooltip_close_delay_ms, 75);
695
696 let clamped = ThemeDocument::theme_from_json(
697 r#"{ "id": "x", "base": "light", "tabs_hover_opacity": 3.0 }"#,
698 )
699 .unwrap();
700 assert!((clamped.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
701 }
702
703 #[test]
704 fn the_vibrant_palette_key_round_trips_and_reaches_the_builder() {
705 let json = r#"{ "id": "x", "base": "light", "vibrant_palette": true }"#;
706 let theme = ThemeDocument::theme_from_json(json).unwrap();
707 assert!(theme.colors.vibrant_palette());
708
709 let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
710 assert!(round_tripped.contains("vibrant_palette"));
711 let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
712 assert!(again.colors.vibrant_palette());
713
714 let plain = ThemeDocument::theme_from_json(r#"{ "id": "x", "base": "dark" }"#).unwrap();
716 assert!(!plain.colors.vibrant_palette());
717 assert!(
718 !ThemeDocument::from_json(r#"{ "id": "x", "base": "dark" }"#)
719 .unwrap()
720 .to_json()
721 .unwrap()
722 .contains("vibrant_palette")
723 );
724 }
725
726 #[test]
727 fn a_role_hover_override_round_trips_and_reaches_the_builder() {
728 let json = r##"{
729 "id": "x",
730 "base": "light",
731 "roles": { "accent": { "color": "#006FEE", "foreground": "#fff", "hover": "#0058BE" } }
732 }"##;
733 let theme = ThemeDocument::theme_from_json(json).unwrap();
734 let via_builder = Theme::builder("x", Theme::light())
735 .role(
736 Color::Accent,
737 parse_color("c", "#006FEE").unwrap(),
738 parse_color("f", "#fff").unwrap(),
739 )
740 .role_hover(Color::Accent, parse_color("h", "#0058BE").unwrap())
741 .build();
742 assert_eq!(
743 theme.colors.accent.hover(),
744 via_builder.colors.accent.hover()
745 );
746 assert_eq!(
747 theme.colors.accent.hover_override(),
748 via_builder.colors.accent.hover_override()
749 );
750
751 let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
752 assert!(round_tripped.contains("hover"));
753 let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
754 assert!(again.colors.accent.hover_override().is_some());
755
756 let plain = ThemeDocument::theme_from_json(
758 r##"{ "id": "x", "base": "light", "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } } }"##,
759 )
760 .unwrap();
761 assert_eq!(plain.colors.accent.hover_override(), None);
762 assert!(
763 !ThemeDocument::from_json(
764 r##"{ "id": "x", "base": "light", "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } } }"##,
765 )
766 .unwrap()
767 .to_json()
768 .unwrap()
769 .contains("hover")
770 );
771 }
772
773 #[test]
774 fn a_document_round_trips_without_growing_derived_keys() {
775 let original =
776 ThemeDocument::from_json(r#"{ "id": "brand", "base": "light", "radius": 8 }"#).unwrap();
777 let json = original.to_json().unwrap();
778 assert!(!json.contains("scrollbar"));
779 assert!(!json.contains("soft"));
780 let again = ThemeDocument::from_json(&json).unwrap();
781 assert_eq!(again.id, "brand");
782 assert_eq!(again.radius, Some(8.0));
783 }
784}