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