Skip to main content

cranpose_liquid/
theme.rs

1//! Liquid theme: semantic colors, the iOS-style type ramp, and glass defaults,
2//! provided to the subtree through composition locals — the analogue of
3//! `MaterialTheme`.
4
5#![allow(non_snake_case)]
6
7use cranpose_core::{compositionLocalOf, CompositionLocal, CompositionLocalProvider};
8use cranpose_macros::composable;
9use cranpose_services::isSystemInDarkTheme;
10use cranpose_ui::text::FontWeight;
11use cranpose_ui::text::{SpanStyle, TextStyle, TextUnit};
12use cranpose_ui_graphics::Color;
13use std::cell::RefCell;
14
15/// Whether the theme follows the OS appearance or is pinned.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
17pub enum SchemeMode {
18    /// Follow [`cranpose_services::isSystemInDarkTheme`] live.
19    #[default]
20    Auto,
21    Light,
22    Dark,
23}
24
25/// Semantic color palette mirroring the iOS system palette.
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct LiquidColors {
28    /// True when this is the dark palette (drives glass exposure/tints).
29    pub is_dark: bool,
30    /// Primary text.
31    pub label: Color,
32    /// Secondary text (subtitles, captions).
33    pub secondary_label: Color,
34    /// Tertiary text (placeholders, disabled).
35    pub tertiary_label: Color,
36    /// Hairline separators.
37    pub separator: Color,
38    /// Filled control track (chips, search fields).
39    pub fill: Color,
40    /// Lighter fill for nested controls.
41    pub secondary_fill: Color,
42    /// Window background (grouped style).
43    pub background: Color,
44    /// Elevated surface (cards, list sections).
45    pub surface: Color,
46    /// Pressed surface wash.
47    pub surface_pressed: Color,
48    /// Accent (interactive) color.
49    pub accent: Color,
50    /// Content on accent fills.
51    pub on_accent: Color,
52    /// Destructive action color.
53    pub destructive: Color,
54    /// Success color.
55    pub success: Color,
56    /// Warning color.
57    pub warning: Color,
58    /// Tint mixed over glass materials.
59    pub glass_tint: Color,
60    /// Hairline stroke drawn around glass shapes.
61    pub glass_stroke: Color,
62}
63
64impl LiquidColors {
65    pub fn light(accent: Color) -> Self {
66        Self {
67            is_dark: false,
68            label: Color::from_rgb_u8(17, 17, 20),
69            secondary_label: Color::from_rgba_u8(60, 60, 67, 153),
70            tertiary_label: Color::from_rgba_u8(60, 60, 67, 76),
71            separator: Color::from_rgba_u8(60, 60, 67, 56),
72            fill: Color::from_rgba_u8(120, 120, 128, 40),
73            secondary_fill: Color::from_rgba_u8(120, 120, 128, 28),
74            background: Color::from_rgb_u8(242, 242, 247),
75            surface: Color::WHITE,
76            surface_pressed: Color::from_rgb_u8(226, 226, 231),
77            accent,
78            on_accent: Color::WHITE,
79            destructive: Color::from_rgb_u8(255, 59, 48),
80            success: Color::from_rgb_u8(52, 199, 89),
81            warning: Color::from_rgb_u8(255, 149, 0),
82            glass_tint: Color::from_rgba_u8(255, 255, 255, 18),
83            glass_stroke: Color::from_rgba_u8(255, 255, 255, 120),
84        }
85    }
86
87    pub fn dark(accent: Color) -> Self {
88        Self {
89            is_dark: true,
90            label: Color::from_rgb_u8(242, 242, 247),
91            secondary_label: Color::from_rgba_u8(235, 235, 245, 153),
92            tertiary_label: Color::from_rgba_u8(235, 235, 245, 76),
93            separator: Color::from_rgba_u8(84, 84, 88, 130),
94            fill: Color::from_rgba_u8(120, 120, 128, 70),
95            secondary_fill: Color::from_rgba_u8(120, 120, 128, 50),
96            background: Color::from_rgb_u8(10, 10, 12),
97            surface: Color::from_rgb_u8(28, 28, 30),
98            surface_pressed: Color::from_rgb_u8(44, 44, 46),
99            accent,
100            on_accent: Color::WHITE,
101            destructive: Color::from_rgb_u8(255, 69, 58),
102            success: Color::from_rgb_u8(48, 209, 88),
103            warning: Color::from_rgb_u8(255, 159, 10),
104            glass_tint: Color::from_rgba_u8(20, 20, 24, 40),
105            glass_stroke: Color::from_rgba_u8(255, 255, 255, 46),
106        }
107    }
108}
109
110/// The iOS text-style ramp as ready-to-use [`TextStyle`]s (colors come from
111/// [`LiquidColors::label`] by default at the call site).
112#[derive(Clone, Debug, PartialEq)]
113pub struct LiquidTypography {
114    pub large_title: TextStyle,
115    pub title1: TextStyle,
116    pub title2: TextStyle,
117    pub title3: TextStyle,
118    pub headline: TextStyle,
119    pub body: TextStyle,
120    pub callout: TextStyle,
121    pub subheadline: TextStyle,
122    pub footnote: TextStyle,
123    pub caption1: TextStyle,
124    pub caption2: TextStyle,
125}
126
127fn ramp_style(size_sp: f32, weight: FontWeight) -> TextStyle {
128    TextStyle {
129        span_style: SpanStyle {
130            font_size: TextUnit::Sp(size_sp),
131            font_weight: Some(weight),
132            ..Default::default()
133        },
134        ..Default::default()
135    }
136}
137
138impl Default for LiquidTypography {
139    fn default() -> Self {
140        Self {
141            large_title: ramp_style(34.0, FontWeight::BOLD),
142            title1: ramp_style(28.0, FontWeight::BOLD),
143            title2: ramp_style(22.0, FontWeight::BOLD),
144            title3: ramp_style(20.0, FontWeight::SEMI_BOLD),
145            headline: ramp_style(17.0, FontWeight::SEMI_BOLD),
146            body: ramp_style(17.0, FontWeight::NORMAL),
147            callout: ramp_style(16.0, FontWeight::NORMAL),
148            subheadline: ramp_style(15.0, FontWeight::NORMAL),
149            footnote: ramp_style(13.0, FontWeight::NORMAL),
150            caption1: ramp_style(12.0, FontWeight::NORMAL),
151            caption2: ramp_style(11.0, FontWeight::NORMAL),
152        }
153    }
154}
155
156/// Theme configuration passed to [`LiquidTheme`].
157#[derive(Clone, Debug, PartialEq)]
158pub struct LiquidThemeSpec {
159    pub scheme: SchemeMode,
160    /// Accent color (iOS system blue by default).
161    pub accent: Color,
162    pub typography: LiquidTypography,
163}
164
165impl Default for LiquidThemeSpec {
166    fn default() -> Self {
167        Self {
168            scheme: SchemeMode::Auto,
169            accent: Color::from_rgb_u8(0, 122, 255),
170            typography: LiquidTypography::default(),
171        }
172    }
173}
174
175fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
176    thread_local! {
177        static LOCAL: RefCell<Option<CompositionLocal<LiquidColors>>> = const { RefCell::new(None) };
178    }
179    LOCAL.with(|cell| {
180        cell.borrow_mut()
181            .get_or_insert_with(|| {
182                compositionLocalOf(|| LiquidColors::light(LiquidThemeSpec::default().accent))
183            })
184            .clone()
185    })
186}
187
188fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
189    thread_local! {
190        static LOCAL: RefCell<Option<CompositionLocal<LiquidTypography>>> = const { RefCell::new(None) };
191    }
192    LOCAL.with(|cell| {
193        cell.borrow_mut()
194            .get_or_insert_with(|| compositionLocalOf(LiquidTypography::default))
195            .clone()
196    })
197}
198
199/// The active semantic palette (light defaults outside a [`LiquidTheme`]).
200#[composable]
201pub fn liquid_colors() -> LiquidColors {
202    local_liquid_colors().current()
203}
204
205/// The active type ramp.
206#[composable]
207pub fn liquid_typography() -> LiquidTypography {
208    local_liquid_typography().current()
209}
210
211/// Provides the Liquid design system (colors, typography) to `content`.
212///
213/// `SchemeMode::Auto` follows the OS light/dark appearance live.
214#[composable]
215pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
216    let dark = match spec.scheme {
217        SchemeMode::Auto => isSystemInDarkTheme(),
218        SchemeMode::Light => false,
219        SchemeMode::Dark => true,
220    };
221    let colors = if dark {
222        LiquidColors::dark(spec.accent)
223    } else {
224        LiquidColors::light(spec.accent)
225    };
226    CompositionLocalProvider(
227        vec![
228            local_liquid_colors().provides(colors),
229            local_liquid_typography().provides(spec.typography.clone()),
230        ],
231        move || {
232            content();
233        },
234    );
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn palettes_differ_and_share_accent() {
243        let accent = Color::from_rgb_u8(0, 122, 255);
244        let light = LiquidColors::light(accent);
245        let dark = LiquidColors::dark(accent);
246        assert!(!light.is_dark);
247        assert!(dark.is_dark);
248        assert_eq!(light.accent, dark.accent);
249        assert_ne!(light.background, dark.background);
250        assert_ne!(light.glass_tint, dark.glass_tint);
251    }
252
253    #[test]
254    fn type_ramp_is_descending() {
255        let t = LiquidTypography::default();
256        let sizes = [
257            &t.large_title,
258            &t.title1,
259            &t.title2,
260            &t.title3,
261            &t.body,
262            &t.footnote,
263            &t.caption2,
264        ];
265        let values: Vec<f32> = sizes
266            .iter()
267            .map(|style| style.span_style.font_size.value())
268            .collect();
269        assert!(values.windows(2).all(|pair| pair[0] >= pair[1]));
270    }
271}