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    /// Active switch track.
57    pub toggle_on: Color,
58    /// Inactive switch track.
59    pub toggle_off: Color,
60    /// Warning color.
61    pub warning: Color,
62    /// Tint mixed over glass materials.
63    pub glass_tint: Color,
64    /// Hairline stroke drawn around glass shapes.
65    pub glass_stroke: Color,
66}
67
68impl LiquidColors {
69    pub fn light(accent: Color) -> Self {
70        Self {
71            is_dark: false,
72            label: Color::from_rgb_u8(17, 17, 20),
73            secondary_label: Color::from_rgba_u8(60, 60, 67, 153),
74            tertiary_label: Color::from_rgba_u8(60, 60, 67, 76),
75            separator: Color::from_rgba_u8(60, 60, 67, 56),
76            fill: Color::from_rgba_u8(120, 120, 128, 40),
77            secondary_fill: Color::from_rgba_u8(120, 120, 128, 28),
78            background: Color::from_rgb_u8(242, 242, 247),
79            surface: Color::WHITE,
80            surface_pressed: Color::from_rgb_u8(226, 226, 231),
81            accent,
82            on_accent: Color::WHITE,
83            destructive: Color::from_rgb_u8(255, 59, 48),
84            success: Color::from_rgb_u8(52, 199, 89),
85            toggle_on: Color::from_rgb_u8(43, 189, 76),
86            toggle_off: Color::from_rgb_u8(170, 170, 181),
87            warning: Color::from_rgb_u8(255, 149, 0),
88            glass_tint: Color::from_rgba_u8(255, 255, 255, 18),
89            glass_stroke: Color::from_rgba_u8(255, 255, 255, 120),
90        }
91    }
92
93    pub fn dark(accent: Color) -> Self {
94        Self {
95            is_dark: true,
96            label: Color::from_rgb_u8(242, 242, 247),
97            secondary_label: Color::from_rgba_u8(235, 235, 245, 153),
98            tertiary_label: Color::from_rgba_u8(235, 235, 245, 76),
99            separator: Color::from_rgba_u8(84, 84, 88, 130),
100            fill: Color::from_rgba_u8(120, 120, 128, 70),
101            secondary_fill: Color::from_rgba_u8(120, 120, 128, 50),
102            background: Color::from_rgb_u8(10, 10, 12),
103            surface: Color::from_rgb_u8(28, 28, 30),
104            surface_pressed: Color::from_rgb_u8(44, 44, 46),
105            accent,
106            on_accent: Color::WHITE,
107            destructive: Color::from_rgb_u8(255, 69, 58),
108            success: Color::from_rgb_u8(48, 209, 88),
109            toggle_on: Color::from_rgb_u8(48, 209, 88),
110            toggle_off: Color::from_rgb_u8(99, 99, 102),
111            warning: Color::from_rgb_u8(255, 159, 10),
112            glass_tint: Color::from_rgba_u8(20, 20, 24, 40),
113            glass_stroke: Color::from_rgba_u8(255, 255, 255, 46),
114        }
115    }
116}
117
118/// The iOS text-style ramp as ready-to-use [`TextStyle`]s (colors come from
119/// [`LiquidColors::label`] by default at the call site).
120#[derive(Clone, Debug, PartialEq)]
121pub struct LiquidTypography {
122    pub large_title: TextStyle,
123    pub title1: TextStyle,
124    pub title2: TextStyle,
125    pub title3: TextStyle,
126    pub headline: TextStyle,
127    pub body: TextStyle,
128    pub callout: TextStyle,
129    pub subheadline: TextStyle,
130    pub footnote: TextStyle,
131    pub caption1: TextStyle,
132    pub caption2: TextStyle,
133}
134
135fn ramp_style(size_sp: f32, weight: FontWeight) -> TextStyle {
136    TextStyle {
137        span_style: SpanStyle {
138            font_size: TextUnit::Sp(size_sp),
139            font_weight: Some(weight),
140            ..Default::default()
141        },
142        ..Default::default()
143    }
144}
145
146impl Default for LiquidTypography {
147    fn default() -> Self {
148        Self {
149            large_title: ramp_style(34.0, FontWeight::BOLD),
150            title1: ramp_style(28.0, FontWeight::BOLD),
151            title2: ramp_style(22.0, FontWeight::BOLD),
152            title3: ramp_style(20.0, FontWeight::SEMI_BOLD),
153            headline: ramp_style(17.0, FontWeight::SEMI_BOLD),
154            body: ramp_style(17.0, FontWeight::NORMAL),
155            callout: ramp_style(16.0, FontWeight::NORMAL),
156            subheadline: ramp_style(15.0, FontWeight::NORMAL),
157            footnote: ramp_style(13.0, FontWeight::NORMAL),
158            caption1: ramp_style(12.0, FontWeight::NORMAL),
159            caption2: ramp_style(11.0, FontWeight::NORMAL),
160        }
161    }
162}
163
164/// Theme configuration passed to [`LiquidTheme`].
165#[derive(Clone, Debug, PartialEq)]
166pub struct LiquidThemeSpec {
167    pub scheme: SchemeMode,
168    /// Accent color (iOS system blue by default).
169    pub accent: Color,
170    pub typography: LiquidTypography,
171}
172
173impl Default for LiquidThemeSpec {
174    fn default() -> Self {
175        Self {
176            scheme: SchemeMode::Auto,
177            accent: Color::from_rgb_u8(0, 122, 255),
178            typography: LiquidTypography::default(),
179        }
180    }
181}
182
183fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
184    thread_local! {
185        static LOCAL: RefCell<Option<CompositionLocal<LiquidColors>>> = const { RefCell::new(None) };
186    }
187    LOCAL.with(|cell| {
188        cell.borrow_mut()
189            .get_or_insert_with(|| {
190                compositionLocalOf(|| LiquidColors::light(LiquidThemeSpec::default().accent))
191            })
192            .clone()
193    })
194}
195
196fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
197    thread_local! {
198        static LOCAL: RefCell<Option<CompositionLocal<LiquidTypography>>> = const { RefCell::new(None) };
199    }
200    LOCAL.with(|cell| {
201        cell.borrow_mut()
202            .get_or_insert_with(|| compositionLocalOf(LiquidTypography::default))
203            .clone()
204    })
205}
206
207/// The active semantic palette (light defaults outside a [`LiquidTheme`]).
208#[composable]
209pub fn liquid_colors() -> LiquidColors {
210    local_liquid_colors().current()
211}
212
213/// The active type ramp.
214#[composable]
215pub fn liquid_typography() -> LiquidTypography {
216    local_liquid_typography().current()
217}
218
219/// Provides the Liquid design system (colors, typography) to `content`.
220///
221/// `SchemeMode::Auto` follows the OS light/dark appearance live.
222#[composable]
223pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
224    let dark = match spec.scheme {
225        SchemeMode::Auto => isSystemInDarkTheme(),
226        SchemeMode::Light => false,
227        SchemeMode::Dark => true,
228    };
229    let colors = if dark {
230        LiquidColors::dark(spec.accent)
231    } else {
232        LiquidColors::light(spec.accent)
233    };
234    CompositionLocalProvider(
235        vec![
236            local_liquid_colors().provides(colors),
237            local_liquid_typography().provides(spec.typography.clone()),
238        ],
239        move || {
240            content();
241        },
242    );
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn palettes_differ_and_share_accent() {
251        let accent = Color::from_rgb_u8(0, 122, 255);
252        let light = LiquidColors::light(accent);
253        let dark = LiquidColors::dark(accent);
254        assert!(!light.is_dark);
255        assert!(dark.is_dark);
256        assert_eq!(light.accent, dark.accent);
257        assert_ne!(light.background, dark.background);
258        assert_ne!(light.glass_tint, dark.glass_tint);
259        assert_ne!(light.toggle_on, dark.toggle_on);
260        assert_ne!(light.toggle_off, dark.toggle_off);
261    }
262
263    #[test]
264    fn type_ramp_is_descending() {
265        let t = LiquidTypography::default();
266        let sizes = [
267            &t.large_title,
268            &t.title1,
269            &t.title2,
270            &t.title3,
271            &t.body,
272            &t.footnote,
273            &t.caption2,
274        ];
275        let values: Vec<f32> = sizes
276            .iter()
277            .map(|style| style.span_style.font_size.value())
278            .collect();
279        assert!(values.windows(2).all(|pair| pair[0] >= pair[1]));
280    }
281}