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