Skip to main content

alpha2_compose/
theme.rs

1//! Themes: the built-in ones, and a spec's theme laid over its base.
2
3use alpha2_model::Rgba;
4
5use crate::spec::{Theme, ThemeChoice, Transition};
6
7/// The built-in themes, by name.
8pub const THEMES: [&str; 2] = ["clean-dark", "clean-light"];
9
10/// A theme with every part decided.
11#[derive(Clone, Debug, PartialEq)]
12pub(crate) struct Resolved {
13    pub font_family: Option<String>,
14    pub text: Rgba,
15    pub background: Rgba,
16    pub surface: Rgba,
17    pub panel: Rgba,
18    pub accent: Rgba,
19    pub title_size: f32,
20    pub subtitle_size: f32,
21    pub body_size: f32,
22    pub caption_size: f32,
23    pub label_size: f32,
24    pub margin: f32,
25    pub transition: Transition,
26    pub still_seconds: f64,
27    pub text_seconds: f64,
28    pub fade_seconds: f64,
29}
30
31const fn gray(level: f32, alpha: f32) -> Rgba {
32    Rgba::new(level, level, level, alpha)
33}
34
35fn builtin(name: &str) -> Option<Resolved> {
36    let crossfade = Transition {
37        kind: "crossfade".to_owned(),
38        duration: 0.5,
39        easing: None,
40        params: std::collections::BTreeMap::new(),
41        version: None,
42    };
43    let common = Resolved {
44        font_family: None,
45        text: gray(1.0, 1.0),
46        background: gray(0.0, 1.0),
47        surface: Rgba::new(0.067, 0.075, 0.094, 1.0),
48        panel: gray(0.0, 0.6),
49        accent: Rgba::new(0.31, 0.545, 1.0, 1.0),
50        title_size: 0.1,
51        subtitle_size: 0.045,
52        body_size: 0.04,
53        caption_size: 0.06,
54        label_size: 0.034,
55        margin: 0.04,
56        transition: crossfade,
57        still_seconds: 4.0,
58        text_seconds: 3.0,
59        fade_seconds: 0.4,
60    };
61    match name {
62        "clean-dark" => Some(common),
63        "clean-light" => Some(Resolved {
64            text: gray(0.067, 1.0),
65            background: gray(1.0, 1.0),
66            surface: Rgba::new(0.96, 0.96, 0.95, 1.0),
67            panel: gray(1.0, 0.75),
68            accent: Rgba::new(0.0, 0.4, 0.85, 1.0),
69            ..common
70        }),
71        _ => None,
72    }
73}
74
75/// The theme `choice` names or builds, or why it is not one.
76pub(crate) fn resolve(choice: &ThemeChoice) -> Result<Resolved, String> {
77    let unknown = |name: &str| {
78        format!(
79            "unknown theme \"{name}\"; the built-in themes are {}",
80            THEMES.join(", ")
81        )
82    };
83    let theme: &Theme = match choice {
84        ThemeChoice::Named(name) => return builtin(name).ok_or_else(|| unknown(name)),
85        ThemeChoice::Custom(theme) => theme,
86    };
87    let base_name = theme.base.as_deref().unwrap_or(THEMES[0]);
88    let base = builtin(base_name).ok_or_else(|| unknown(base_name))?;
89    let size = |value: Option<f32>, fallback: f32, field: &str| match value {
90        Some(value) if !(value.is_finite() && value > 0.0 && value <= 1.0) => Err(format!(
91            "{field} is {value}; a theme size is a fraction of the canvas's shorter side, in (0, 1]"
92        )),
93        value => Ok(value.unwrap_or(fallback)),
94    };
95    let seconds = |value: Option<f64>, fallback: f64, field: &str| match value {
96        Some(value) if !(value.is_finite() && value >= 0.0) => {
97            Err(format!("{field} is {value}; it is non-negative seconds"))
98        }
99        value => Ok(value.unwrap_or(fallback)),
100    };
101    Ok(Resolved {
102        font_family: theme.font_family.clone().or(base.font_family),
103        text: theme.text.unwrap_or(base.text),
104        background: theme.background.unwrap_or(base.background),
105        surface: theme.surface.unwrap_or(base.surface),
106        panel: theme.panel.unwrap_or(base.panel),
107        accent: theme.accent.unwrap_or(base.accent),
108        title_size: size(theme.title_size, base.title_size, "title_size")?,
109        subtitle_size: size(theme.subtitle_size, base.subtitle_size, "subtitle_size")?,
110        body_size: size(theme.body_size, base.body_size, "body_size")?,
111        caption_size: size(theme.caption_size, base.caption_size, "caption_size")?,
112        label_size: size(theme.label_size, base.label_size, "label_size")?,
113        margin: size(theme.margin, base.margin, "margin")?,
114        transition: theme.transition.clone().unwrap_or(base.transition),
115        still_seconds: seconds(theme.still_seconds, base.still_seconds, "still_seconds")?,
116        text_seconds: seconds(theme.text_seconds, base.text_seconds, "text_seconds")?,
117        fade_seconds: seconds(theme.fade_seconds, base.fade_seconds, "fade_seconds")?,
118    })
119}