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