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