Skip to main content

clankerdiff_theme/
lib.rs

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