Skip to main content

clankerdiff_theme/
lib.rs

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