Skip to main content

clankerdiff_theme/
lib.rs

1//! Renderer-neutral colors, semantic diff palettes, and syntax themes.
2
3use 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/// An sRGB color with an explicit alpha channel.
9#[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    /// Composites this color over an opaque background.
24    #[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/// Text modifiers returned by the syntax engine.
56#[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/// A highlighted, UTF-8-safe byte range into the supplied source.
83#[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/// Style assigned to a Tree-sitter capture name.
91#[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/// Semantic tint for a diff cell.
99#[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/// Foreground and background colors resolved for one [`DiffTone`].
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ToneColors {
121    pub foreground: Rgba,
122    pub background: Rgba,
123}
124
125/// Colors used by diff renderers.
126#[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
178/// Alpha applied to the canvas color when it backs a modal scrim.
179pub const SCRIM_ALPHA: u8 = 184;
180
181/// Renderer-neutral application colors derived from a diff palette.
182#[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/// Semantic intent shared by renderer-native action controls.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200pub enum ButtonVariant {
201    Primary,
202    Secondary,
203    Destructive,
204    #[default]
205    Ghost,
206}
207
208/// Interaction state shared by renderer-native controls.
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
210pub enum InteractionState {
211    #[default]
212    Rest,
213    Hovered,
214    Disabled,
215}
216
217/// Complete semantic state for an action control.
218#[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/// Semantic size shared by renderer-native controls.
241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
242pub enum ControlSize {
243    Small,
244    #[default]
245    Medium,
246}
247
248/// Semantic state for selectable content.
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
250pub enum SelectionState {
251    #[default]
252    None,
253    Selected,
254    Focused,
255    Disabled,
256}
257
258/// Semantic tone for notices and status messages.
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260pub enum NoticeTone {
261    #[default]
262    Info,
263    Positive,
264    Warning,
265    Error,
266}
267
268/// Cross-renderer modal size category.
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
270pub enum ModalSize {
271    Compact,
272    #[default]
273    Medium,
274    Wide,
275}
276
277/// Renderer-neutral visual result for one semantic component state.
278#[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    /// Resolves an action control without renderer-specific state ordering.
287    #[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    /// Resolves selectable content consistently across renderers.
314    #[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    /// Resolves the foreground used to communicate a notice tone.
341    #[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/// Stable identifiers for embedded and host-provided themes.
400#[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/// Summary metadata for a theme offered by the built-in catalog.
420#[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/// A capture-based syntax theme plus semantic colors for the diff UI.
437#[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    /// Starts a builder for a host-provided syntax theme.
447    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    /// Parses a versioned Diff theme JSON document.
456    ///
457    /// Capture lookup first tries an exact name and then progressively removes
458    /// dot-separated suffixes. The full parsed palette and map affect revision.
459    ///
460    /// # Errors
461    /// Returns an error for malformed JSON or an unsupported schema version.
462    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    /// Resolves an exact capture or its nearest dot-separated parent.
500    #[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    /// Returns all selectable built-in themes, with stable kebab-case identifiers.
513    #[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    /// Loads a selectable built-in theme by its stable identifier.
537    ///
538    /// Aliases are matched case-insensitively and with spaces or underscores
539    /// normalized to dashes.
540    ///
541    /// # Errors
542    /// Returns an error when no built-in theme has the supplied identifier.
543    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    /// Loads the bundled Sage theme.
601    ///
602    /// # Errors
603    /// Returns an error if the checked-in theme JSON is invalid.
604    pub fn sage() -> Result<Self, ThemeError> {
605        Self::from_bytes(ThemeId::Sage, include_bytes!("../assets/themes/sage.json"))
606    }
607    /// Loads the bundled Ayu Dark theme.
608    ///
609    /// # Errors
610    /// Returns an error if the checked-in theme JSON is invalid.
611    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
714/// Builder for syntax themes without exposing the capture map representation.
715pub struct SyntaxThemeBuilder {
716    id: ThemeId,
717    palette: DiffPalette,
718    syntax: BTreeMap<String, SyntaxStyle>,
719}
720impl SyntaxThemeBuilder {
721    /// Assigns a style to an exact capture or parent capture prefix.
722    #[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    /// Sets the semantic diff palette retained by compatibility aggregate themes.
728    #[must_use]
729    pub fn palette(mut self, palette: DiffPalette) -> Self {
730        self.palette = palette;
731        self
732    }
733    /// Builds the immutable theme and derives its stable revision.
734    ///
735    /// # Errors
736    /// Returns an error if the internal versioned representation cannot be serialized.
737    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
750/// A syntax-only theme view. This alias keeps the capture API compact while
751/// allowing callers to depend on the syntax boundary rather than the diff UI.
752pub type SyntaxTheme = DiffTheme;
753
754/// Semantic Markdown roles used by renderer adapters.
755#[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/// Aggregate theme shared by terminal and desktop adapters.
776#[derive(Debug, Clone)]
777pub struct ReviewTheme {
778    pub syntax: SyntaxTheme,
779    pub markdown: MarkdownPalette,
780    pub diff: DiffPalette,
781}
782
783impl ReviewTheme {
784    /// Returns a stable revision derived from every renderer-visible theme value.
785    ///
786    /// Renderer caches should use this aggregate revision rather than the syntax
787    /// revision alone so Markdown and diff palette changes also invalidate rows.
788    #[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/// Errors produced while loading theme JSON.
828#[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}