Skip to main content

ctl_core/
theme.rs

1//! The styles a [`Document`](crate::Document) is drawn in.
2//!
3//! A document says what each span means through its [`Role`]. The theme says
4//! what that meaning looks like. The renderer applies the theme when a
5//! document becomes widgets, so the layout crates never see a role. The writer
6//! decides afterwards whether to write the styles at all.
7
8use ctl_text::Style;
9use ctl_text::anstyle::{AnsiColor, Color};
10
11use crate::document::{NoticeLevel, Role};
12
13/// A bold span in one of the 16 named colours.
14const fn bold(color: AnsiColor) -> Style {
15    Style::new().bold().fg_color(Some(Color::Ansi(color)))
16}
17
18/// Maps each [`Role`], and the label of each [`NoticeLevel`], to an
19/// [`anstyle::Style`](ctl_text::anstyle::Style).
20///
21/// [`Theme::default`] is ctl-core's palette. It uses only the 16 named
22/// colours and bold, so it looks the same at every colour depth:
23///
24/// | Role | Style |
25/// |:--|:--|
26/// | [`Role::Plain`] | none |
27/// | [`Role::Heading`] | bold cyan |
28/// | [`Role::Success`] | bold green |
29/// | [`Role::Warning`] | bold yellow |
30/// | [`Role::Error`] | bold red |
31/// | [`Role::Value`] | bold yellow |
32/// | [`Role::Muted`] | bright black |
33/// | [`Role::Token`] | bold green |
34/// | [`Role::Id`] | bold, in the terminal's own foreground |
35///
36/// A notice label takes the style of the role of the same name until
37/// [`Theme::notice_style`] replaces it.
38///
39/// Equality compares the choices a theme holds, including which notice labels
40/// were given their own style. It does not compare the output they draw. A
41/// label set to its role's style draws the same as an unset one, yet the two
42/// themes differ.
43///
44/// A theme replaces the default through [`RenderOptions::theme`],
45/// [`View::theme`], and [`App::theme`]. It only says how a role looks:
46/// whether colour is written is the colour mode's decision, so
47/// [`ColorMode::Never`] writes none whatever the theme holds.
48///
49/// ```
50/// use ctl_core::Theme;
51/// use ctl_core::document::Role;
52/// use ctl_core::text::anstyle::{AnsiColor, Style};
53///
54/// let magenta = Style::new().fg_color(Some(AnsiColor::Magenta.into()));
55/// let theme = Theme::default().style(Role::Id, magenta);
56/// assert_eq!(theme.role_style(Role::Id), magenta);
57/// assert_eq!(
58///     theme.role_style(Role::Heading),
59///     Theme::default().role_style(Role::Heading)
60/// );
61/// ```
62///
63/// [`RenderOptions::theme`]: crate::RenderOptions::theme
64/// [`ColorMode::Never`]: crate::ColorMode::Never
65#[cfg_attr(
66    feature = "view",
67    doc = "",
68    doc = "[`View::theme`]: crate::View::theme"
69)]
70#[cfg_attr(
71    not(feature = "view"),
72    doc = "",
73    doc = "[`View::theme`]: crate#cargo-features"
74)]
75#[cfg_attr(feature = "app", doc = "", doc = "[`App::theme`]: crate::App::theme")]
76#[cfg_attr(
77    not(feature = "app"),
78    doc = "",
79    doc = "[`App::theme`]: crate#cargo-features"
80)]
81#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
82pub struct Theme {
83    plain: Style,
84    heading: Style,
85    success: Style,
86    warning: Style,
87    error: Style,
88    value: Style,
89    muted: Style,
90    token: Style,
91    id: Style,
92    /// A notice label's own style, or `None` to take its role's.
93    success_notice: Option<Style>,
94    warning_notice: Option<Style>,
95    error_notice: Option<Style>,
96}
97
98impl Theme {
99    /// ctl-core's palette, the table in the [type docs](Self).
100    ///
101    /// The same as [`Theme::default`], usable in a `const`.
102    #[must_use]
103    pub const fn new() -> Self {
104        Self {
105            plain: Style::new(),
106            heading: bold(AnsiColor::Cyan),
107            success: bold(AnsiColor::Green),
108            warning: bold(AnsiColor::Yellow),
109            error: bold(AnsiColor::Red),
110            value: bold(AnsiColor::Yellow),
111            muted: Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlack))),
112            token: bold(AnsiColor::Green),
113            id: Style::new().bold(),
114            success_notice: None,
115            warning_notice: None,
116            error_notice: None,
117        }
118    }
119
120    /// The style `role` is drawn in.
121    #[must_use]
122    pub const fn role_style(self, role: Role) -> Style {
123        match role {
124            Role::Plain => self.plain,
125            Role::Heading => self.heading,
126            Role::Success => self.success,
127            Role::Warning => self.warning,
128            Role::Error => self.error,
129            Role::Value => self.value,
130            Role::Muted => self.muted,
131            Role::Token => self.token,
132            Role::Id => self.id,
133        }
134    }
135
136    /// This theme with `role` drawn in `style`.
137    #[must_use]
138    pub const fn style(mut self, role: Role, style: Style) -> Self {
139        match role {
140            Role::Plain => self.plain = style,
141            Role::Heading => self.heading = style,
142            Role::Success => self.success = style,
143            Role::Warning => self.warning = style,
144            Role::Error => self.error = style,
145            Role::Value => self.value = style,
146            Role::Muted => self.muted = style,
147            Role::Token => self.token = style,
148            Role::Id => self.id = style,
149        }
150        self
151    }
152
153    /// The style the label of a `level` notice (`success`, `warning`, or
154    /// `error`) is drawn in: the style [`Theme::notice_style`] set, or
155    /// else the style of the role of the same name.
156    #[must_use]
157    pub const fn label_style(self, level: NoticeLevel) -> Style {
158        let (own, role) = match level {
159            NoticeLevel::Success => (self.success_notice, self.success),
160            NoticeLevel::Warning => (self.warning_notice, self.warning),
161            NoticeLevel::Error => (self.error_notice, self.error),
162        };
163        match own {
164            Some(style) => style,
165            None => role,
166        }
167    }
168
169    /// This theme with the label of a `level` notice drawn in `style`.
170    #[must_use]
171    pub const fn notice_style(mut self, level: NoticeLevel, style: Style) -> Self {
172        match level {
173            NoticeLevel::Success => self.success_notice = Some(style),
174            NoticeLevel::Warning => self.warning_notice = Some(style),
175            NoticeLevel::Error => self.error_notice = Some(style),
176        }
177        self
178    }
179}
180
181impl Default for Theme {
182    /// ctl-core's palette; see [`Theme::new`].
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use ctl_text::Style;
191    use ctl_text::anstyle::{AnsiColor, Color, Effects};
192
193    use super::Theme;
194    use crate::document::{NoticeLevel, Role};
195
196    #[test]
197    fn the_default_palette_is_bold_named_colours() {
198        let theme = Theme::default();
199        let cyan = Style::new()
200            .bold()
201            .fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
202        assert_eq!(theme.role_style(Role::Heading), cyan);
203        assert_eq!(theme.role_style(Role::Plain), Style::new());
204        assert_eq!(theme.role_style(Role::Id), Style::new().bold());
205        assert_eq!(
206            theme.label_style(NoticeLevel::Error),
207            theme.role_style(Role::Error)
208        );
209    }
210
211    #[test]
212    fn replacing_one_role_keeps_the_others() {
213        let underline = Style::new().effects(Effects::UNDERLINE);
214        let theme = Theme::new().style(Role::Muted, underline);
215        assert_eq!(theme.role_style(Role::Muted), underline);
216        assert_eq!(
217            theme.role_style(Role::Value),
218            Theme::new().role_style(Role::Value)
219        );
220        assert_eq!(
221            theme.label_style(NoticeLevel::Warning),
222            Theme::new().label_style(NoticeLevel::Warning)
223        );
224    }
225
226    #[test]
227    fn a_notice_label_has_its_own_style() {
228        let plain = Theme::new().notice_style(NoticeLevel::Success, Style::new());
229        assert_eq!(plain.label_style(NoticeLevel::Success), Style::new());
230        assert_eq!(
231            plain.role_style(Role::Success),
232            Theme::new().role_style(Role::Success)
233        );
234    }
235
236    #[test]
237    fn a_notice_label_follows_its_role_until_given_its_own_style() {
238        let magenta = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Magenta)));
239        let follows = Theme::new().style(Role::Error, magenta);
240        assert_eq!(follows.label_style(NoticeLevel::Error), magenta);
241
242        let underline = Style::new().effects(Effects::UNDERLINE);
243        let own = Theme::new()
244            .notice_style(NoticeLevel::Error, underline)
245            .style(Role::Error, magenta);
246        assert_eq!(own.label_style(NoticeLevel::Error), underline);
247        assert_eq!(own.role_style(Role::Error), magenta);
248    }
249}