1use arborium_theme::{HIGHLIGHTS, ThemeSlot, builtin, slot_to_highlight_index};
4pub use clankerdiff_fingerprint::{Fingerprint, FingerprintError};
5use serde::{Deserialize, Serialize};
6use std::{collections::BTreeMap, fmt};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct Rgba {
11 pub r: u8,
12 pub g: u8,
13 pub b: u8,
14 pub a: u8,
15}
16
17impl Rgba {
18 #[must_use]
19 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
20 Self { r, g, b, a }
21 }
22
23 #[must_use]
25 pub const fn over(self, background: Self) -> Self {
26 #[expect(
27 clippy::cast_possible_truncation,
28 reason = "weighted u8 average fits in u8"
29 )]
30 const fn channel(foreground: u8, background: u8, alpha: u8) -> u8 {
31 let foreground = foreground as u32 * alpha as u32;
32 let background = background as u32 * (u8::MAX - alpha) as u32;
33 ((foreground + background + 127) / 255) as u8
34 }
35 Self::new(
36 channel(self.r, background.r, self.a),
37 channel(self.g, background.g, self.a),
38 channel(self.b, background.b, self.a),
39 u8::MAX,
40 )
41 }
42
43 #[must_use]
44 pub const fn to_bytes(self) -> [u8; 4] {
45 [self.r, self.g, self.b, self.a]
46 }
47}
48
49impl Default for Rgba {
50 fn default() -> Self {
51 Self::new(212, 221, 214, 255)
52 }
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub struct FontStyle {
58 #[serde(default)]
59 pub bold: bool,
60 #[serde(default)]
61 pub italic: bool,
62 #[serde(default)]
63 pub underline: bool,
64}
65
66impl FontStyle {
67 #[must_use]
68 pub const fn none() -> Self {
69 Self {
70 bold: false,
71 italic: false,
72 underline: false,
73 }
74 }
75
76 #[must_use]
77 pub const fn is_plain(self) -> bool {
78 !self.bold && !self.italic && !self.underline
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct HighlightSpan {
85 pub range: std::ops::Range<usize>,
86 pub foreground: Rgba,
87 pub font_style: FontStyle,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92pub struct SyntaxStyle {
93 pub foreground: Rgba,
94 #[serde(default)]
95 pub font_style: FontStyle,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub enum DiffTone {
101 Context,
102 Added,
103 Removed,
104 Meta,
105}
106
107impl DiffTone {
108 #[must_use]
109 pub const fn marker(self) -> char {
110 match self {
111 Self::Added => '+',
112 Self::Removed => '-',
113 Self::Context | Self::Meta => ' ',
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ToneColors {
121 pub foreground: Rgba,
122 pub background: Rgba,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct DiffPalette {
128 pub background: Rgba,
129 pub foreground: Rgba,
130 pub gutter: Rgba,
131 pub addition: Rgba,
132 pub deletion: Rgba,
133 pub addition_background: Rgba,
134 pub deletion_background: Rgba,
135 pub selection: Rgba,
136 pub accent: Rgba,
137 pub muted: Rgba,
138 pub border: Rgba,
139}
140
141impl DiffPalette {
142 #[must_use]
143 pub const fn tone(&self, tone: DiffTone) -> ToneColors {
144 match tone {
145 DiffTone::Added => ToneColors {
146 foreground: self.addition,
147 background: self.addition_background,
148 },
149 DiffTone::Removed => ToneColors {
150 foreground: self.deletion,
151 background: self.deletion_background,
152 },
153 DiffTone::Context | DiffTone::Meta => ToneColors {
154 foreground: self.foreground,
155 background: self.background,
156 },
157 }
158 }
159
160 #[must_use]
161 pub const fn colors(&self) -> [Rgba; 11] {
162 [
163 self.background,
164 self.foreground,
165 self.gutter,
166 self.addition,
167 self.deletion,
168 self.addition_background,
169 self.deletion_background,
170 self.selection,
171 self.accent,
172 self.muted,
173 self.border,
174 ]
175 }
176}
177
178pub const SCRIM_ALPHA: u8 = 184;
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub struct UiPalette {
184 pub canvas: Rgba,
185 pub surface: Rgba,
186 pub surface_hover: Rgba,
187 pub surface_selected: Rgba,
188 pub text: Rgba,
189 pub text_muted: Rgba,
190 pub border: Rgba,
191 pub accent: Rgba,
192 pub accent_foreground: Rgba,
193 pub positive: Rgba,
194 pub destructive: Rgba,
195 pub scrim: Rgba,
196}
197
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200pub enum ButtonVariant {
201 Primary,
202 Secondary,
203 Destructive,
204 #[default]
205 Ghost,
206}
207
208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
210pub enum InteractionState {
211 #[default]
212 Rest,
213 Hovered,
214 Disabled,
215}
216
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
219pub struct ControlState {
220 pub interaction: InteractionState,
221 pub selected: bool,
222}
223
224impl ControlState {
225 #[must_use]
226 pub const fn new(interaction: InteractionState) -> Self {
227 Self {
228 interaction,
229 selected: false,
230 }
231 }
232
233 #[must_use]
234 pub const fn selected(mut self, selected: bool) -> Self {
235 self.selected = selected;
236 self
237 }
238}
239
240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
242pub enum ControlSize {
243 Small,
244 #[default]
245 Medium,
246}
247
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
250pub enum SelectionState {
251 #[default]
252 None,
253 Selected,
254 Focused,
255 Disabled,
256}
257
258#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260pub enum NoticeTone {
261 #[default]
262 Info,
263 Positive,
264 Warning,
265 Error,
266}
267
268#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
270pub enum ModalSize {
271 Compact,
272 #[default]
273 Medium,
274 Wide,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct SemanticStyle {
280 pub foreground: Rgba,
281 pub background: Option<Rgba>,
282 pub emphasized: bool,
283}
284
285impl UiPalette {
286 #[must_use]
288 pub const fn control_style(self, variant: ButtonVariant, state: ControlState) -> SemanticStyle {
289 if matches!(state.interaction, InteractionState::Disabled) {
290 return SemanticStyle {
291 foreground: self.text_muted,
292 background: Some(self.surface_selected),
293 emphasized: false,
294 };
295 }
296 let (foreground, background) = match (variant, state.interaction, state.selected) {
297 (ButtonVariant::Primary, _, _) => (self.accent_foreground, Some(self.accent)),
298 (ButtonVariant::Destructive, _, _) => (self.accent_foreground, Some(self.destructive)),
299 (ButtonVariant::Ghost, _, true) => (self.accent, Some(self.surface_selected)),
300 (ButtonVariant::Secondary | ButtonVariant::Ghost, InteractionState::Hovered, _) => {
301 (self.text, Some(self.surface_hover))
302 }
303 (ButtonVariant::Secondary, _, _) => (self.text, Some(self.surface_selected)),
304 (ButtonVariant::Ghost, _, false) => (self.text_muted, None),
305 };
306 SemanticStyle {
307 foreground,
308 background,
309 emphasized: state.selected,
310 }
311 }
312
313 #[must_use]
315 pub const fn selection_style(self, state: SelectionState) -> SemanticStyle {
316 match state {
317 SelectionState::None => SemanticStyle {
318 foreground: self.text,
319 background: Some(self.surface),
320 emphasized: false,
321 },
322 SelectionState::Selected => SemanticStyle {
323 foreground: self.accent,
324 background: Some(self.surface_selected),
325 emphasized: false,
326 },
327 SelectionState::Focused => SemanticStyle {
328 foreground: self.accent_foreground,
329 background: Some(self.accent),
330 emphasized: true,
331 },
332 SelectionState::Disabled => SemanticStyle {
333 foreground: self.text_muted,
334 background: Some(self.surface),
335 emphasized: false,
336 },
337 }
338 }
339
340 #[must_use]
342 pub const fn notice_style(self, tone: NoticeTone) -> SemanticStyle {
343 let foreground = match tone {
344 NoticeTone::Info => self.text_muted,
345 NoticeTone::Positive => self.positive,
346 NoticeTone::Warning => self.accent,
347 NoticeTone::Error => self.destructive,
348 };
349 SemanticStyle {
350 foreground,
351 background: None,
352 emphasized: matches!(tone, NoticeTone::Warning | NoticeTone::Error),
353 }
354 }
355}
356
357impl From<&DiffPalette> for UiPalette {
358 fn from(palette: &DiffPalette) -> Self {
359 let mut scrim = palette.background;
360 scrim.a = SCRIM_ALPHA;
361 Self {
362 canvas: palette.background,
363 surface: palette.background,
364 surface_hover: palette.selection,
365 surface_selected: palette.selection,
366 text: palette.foreground,
367 text_muted: palette.muted,
368 border: palette.border,
369 accent: palette.accent,
370 accent_foreground: palette.background,
371 positive: palette.addition,
372 destructive: palette.deletion,
373 scrim,
374 }
375 }
376}
377
378impl Default for DiffPalette {
379 fn default() -> Self {
380 let background = Rgba::new(21, 29, 31, 255);
381 let addition = Rgba::new(179, 215, 98, 255);
382 let deletion = Rgba::new(223, 120, 122, 255);
383 Self {
384 background,
385 foreground: Rgba::default(),
386 gutter: Rgba::new(80, 96, 91, 255),
387 addition,
388 deletion,
389 addition_background: diff_background(addition, background),
390 deletion_background: diff_background(deletion, background),
391 selection: Rgba::new(143, 188, 176, 45),
392 accent: Rgba::new(143, 188, 176, 255),
393 muted: Rgba::new(125, 143, 136, 255),
394 border: Rgba::new(57, 73, 73, 255),
395 }
396 }
397}
398
399#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
401pub enum ThemeId {
402 #[default]
403 Sage,
404 Ayu,
405 Builtin(String),
406 Custom(String),
407}
408
409impl fmt::Display for ThemeId {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 Self::Sage => f.write_str("sage"),
413 Self::Ayu => f.write_str("ayu-dark"),
414 Self::Builtin(name) | Self::Custom(name) => f.write_str(name),
415 }
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct ThemeDescriptor {
422 pub id: String,
423 pub name: String,
424 pub is_dark: bool,
425 pub source_url: Option<String>,
426}
427
428#[derive(Debug, Serialize, Deserialize)]
429#[serde(deny_unknown_fields)]
430struct ThemeDocument {
431 version: u32,
432 palette: DiffPalette,
433 syntax: BTreeMap<String, SyntaxStyle>,
434}
435
436#[derive(Debug, Clone)]
438pub struct DiffTheme {
439 id: ThemeId,
440 palette: DiffPalette,
441 syntax: BTreeMap<String, SyntaxStyle>,
442 revision: Fingerprint,
443}
444
445impl DiffTheme {
446 pub fn builder(id: impl Into<String>) -> SyntaxThemeBuilder {
448 SyntaxThemeBuilder {
449 id: ThemeId::Custom(id.into()),
450 palette: DiffPalette::default(),
451 syntax: BTreeMap::new(),
452 }
453 }
454
455 pub fn from_bytes(id: ThemeId, bytes: &[u8]) -> Result<Self, ThemeError> {
463 let document: ThemeDocument =
464 serde_json::from_slice(bytes).map_err(|source| ThemeError::Parse {
465 message: source.to_string(),
466 })?;
467 if document.version != 1 {
468 return Err(ThemeError::UnsupportedVersion {
469 version: document.version,
470 });
471 }
472 let canonical = serde_json::to_vec(&document).map_err(|source| ThemeError::Parse {
473 message: source.to_string(),
474 })?;
475 let revision = Fingerprint::of([id.to_string().as_bytes(), canonical.as_slice()]);
476 Ok(Self {
477 id,
478 palette: document.palette,
479 syntax: document.syntax,
480 revision,
481 })
482 }
483
484 #[must_use]
485 pub const fn id(&self) -> &ThemeId {
486 &self.id
487 }
488
489 #[must_use]
490 pub const fn palette(&self) -> &DiffPalette {
491 &self.palette
492 }
493
494 #[must_use]
495 pub const fn revision(&self) -> Fingerprint {
496 self.revision
497 }
498
499 #[must_use]
501 pub fn style(&self, capture: &str) -> Option<SyntaxStyle> {
502 let mut candidate = capture;
503 loop {
504 if let Some(style) = self.syntax.get(candidate) {
505 return Some(*style);
506 }
507 let (parent, _) = candidate.rsplit_once('.')?;
508 candidate = parent;
509 }
510 }
511
512 #[must_use]
514 pub fn catalog() -> Vec<ThemeDescriptor> {
515 let mut themes = vec![ThemeDescriptor {
516 id: "sage".to_owned(),
517 name: "Sage".to_owned(),
518 is_dark: true,
519 source_url: None,
520 }];
521 themes.extend(builtin::all().into_iter().map(|theme| ThemeDescriptor {
522 id: theme_slug(&theme.name),
523 name: theme.name,
524 is_dark: theme.is_dark,
525 source_url: theme.source_url,
526 }));
527 themes.sort_by(|left, right| {
528 left.is_dark
529 .cmp(&right.is_dark)
530 .reverse()
531 .then_with(|| left.name.cmp(&right.name))
532 });
533 themes
534 }
535
536 pub fn builtin(name: &str) -> Result<Self, ThemeError> {
544 let id = theme_slug(name);
545 if id == "sage" {
546 return Self::sage();
547 }
548 if id == "ayu" || id == "ayu-dark" {
549 return Self::ayu();
550 }
551 let source = builtin::all()
552 .into_iter()
553 .find(|theme| theme_slug(&theme.name) == id)
554 .ok_or_else(|| ThemeError::UnknownTheme {
555 name: name.to_owned(),
556 })?;
557 Ok(Self::from_arborium(ThemeId::Builtin(id), &source))
558 }
559
560 fn from_arborium(id: ThemeId, source: &arborium_theme::Theme) -> Self {
561 let palette = palette_from_arborium(source);
562 let syntax = HIGHLIGHTS
563 .iter()
564 .zip(source.styles.iter())
565 .filter_map(|(highlight, style)| {
566 let foreground = style.fg?;
567 Some((
568 highlight.name.to_owned(),
569 SyntaxStyle {
570 foreground: rgba(foreground),
571 font_style: FontStyle {
572 bold: style.modifiers.bold,
573 italic: style.modifiers.italic,
574 underline: style.modifiers.underline,
575 },
576 },
577 ))
578 })
579 .collect::<BTreeMap<_, _>>();
580 let mut revision_parts = vec![id.to_string().into_bytes()];
581 revision_parts.extend(palette.colors().map(|color| color.to_bytes().to_vec()));
582 for (capture, style) in &syntax {
583 revision_parts.push(capture.as_bytes().to_vec());
584 revision_parts.push(style.foreground.to_bytes().to_vec());
585 revision_parts.push(vec![
586 u8::from(style.font_style.bold),
587 u8::from(style.font_style.italic),
588 u8::from(style.font_style.underline),
589 ]);
590 }
591 let revision = Fingerprint::of(revision_parts.iter().map(Vec::as_slice));
592 Self {
593 id,
594 palette,
595 syntax,
596 revision,
597 }
598 }
599
600 pub fn sage() -> Result<Self, ThemeError> {
605 Self::from_bytes(ThemeId::Sage, include_bytes!("../assets/themes/sage.json"))
606 }
607 pub fn ayu() -> Result<Self, ThemeError> {
612 Self::from_bytes(
613 ThemeId::Ayu,
614 include_bytes!("../assets/themes/ayu-dark.json"),
615 )
616 }
617}
618
619fn theme_slug(name: &str) -> String {
620 name.trim()
621 .chars()
622 .map(|character| match character {
623 ' ' | '_' => '-',
624 character => character.to_ascii_lowercase(),
625 })
626 .collect()
627}
628
629const fn rgba(color: arborium_theme::Color) -> Rgba {
630 Rgba::new(color.r, color.g, color.b, u8::MAX)
631}
632
633fn palette_from_arborium(theme: &arborium_theme::Theme) -> DiffPalette {
634 let background = theme.background.map_or_else(
635 || {
636 if theme.is_dark {
637 Rgba::new(21, 29, 31, 255)
638 } else {
639 Rgba::new(250, 250, 250, 255)
640 }
641 },
642 rgba,
643 );
644 let foreground = theme.foreground.map_or_else(
645 || {
646 if theme.is_dark {
647 Rgba::new(220, 220, 220, 255)
648 } else {
649 Rgba::new(35, 35, 35, 255)
650 }
651 },
652 rgba,
653 );
654 let style_color = |slot| {
655 slot_to_highlight_index(slot)
656 .and_then(|index| theme.style(index))
657 .and_then(|style| style.fg)
658 .map(rgba)
659 };
660 let addition = style_color(ThemeSlot::DiffAdd).unwrap_or_else(|| {
661 if theme.is_dark {
662 Rgba::new(128, 190, 120, 255)
663 } else {
664 Rgba::new(40, 125, 55, 255)
665 }
666 });
667 let deletion = style_color(ThemeSlot::DiffDelete).unwrap_or_else(|| {
668 if theme.is_dark {
669 Rgba::new(225, 115, 115, 255)
670 } else {
671 Rgba::new(185, 45, 45, 255)
672 }
673 });
674 let accent = style_color(ThemeSlot::Link)
675 .or_else(|| style_color(ThemeSlot::Function))
676 .or_else(|| style_color(ThemeSlot::Keyword))
677 .unwrap_or_else(|| mix(foreground, background, 72));
678 DiffPalette {
679 background,
680 foreground,
681 gutter: mix(foreground, background, 45),
682 addition,
683 deletion,
684 addition_background: diff_background(addition, background),
685 deletion_background: diff_background(deletion, background),
686 selection: mix(accent, background, 28),
687 accent,
688 muted: mix(foreground, background, 62),
689 border: mix(foreground, background, 25),
690 }
691}
692
693const fn mix(foreground: Rgba, background: Rgba, amount: u8) -> Rgba {
694 Rgba::new(foreground.r, foreground.g, foreground.b, amount).over(background)
695}
696
697const DIFF_BACKGROUND_ALPHA: u8 = 31;
698const fn diff_background(foreground: Rgba, background: Rgba) -> Rgba {
699 Rgba::new(
700 foreground.r,
701 foreground.g,
702 foreground.b,
703 DIFF_BACKGROUND_ALPHA,
704 )
705 .over(background)
706}
707
708impl Default for DiffTheme {
709 fn default() -> Self {
710 Self::sage().expect("bundled Sage theme JSON must parse")
711 }
712}
713
714pub struct SyntaxThemeBuilder {
716 id: ThemeId,
717 palette: DiffPalette,
718 syntax: BTreeMap<String, SyntaxStyle>,
719}
720impl SyntaxThemeBuilder {
721 #[must_use]
723 pub fn capture(mut self, name: impl Into<String>, style: SyntaxStyle) -> Self {
724 self.syntax.insert(name.into(), style);
725 self
726 }
727 #[must_use]
729 pub fn palette(mut self, palette: DiffPalette) -> Self {
730 self.palette = palette;
731 self
732 }
733 pub fn build(self) -> Result<SyntaxTheme, ThemeError> {
738 let document = ThemeDocument {
739 version: 1,
740 palette: self.palette,
741 syntax: self.syntax,
742 };
743 let bytes = serde_json::to_vec(&document).map_err(|error| ThemeError::Parse {
744 message: error.to_string(),
745 })?;
746 DiffTheme::from_bytes(self.id, &bytes)
747 }
748}
749
750pub type SyntaxTheme = DiffTheme;
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct MarkdownPalette {
757 pub heading: Rgba,
758 pub link: Rgba,
759 pub quote: Rgba,
760 pub code: Rgba,
761}
762
763impl Default for MarkdownPalette {
764 fn default() -> Self {
765 let palette = DiffPalette::default();
766 Self {
767 heading: palette.accent,
768 link: palette.accent,
769 quote: palette.muted,
770 code: palette.foreground,
771 }
772 }
773}
774
775#[derive(Debug, Clone)]
777pub struct ReviewTheme {
778 pub syntax: SyntaxTheme,
779 pub markdown: MarkdownPalette,
780 pub diff: DiffPalette,
781}
782
783impl ReviewTheme {
784 #[must_use]
789 pub fn revision(&self) -> Fingerprint {
790 let mut fields = Vec::with_capacity(1 + self.diff.colors().len() + 4);
791 fields.push(self.syntax.revision().as_bytes().to_vec());
792 fields.extend(self.diff.colors().map(|color| color.to_bytes().to_vec()));
793 fields.extend(
794 [
795 self.markdown.heading,
796 self.markdown.link,
797 self.markdown.quote,
798 self.markdown.code,
799 ]
800 .map(|color| color.to_bytes().to_vec()),
801 );
802 Fingerprint::of(fields)
803 }
804}
805
806impl Default for ReviewTheme {
807 fn default() -> Self {
808 let syntax = SyntaxTheme::default();
809 Self {
810 markdown: MarkdownPalette::default(),
811 diff: syntax.palette().clone(),
812 syntax,
813 }
814 }
815}
816
817impl From<DiffTheme> for ReviewTheme {
818 fn from(syntax: DiffTheme) -> Self {
819 Self {
820 markdown: MarkdownPalette::default(),
821 diff: syntax.palette().clone(),
822 syntax,
823 }
824 }
825}
826
827#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
829pub enum ThemeError {
830 #[error("unknown built-in theme `{name}`")]
831 UnknownTheme { name: String },
832 #[error("failed to parse theme JSON: {message}")]
833 Parse { message: String },
834 #[error("unsupported theme JSON version {version}")]
835 UnsupportedVersion { version: u32 },
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
843 fn bundled_themes_parse_with_distinct_revisions() {
844 let sage = DiffTheme::default();
845 let ayu = DiffTheme::ayu().unwrap();
846 assert_eq!(sage.id(), &ThemeId::Sage);
847 assert_eq!(ayu.id(), &ThemeId::Ayu);
848 assert_ne!(sage.revision(), ayu.revision());
849 }
850
851 #[test]
852 fn arborium_catalog_loads_every_theme() {
853 let catalog = DiffTheme::catalog();
854 assert!(catalog.len() > 30);
855 let mut ids = catalog
856 .iter()
857 .map(|theme| theme.id.as_str())
858 .collect::<Vec<_>>();
859 ids.sort_unstable();
860 ids.dedup();
861 assert_eq!(ids.len(), catalog.len());
862 for descriptor in catalog {
863 let theme = DiffTheme::builtin(&descriptor.id).unwrap();
864 assert_eq!(theme.id().to_string(), descriptor.id);
865 assert_ne!(theme.palette().foreground, theme.palette().background);
866 }
867 }
868
869 #[test]
870 fn capture_style_uses_parent_fallback() {
871 let theme = DiffTheme::default();
872 assert_eq!(
873 theme.style("function.method.builtin"),
874 theme.style("function.method")
875 );
876 assert!(theme.style("not-a-capture").is_none());
877 }
878
879 #[test]
880 fn invalid_json_and_version_are_rejected() {
881 assert!(matches!(
882 DiffTheme::from_bytes(ThemeId::Custom("x".into()), b"{"),
883 Err(ThemeError::Parse { .. })
884 ));
885 let bytes = include_bytes!("../assets/themes/sage.json");
886 let changed =
887 String::from_utf8_lossy(bytes).replacen("\"version\": 1", "\"version\": 2", 1);
888 assert_eq!(
889 DiffTheme::from_bytes(ThemeId::Sage, changed.as_bytes()).unwrap_err(),
890 ThemeError::UnsupportedVersion { version: 2 }
891 );
892 }
893
894 #[test]
895 fn semantic_palette_values_remain_stable() {
896 let sage = DiffTheme::default();
897 let ayu = DiffTheme::ayu().unwrap();
898 assert_eq!(sage.palette().addition, Rgba::new(167, 192, 128, 255));
899 assert_eq!(sage.palette().deletion, Rgba::new(230, 126, 128, 255));
900 assert_eq!(ayu.palette().addition, Rgba::new(194, 217, 76, 255));
901 assert_eq!(ayu.palette().deletion, Rgba::new(255, 51, 51, 255));
902 }
903
904 #[test]
905 fn font_style_serializes_as_named_flags() {
906 let style = FontStyle {
907 bold: true,
908 italic: true,
909 underline: false,
910 };
911 assert_eq!(
912 serde_json::to_string(&style).unwrap(),
913 r#"{"bold":true,"italic":true,"underline":false}"#
914 );
915 }
916}