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}
144
145#[derive(Debug)]
147pub enum ThemeDocumentError {
148 Json(serde_json::Error),
149 Color {
150 field: String,
151 value: String,
152 detail: String,
153 },
154 AccentConflict,
155}
156
157impl fmt::Display for ThemeDocumentError {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 match self {
160 Self::Json(err) => write!(f, "theme document: {err}"),
161 Self::Color {
162 field,
163 value,
164 detail,
165 } => {
166 write!(
167 f,
168 "theme document: {field} value {value:?} is not a colour ({detail})"
169 )
170 }
171 Self::AccentConflict => write!(
172 f,
173 "theme document: `accent` and `roles.accent` cannot both be set"
174 ),
175 }
176 }
177}
178
179impl std::error::Error for ThemeDocumentError {
180 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
181 match self {
182 Self::Json(err) => Some(err),
183 Self::Color { .. } | Self::AccentConflict => None,
184 }
185 }
186}
187
188impl From<serde_json::Error> for ThemeDocumentError {
189 fn from(err: serde_json::Error) -> Self {
190 Self::Json(err)
191 }
192}
193
194impl ThemeDocument {
195 pub fn from_json(json: &str) -> Result<Self, ThemeDocumentError> {
197 Ok(serde_json::from_str(json)?)
198 }
199
200 pub fn theme_from_json(json: &str) -> Result<Theme, ThemeDocumentError> {
202 Self::from_json(json)?.to_theme()
203 }
204
205 pub fn to_json(&self) -> Result<String, ThemeDocumentError> {
207 Ok(serde_json::to_string_pretty(self)?)
208 }
209
210 pub fn to_theme(&self) -> Result<Theme, ThemeDocumentError> {
212 let base = match self.base {
213 Appearance::Light => Theme::light(),
214 Appearance::Dark => Theme::dark(),
215 };
216 let mut builder = Theme::builder(self.id.clone(), base);
217 if let Some(appearance) = self.appearance {
218 builder = builder.appearance(appearance);
219 }
220 if let Some(radius) = self.radius {
221 builder = builder.radius(px(radius));
222 }
223 if let Some(radius) = self.field_radius {
224 builder = builder.field_radius(px(radius));
225 }
226 if let Some(width) = self.border_width {
227 builder = builder.border_width(px(width));
228 }
229 if let Some(opacity) = self.disabled_opacity {
230 builder = builder.disabled_opacity(opacity);
231 }
232 if let Some(cursor) = self.cursor_interactive {
233 builder = builder.cursor_interactive(cursor);
234 }
235 if let Some(opacity) = self.tabs_hover_opacity {
236 builder = builder.tabs_hover_opacity(opacity);
237 }
238 if let Some(ms) = self.tooltip_cooldown_ms {
239 builder = builder.tooltip_cooldown_ms(ms);
240 }
241 if let Some(ms) = self.long_press_ms {
242 builder = builder.long_press_ms(ms);
243 }
244 if let Some(ms) = self.hover_fade_ms {
245 builder = builder.hover_fade_ms(ms);
246 }
247 if let Some(ms) = self.tooltip_delay_ms {
248 builder = builder.tooltip_delay_ms(ms);
249 }
250 if let Some(vibrant) = self.vibrant_palette {
251 builder = builder.vibrant_palette(vibrant);
252 }
253 if let Some(ms) = self.tooltip_close_delay_ms {
254 builder = builder.tooltip_close_delay_ms(ms);
255 }
256 builder = apply_color(
257 builder,
258 "background",
259 self.background.as_deref(),
260 ThemeBuilder::background,
261 )?;
262 builder = apply_color(
263 builder,
264 "foreground",
265 self.foreground.as_deref(),
266 ThemeBuilder::foreground,
267 )?;
268 builder = apply_color(builder, "muted", self.muted.as_deref(), ThemeBuilder::muted)?;
269 builder = apply_color(
270 builder,
271 "border",
272 self.border.as_deref(),
273 ThemeBuilder::border,
274 )?;
275 builder = apply_color(
276 builder,
277 "separator",
278 self.separator.as_deref(),
279 ThemeBuilder::separator,
280 )?;
281 builder = apply_color(builder, "focus", self.focus.as_deref(), ThemeBuilder::focus)?;
282 builder = apply_color(builder, "link", self.link.as_deref(), ThemeBuilder::link)?;
283 builder = apply_color(
284 builder,
285 "backdrop",
286 self.backdrop.as_deref(),
287 ThemeBuilder::backdrop,
288 )?;
289 if let Some(pair) = &self.surface {
290 builder = builder.surface(
291 parse_color("surface.background", &pair.background)?,
292 parse_color("surface.foreground", &pair.foreground)?,
293 );
294 }
295 if let Some(levels) = &self.surface_levels {
296 builder = builder.surface_levels(
297 parse_color("surface_levels.secondary", &levels.secondary)?,
298 parse_color("surface_levels.tertiary", &levels.tertiary)?,
299 );
300 }
301 if let Some(pair) = &self.overlay {
302 builder = builder.overlay(
303 parse_color("overlay.background", &pair.background)?,
304 parse_color("overlay.foreground", &pair.foreground)?,
305 );
306 }
307 if let Some(pair) = &self.segment {
308 builder = builder.segment(
309 parse_color("segment.background", &pair.background)?,
310 parse_color("segment.foreground", &pair.foreground)?,
311 );
312 }
313 if self.accent.is_some() && self.roles.as_ref().is_some_and(|r| r.accent.is_some()) {
314 return Err(ThemeDocumentError::AccentConflict);
315 }
316 if let Some(accent) = &self.accent {
317 builder = builder.accent(parse_color("accent", accent)?);
318 }
319 if let Some(roles) = &self.roles {
320 builder = apply_role(builder, "default", roles.default.as_ref())?;
321 builder = apply_role(builder, "accent", roles.accent.as_ref())?;
322 builder = apply_role(builder, "success", roles.success.as_ref())?;
323 builder = apply_role(builder, "warning", roles.warning.as_ref())?;
324 builder = apply_role(builder, "danger", roles.danger.as_ref())?;
325 }
326 if let Some(pair) = &self.field {
327 builder = builder.field(
328 parse_color("field.background", &pair.background)?,
329 parse_color("field.foreground", &pair.foreground)?,
330 );
331 }
332 builder = apply_color(
333 builder,
334 "field_placeholder",
335 self.field_placeholder.as_deref(),
336 ThemeBuilder::field_placeholder,
337 )?;
338 builder = apply_color(
339 builder,
340 "field_border",
341 self.field_border.as_deref(),
342 ThemeBuilder::field_border,
343 )?;
344 Ok(builder.build())
345 }
346}
347
348fn apply_color(
349 builder: ThemeBuilder,
350 field: &str,
351 raw: Option<&str>,
352 apply: fn(ThemeBuilder, Hsla) -> ThemeBuilder,
353) -> Result<ThemeBuilder, ThemeDocumentError> {
354 match raw {
355 Some(value) => Ok(apply(builder, parse_color(field, value)?)),
356 None => Ok(builder),
357 }
358}
359
360fn apply_role(
361 builder: ThemeBuilder,
362 name: &str,
363 role: Option<&RoleOverride>,
364) -> Result<ThemeBuilder, ThemeDocumentError> {
365 match role {
366 Some(role) => Ok(builder.role(
367 name,
368 parse_color(&format!("roles.{name}.color"), &role.color)?,
369 parse_color(&format!("roles.{name}.foreground"), &role.foreground)?,
370 )),
371 None => Ok(builder),
372 }
373}
374
375fn parse_color(field: &str, raw: &str) -> Result<Hsla, ThemeDocumentError> {
377 let value = raw.trim();
378 if let Some(hex) = value.strip_prefix('#') {
379 return parse_hex(field, value, hex);
380 }
381 if let Some(inner) = value
382 .strip_prefix("oklch(")
383 .and_then(|rest| rest.strip_suffix(')'))
384 {
385 return parse_oklch(field, value, inner);
386 }
387 if let Some(inner) = value
388 .strip_prefix("oklcha(")
389 .and_then(|rest| rest.strip_suffix(')'))
390 {
391 return parse_oklch(field, value, inner);
392 }
393 Err(ThemeDocumentError::Color {
394 field: field.to_owned(),
395 value: value.to_owned(),
396 detail: "expected oklch(...), oklcha(...) or #hex".into(),
397 })
398}
399
400fn parse_oklch(field: &str, raw: &str, inner: &str) -> Result<Hsla, ThemeDocumentError> {
401 let normalized = inner.replace('/', " ");
402 let parts: Vec<&str> = normalized.split_whitespace().collect();
403 if parts.len() < 3 || parts.len() > 4 {
404 return Err(ThemeDocumentError::Color {
405 field: field.to_owned(),
406 value: raw.to_owned(),
407 detail: "oklch takes L C H, optionally / alpha".into(),
408 });
409 }
410 let l = parse_component(field, raw, parts[0], true)?;
411 let c = parse_component(field, raw, parts[1], false)?;
412 let h = parse_component(field, raw, parts[2], false)?;
413 let a = match parts.get(3) {
414 Some(part) => parse_component(field, raw, part, false)?,
415 None => 1.0,
416 };
417 Ok(oklcha(l, c, h, a))
418}
419
420fn parse_component(
421 field: &str,
422 raw: &str,
423 part: &str,
424 lightness: bool,
425) -> Result<f32, ThemeDocumentError> {
426 let percent = part.ends_with('%');
427 let number = part.trim_end_matches('%');
428 let value: f32 = number.parse().map_err(|_| ThemeDocumentError::Color {
429 field: field.to_owned(),
430 value: raw.to_owned(),
431 detail: format!("cannot parse {part:?} as a number"),
432 })?;
433 if percent || (lightness && value > 1.0) {
434 Ok(value / 100.0)
435 } else {
436 Ok(value)
437 }
438}
439
440fn parse_hex(field: &str, raw: &str, hex: &str) -> Result<Hsla, ThemeDocumentError> {
441 let hex = hex.trim();
442 let fail = |detail: &str| ThemeDocumentError::Color {
443 field: field.to_owned(),
444 value: raw.to_owned(),
445 detail: detail.into(),
446 };
447 let nibble = |ch: u8| match ch {
448 b'0'..=b'9' => Ok(ch - b'0'),
449 b'a'..=b'f' => Ok(ch - b'a' + 10),
450 b'A'..=b'F' => Ok(ch - b'A' + 10),
451 _ => Err(fail("hex digit is not 0-9A-F")),
452 };
453 let byte =
454 |hi: u8, lo: u8| -> Result<u8, ThemeDocumentError> { Ok((nibble(hi)? << 4) | nibble(lo)?) };
455 let bytes = hex.as_bytes();
456 let (r, g, b, a) = match bytes {
457 [r, g, b] => (nibble(*r)? * 17, nibble(*g)? * 17, nibble(*b)? * 17, 255),
458 [r, g, b, a] => (
459 nibble(*r)? * 17,
460 nibble(*g)? * 17,
461 nibble(*b)? * 17,
462 nibble(*a)? * 17,
463 ),
464 [r1, r2, g1, g2, b1, b2] => (byte(*r1, *r2)?, byte(*g1, *g2)?, byte(*b1, *b2)?, 255),
465 [r1, r2, g1, g2, b1, b2, a1, a2] => (
466 byte(*r1, *r2)?,
467 byte(*g1, *g2)?,
468 byte(*b1, *b2)?,
469 byte(*a1, *a2)?,
470 ),
471 _ => return Err(fail("hex is #RGB, #RGBA, #RRGGBB or #RRGGBBAA")),
472 };
473 Ok(Hsla::from(Rgba {
474 r: r as f32 / 255.0,
475 g: g as f32 / 255.0,
476 b: b as f32 / 255.0,
477 a: a as f32 / 255.0,
478 }))
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use herogpui_core::{oklch, with_alpha};
485
486 #[test]
487 fn an_empty_document_is_the_named_base_with_a_new_id() {
488 let theme =
489 ThemeDocument::theme_from_json(r#"{ "id": "brand", "base": "light" }"#).unwrap();
490 let base = Theme::light();
491 assert_eq!(theme.id.as_ref(), "brand");
492 assert_eq!(theme.appearance, Appearance::Light);
493 assert_eq!(theme.colors.background, base.colors.background);
494 assert_eq!(theme.colors.accent.color, base.colors.accent.color);
495 assert_eq!(theme.layout.radius, base.layout.radius);
496 }
497
498 #[test]
499 fn overrides_go_through_the_builder_so_derived_mixes_stay_live() {
500 let accent = oklch(0.55, 0.23, 295.0);
501 let via_builder = Theme::builder("violet", Theme::light())
502 .accent(accent)
503 .foreground(oklch(0.30, 0.05, 120.0))
504 .build();
505 let via_json = ThemeDocument::theme_from_json(
506 r#"{
507 "id": "violet",
508 "base": "light",
509 "accent": "oklch(0.55 0.23 295)",
510 "foreground": "oklch(0.30 0.05 120)"
511 }"#,
512 )
513 .unwrap();
514 assert_eq!(
515 via_json.colors.accent.color,
516 via_builder.colors.accent.color
517 );
518 assert_eq!(
519 via_json.colors.accent.foreground,
520 via_builder.colors.accent.foreground
521 );
522 assert_eq!(via_json.colors.scrollbar, via_builder.colors.scrollbar);
523 assert_eq!(
524 via_json.colors.scrollbar,
525 with_alpha(oklch(0.30, 0.05, 120.0), 0.15)
526 );
527 assert!((via_json.colors.accent.soft().a - 0.15).abs() < 1e-4);
528 }
529
530 #[test]
531 fn unknown_keys_are_rejected() {
532 let err =
533 ThemeDocument::from_json(r##"{ "id": "x", "base": "light", "primary": "#f00" }"##)
534 .unwrap_err();
535 let message = err.to_string();
536 assert!(
537 message.contains("primary") || message.contains("unknown"),
538 "{message}"
539 );
540 }
541
542 #[test]
543 fn accent_and_roles_accent_cannot_both_be_set() {
544 let err = ThemeDocument::theme_from_json(
545 r##"{
546 "id": "x",
547 "base": "light",
548 "accent": "#006FEE",
549 "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } }
550 }"##,
551 )
552 .unwrap_err();
553 assert!(matches!(err, ThemeDocumentError::AccentConflict));
554 }
555
556 #[test]
557 fn hex_and_percent_lightness_parse() {
558 let theme = ThemeDocument::theme_from_json(
559 r##"{
560 "id": "x",
561 "base": "dark",
562 "background": "#111",
563 "link": "oklch(55% 0.2 250 / 0.9)"
564 }"##,
565 )
566 .unwrap();
567 assert_eq!(theme.id.as_ref(), "x");
568 assert_eq!(theme.appearance, Appearance::Dark);
569 assert!((theme.colors.link.a - 0.9).abs() < 1e-4);
570 }
571
572 #[test]
573 fn customisation_tokens_apply_from_json_through_the_builder() {
574 let json = r#"{
575 "id": "x",
576 "base": "light",
577 "tabs_hover_opacity": 0.2,
578 "tooltip_cooldown_ms": 250,
579 "long_press_ms": 350,
580 "hover_fade_ms": 0,
581 "tooltip_delay_ms": 50,
582 "tooltip_close_delay_ms": 75
583 }"#;
584 let theme = ThemeDocument::theme_from_json(json).unwrap();
585 assert!((theme.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
586 assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
587 assert_eq!(theme.layout.long_press_ms, 350);
588 assert_eq!(theme.layout.hover_fade_ms, 0);
589 assert_eq!(theme.layout.tooltip_delay_ms, 50);
590 assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
591
592 let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
595 let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
596 assert!((again.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
597 assert_eq!(again.layout.tooltip_cooldown_ms, 250);
598 assert_eq!(again.layout.long_press_ms, 350);
599 assert_eq!(again.layout.hover_fade_ms, 0);
600 assert_eq!(again.layout.tooltip_delay_ms, 50);
601 assert_eq!(again.layout.tooltip_close_delay_ms, 75);
602
603 let clamped = ThemeDocument::theme_from_json(
604 r#"{ "id": "x", "base": "light", "tabs_hover_opacity": 3.0 }"#,
605 )
606 .unwrap();
607 assert!((clamped.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
608 }
609
610 #[test]
611 fn the_vibrant_palette_key_round_trips_and_reaches_the_builder() {
612 let json = r#"{ "id": "x", "base": "light", "vibrant_palette": true }"#;
613 let theme = ThemeDocument::theme_from_json(json).unwrap();
614 assert!(theme.colors.vibrant_palette());
615
616 let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
617 assert!(round_tripped.contains("vibrant_palette"));
618 let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
619 assert!(again.colors.vibrant_palette());
620
621 let plain = ThemeDocument::theme_from_json(r#"{ "id": "x", "base": "dark" }"#).unwrap();
623 assert!(!plain.colors.vibrant_palette());
624 assert!(
625 !ThemeDocument::from_json(r#"{ "id": "x", "base": "dark" }"#)
626 .unwrap()
627 .to_json()
628 .unwrap()
629 .contains("vibrant_palette")
630 );
631 }
632
633 #[test]
634 fn a_document_round_trips_without_growing_derived_keys() {
635 let original =
636 ThemeDocument::from_json(r#"{ "id": "brand", "base": "light", "radius": 8 }"#).unwrap();
637 let json = original.to_json().unwrap();
638 assert!(!json.contains("scrollbar"));
639 assert!(!json.contains("soft"));
640 let again = ThemeDocument::from_json(&json).unwrap();
641 assert_eq!(again.id, "brand");
642 assert_eq!(again.radius, Some(8.0));
643 }
644}