1mod 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#[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 #[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct ToneColors {
126 pub foreground: Rgba,
127 pub background: Rgba,
128}
129
130#[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
183pub const SCRIM_ALPHA: u8 = 184;
185
186#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub enum ButtonVariant {
206 Primary,
207 Secondary,
208 Destructive,
209 #[default]
210 Ghost,
211}
212
213#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
215pub enum InteractionState {
216 #[default]
217 Rest,
218 Hovered,
219 Disabled,
220}
221
222#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
247pub enum ControlSize {
248 Small,
249 #[default]
250 Medium,
251}
252
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
255pub enum SelectionState {
256 #[default]
257 None,
258 Selected,
259 Focused,
260 Disabled,
261}
262
263#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub enum NoticeTone {
266 #[default]
267 Info,
268 Positive,
269 Warning,
270 Error,
271}
272
273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
275pub enum ModalSize {
276 Compact,
277 #[default]
278 Medium,
279 Wide,
280}
281
282#[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 #[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 #[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 #[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#[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#[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 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 #[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 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 pub fn sage() -> Result<Self, ThemeError> {
596 Self::from_bytes(ThemeId::Sage, include_bytes!("../assets/themes/sage.json"))
597 }
598 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#[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}