Skip to main content

mdsee_render/
theme.rs

1//! Theme(design.md §61, §62)。
2//!
3//! `SemanticStyle`(§19)を実際の表示属性へ変換する。
4//! Sprint 2では組込みの dark / light のみ。
5
6use crate::style::{Rgb, StyleSpec};
7
8/// テキストの表示属性。§61の `TextStyle`。
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct TextStyle {
11    pub fg: Option<Rgb>,
12    pub bold: bool,
13    pub italic: bool,
14    pub strike: bool,
15    pub underline: bool,
16}
17
18impl TextStyle {
19    pub const DEFAULT: Self = Self {
20        fg: None,
21        bold: false,
22        italic: false,
23        strike: false,
24        underline: false,
25    };
26
27    pub const fn fg(fg: Rgb) -> Self {
28        Self {
29            fg: Some(fg),
30            ..Self::DEFAULT
31        }
32    }
33
34    pub const fn bold(mut self) -> Self {
35        self.bold = true;
36        self
37    }
38
39    pub const fn italic(mut self) -> Self {
40        self.italic = true;
41        self
42    }
43
44    pub const fn underline(mut self) -> Self {
45        self.underline = true;
46        self
47    }
48
49    fn to_spec(self) -> StyleSpec {
50        StyleSpec {
51            fg: self.fg,
52            bold: self.bold,
53            italic: self.italic,
54            strike: self.strike,
55            underline: self.underline,
56        }
57    }
58}
59
60/// Alert種別ごとのstyle(§61)。
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct AlertTheme {
63    pub note: TextStyle,
64    pub tip: TextStyle,
65    pub important: TextStyle,
66    pub warning: TextStyle,
67    pub caution: TextStyle,
68}
69
70/// Theme(§61)。
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Theme {
73    pub body: TextStyle,
74    pub muted: TextStyle,
75
76    pub h1: TextStyle,
77    pub h2: TextStyle,
78    pub h3: TextStyle,
79
80    pub link: TextStyle,
81
82    pub inline_code: TextStyle,
83
84    pub quote: TextStyle,
85
86    pub border: TextStyle,
87
88    pub alerts: AlertTheme,
89
90    pub syntax_theme: String,
91}
92
93impl Theme {
94    /// 組込みdark theme(§62)。
95    pub fn dark() -> Self {
96        Self {
97            body: TextStyle::DEFAULT,
98            muted: TextStyle::fg(Rgb(139, 148, 158)),
99            h1: TextStyle::fg(Rgb(63, 185, 80)).bold(),
100            h2: TextStyle::fg(Rgb(88, 166, 255)).bold(),
101            h3: TextStyle::fg(Rgb(233, 208, 115)).bold(),
102            link: TextStyle::fg(Rgb(88, 166, 255)).underline(),
103            inline_code: TextStyle::fg(Rgb(79, 193, 233)),
104            quote: TextStyle::fg(Rgb(139, 148, 158)),
105            border: TextStyle::fg(Rgb(48, 54, 61)),
106            alerts: AlertTheme {
107                note: TextStyle::fg(Rgb(88, 166, 255)),
108                tip: TextStyle::fg(Rgb(63, 185, 80)),
109                important: TextStyle::fg(Rgb(163, 113, 247)),
110                warning: TextStyle::fg(Rgb(219, 171, 121)),
111                caution: TextStyle::fg(Rgb(248, 81, 73)),
112            },
113            syntax_theme: "base16-ocean.dark".to_string(),
114        }
115    }
116
117    /// 組込みlight theme(§62)。
118    pub fn light() -> Self {
119        Self {
120            body: TextStyle::DEFAULT,
121            muted: TextStyle::fg(Rgb(101, 109, 118)),
122            h1: TextStyle::fg(Rgb(31, 122, 46)).bold(),
123            h2: TextStyle::fg(Rgb(9, 76, 178)).bold(),
124            h3: TextStyle::fg(Rgb(148, 108, 8)).bold(),
125            link: TextStyle::fg(Rgb(9, 76, 178)).underline(),
126            inline_code: TextStyle::fg(Rgb(6, 122, 174)),
127            quote: TextStyle::fg(Rgb(101, 109, 118)),
128            border: TextStyle::fg(Rgb(208, 211, 215)),
129            alerts: AlertTheme {
130                note: TextStyle::fg(Rgb(9, 76, 178)),
131                tip: TextStyle::fg(Rgb(31, 122, 46)),
132                important: TextStyle::fg(Rgb(111, 66, 193)),
133                warning: TextStyle::fg(Rgb(154, 103, 29)),
134                caution: TextStyle::fg(Rgb(191, 38, 34)),
135            },
136            syntax_theme: "InspiredGitHub".to_string(),
137        }
138    }
139
140    /// 組込みtheme名(§62)。auto は `select_auto` で解決する。
141    pub fn builtin(name: &str) -> Option<Self> {
142        match name {
143            "dark" => Some(Self::dark()),
144            "light" => Some(Self::light()),
145            _ => None,
146        }
147    }
148
149    /// §19のSemanticStyleを表示属性へ変換する。
150    pub(crate) fn spec(&self, style: mdsee_layout::SemanticStyle) -> StyleSpec {
151        use mdsee_layout::SemanticStyle;
152        let text_style = match style {
153            SemanticStyle::Body => self.body,
154            SemanticStyle::Muted => self.muted,
155            SemanticStyle::Heading1 => self.h1,
156            SemanticStyle::Heading2 => self.h2,
157            SemanticStyle::Heading3 => self.h3,
158            SemanticStyle::Heading4 => self.h1,
159            SemanticStyle::Heading5 => self.h2,
160            SemanticStyle::Heading6 => self.muted,
161            SemanticStyle::Strong => TextStyle::DEFAULT.bold(),
162            SemanticStyle::Emphasis => TextStyle::DEFAULT.italic(),
163            SemanticStyle::Strike => TextStyle::DEFAULT,
164            SemanticStyle::InlineCode => self.inline_code,
165            SemanticStyle::Link => self.link,
166            SemanticStyle::Quote => self.quote,
167            SemanticStyle::Code => self.quote,
168            SemanticStyle::Border => self.border,
169            SemanticStyle::AlertNote => self.alerts.note,
170            SemanticStyle::AlertTip => self.alerts.tip,
171            SemanticStyle::AlertImportant => self.alerts.important,
172            SemanticStyle::AlertWarning => self.alerts.warning,
173            SemanticStyle::AlertCaution => self.alerts.caution,
174        };
175        text_style.to_spec()
176    }
177}
178
179impl Default for Theme {
180    fn default() -> Self {
181        Self::dark()
182    }
183}
184
185/// `auto` themeの解決(§62)。
186///
187/// 環境変数 `COLORFGBG`(`15;0` 形式)の背景色が暗い番号(0〜6, 8)なら
188/// dark theme、明るい番号(7, 9〜15)なら light theme。
189/// 判定できない場合はdark。
190pub fn select_auto_theme(colorfgbg: Option<&str>) -> Theme {
191    let background_is_light = colorfgbg.and_then(|value| {
192        value
193            .rsplit(';')
194            .next()
195            .and_then(|last| last.parse::<u8>().ok())
196            .map(|bg| bg == 7 || (9..=15).contains(&bg))
197    });
198    match background_is_light {
199        Some(true) => Theme::light(),
200        _ => Theme::dark(),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn builtin_themes_are_resolvable() {
210        assert!(Theme::builtin("dark").is_some());
211        assert!(Theme::builtin("light").is_some());
212        assert!(Theme::builtin("solarized").is_none());
213    }
214
215    #[test]
216    fn dark_and_light_differ() {
217        assert_ne!(Theme::dark(), Theme::light());
218    }
219
220    #[test]
221    fn auto_theme_uses_colorfgbg_background() {
222        assert_eq!(select_auto_theme(Some("15;0")), Theme::dark());
223        assert_eq!(select_auto_theme(Some("0;15")), Theme::light());
224        assert_eq!(select_auto_theme(None), Theme::dark());
225        assert_eq!(select_auto_theme(Some("garbage")), Theme::dark());
226    }
227
228    #[test]
229    fn spec_maps_heading_and_link() {
230        let theme = Theme::dark();
231        let h1 = theme.spec(mdsee_layout::SemanticStyle::Heading1);
232        assert!(h1.bold);
233        assert_eq!(h1.fg, Some(Rgb(63, 185, 80)));
234
235        let link = theme.spec(mdsee_layout::SemanticStyle::Link);
236        assert!(link.underline);
237    }
238}