Skip to main content

urushi/theme/
definition.rs

1//! Theme construction and light/dark selection.
2
3use std::fmt;
4
5use urushi_terminal::TerminalBackground;
6
7use crate::{BlockStyle, List, Scrollbar, Table, TextStyle, Tree, View};
8
9use super::{BlockThemeRole, ComponentTheme, SemanticTokens, TextThemeRole};
10
11/// An explicit choice between a light and dark theme.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ColorScheme {
14    Light,
15    Dark,
16}
17
18/// The caller's policy for selecting a light or dark theme.
19///
20/// `Light` and `Dark` are explicit choices and need no terminal observation.
21/// `Auto` classifies an observed terminal background, or uses its explicit
22/// fallback when observation is unavailable.
23///
24/// ```
25/// use std::io;
26/// use urushi::{ColorScheme, ThemeMode};
27/// use urushi_terminal::TerminalQuery;
28///
29/// # fn resolve_mode(
30/// #     terminal: &mut impl TerminalQuery,
31/// #     mode: ThemeMode,
32/// # ) -> io::Result<ColorScheme> {
33/// let background = match mode {
34///     ThemeMode::Auto { .. } => terminal.terminal_background()?,
35///     ThemeMode::Light | ThemeMode::Dark => None,
36/// };
37/// let scheme = mode.resolve(background);
38/// # Ok(scheme)
39/// # }
40/// ```
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ThemeMode {
43    Light,
44    Dark,
45    Auto { fallback: ColorScheme },
46}
47
48impl ThemeMode {
49    pub fn resolve(self, background: Option<TerminalBackground>) -> ColorScheme {
50        match self {
51            Self::Light => ColorScheme::Light,
52            Self::Dark => ColorScheme::Dark,
53            Self::Auto { fallback } => background.map_or(fallback, classify_background),
54        }
55    }
56}
57
58fn classify_background(background: TerminalBackground) -> ColorScheme {
59    let red = linear_srgb(background.red());
60    let green = linear_srgb(background.green());
61    let blue = linear_srgb(background.blue());
62    let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
63    if luminance < 0.5 {
64        ColorScheme::Dark
65    } else {
66        ColorScheme::Light
67    }
68}
69
70fn linear_srgb(channel: u16) -> f64 {
71    let encoded = f64::from(channel) / f64::from(u16::MAX);
72    if encoded <= 0.04045 {
73        encoded / 12.92
74    } else {
75        ((encoded + 0.055) / 1.055).powf(2.4)
76    }
77}
78
79/// A theme with common semantic tokens and canonical component presentations.
80///
81/// A theme carries no application-specific slot. Applications extend it by
82/// implementing [`TextThemeRole`] for their own role type and deriving the style
83/// inside [`TextThemeRole::resolve`]; see the module documentation for the
84/// conventions that follow from that.
85#[derive(Debug, Clone, PartialEq)]
86pub struct Theme {
87    tokens: SemanticTokens,
88    components: ComponentTheme,
89}
90
91impl Theme {
92    pub fn from_tokens(tokens: SemanticTokens) -> Self {
93        let components = ComponentTheme::from_tokens(&tokens);
94        Self::new(tokens, components)
95    }
96
97    pub fn new(tokens: SemanticTokens, components: ComponentTheme) -> Self {
98        Self { tokens, components }
99    }
100
101    pub fn tokens(&self) -> &SemanticTokens {
102        &self.tokens
103    }
104
105    pub fn components(&self) -> &ComponentTheme {
106        &self.components
107    }
108
109    /// Composes a list with this theme's canonical presentation.
110    pub fn list<T>(&self, list: &List<T>) -> View
111    where
112        T: fmt::Display,
113    {
114        self.components.list().compose(list)
115    }
116
117    /// Composes a tree with this theme's canonical presentation.
118    pub fn tree<T>(&self, tree: &Tree<T>) -> View
119    where
120        T: fmt::Display,
121    {
122        self.components.tree().compose(tree)
123    }
124
125    /// Composes a table with this theme's canonical presentation.
126    pub fn table<T>(&self, table: &Table<T>) -> View
127    where
128        T: crate::TableRow,
129    {
130        self.components.table().compose(table)
131    }
132
133    /// Composes a scrollbar with this theme's canonical presentation.
134    pub fn scrollbar(&self, scrollbar: &Scrollbar) -> View {
135        self.components.scrollbar().compose(scrollbar)
136    }
137
138    /// Resolves one role against this theme.
139    ///
140    /// The style is returned by value because application roles derive their
141    /// style rather than reading it from a stored table. Consumers that resolve
142    /// roles inside a draw loop should resolve once into their own cache and
143    /// borrow from it; [`Theme::components`] also exposes built-in styles as
144    /// borrows.
145    pub fn text_style<R>(&self, role: R) -> TextStyle
146    where
147        R: TextThemeRole,
148    {
149        role.resolve(self)
150    }
151
152    /// Resolves one geometry-bearing role against this theme.
153    pub fn block_style<R>(&self, role: R) -> BlockStyle
154    where
155        R: BlockThemeRole,
156    {
157        role.resolve(self)
158    }
159}
160
161/// A matched light and dark theme.
162#[derive(Debug, Clone, PartialEq)]
163pub struct ThemeSet {
164    light: Theme,
165    dark: Theme,
166}
167
168impl ThemeSet {
169    pub fn new(light: Theme, dark: Theme) -> Self {
170        Self { light, dark }
171    }
172
173    pub fn light(&self) -> &Theme {
174        &self.light
175    }
176
177    pub fn dark(&self) -> &Theme {
178        &self.dark
179    }
180
181    pub fn select(&self, scheme: ColorScheme) -> &Theme {
182        match scheme {
183            ColorScheme::Light => self.light(),
184            ColorScheme::Dark => self.dark(),
185        }
186    }
187}