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).
127impl LiquidColors {
128    /// The palette for a person who asked the system for more contrast: the
129    /// secondary and tertiary labels, the separator, the fills and the glass
130    /// edge move toward the label color, and the off toggle darkens.
131    pub fn with_more_contrast(mut self) -> Self {
132        self.secondary_label = self.secondary_label.with_alpha(0.9);
133        self.tertiary_label = self.tertiary_label.with_alpha(0.7);
134        self.separator = self.separator.with_alpha(0.6);
135        self.fill = self.fill.with_alpha(0.32);
136        self.secondary_fill = self.secondary_fill.with_alpha(0.24);
137        self.glass_stroke = self.glass_stroke.with_alpha(0.9);
138        self.toggle_off = if self.is_dark {
139            Color::from_rgb_u8(142, 142, 147)
140        } else {
141            Color::from_rgb_u8(99, 99, 108)
142        };
143        self
144    }
145}
146
147#[derive(Clone, Debug, PartialEq)]
148pub struct LiquidTypography {
149    pub large_title: TextStyle,
150    pub title1: TextStyle,
151    pub title2: TextStyle,
152    pub title3: TextStyle,
153    pub headline: TextStyle,
154    pub body: TextStyle,
155    pub callout: TextStyle,
156    pub subheadline: TextStyle,
157    pub footnote: TextStyle,
158    pub caption1: TextStyle,
159    pub caption2: TextStyle,
160}
161
162fn ramp_style(size_sp: f32, weight: FontWeight) -> TextStyle {
163    TextStyle {
164        span_style: SpanStyle {
165            font_size: TextUnit::Sp(size_sp),
166            font_weight: Some(weight),
167            ..Default::default()
168        },
169        ..Default::default()
170    }
171}
172
173impl LiquidTypography {
174    /// The ramp for a person who asked the system for bold text: every style
175    /// gains two hundred of weight, so body text reads semi bold and titles
176    /// extra bold.
177    pub fn bolder(self) -> Self {
178        Self {
179            large_title: bolder(self.large_title),
180            title1: bolder(self.title1),
181            title2: bolder(self.title2),
182            title3: bolder(self.title3),
183            headline: bolder(self.headline),
184            body: bolder(self.body),
185            callout: bolder(self.callout),
186            subheadline: bolder(self.subheadline),
187            footnote: bolder(self.footnote),
188            caption1: bolder(self.caption1),
189            caption2: bolder(self.caption2),
190        }
191    }
192}
193
194fn bolder(mut style: TextStyle) -> TextStyle {
195    let weight = style.span_style.font_weight.unwrap_or(FontWeight::NORMAL);
196    style.span_style.font_weight = Some(FontWeight((weight.0 + 200).min(900)));
197    style
198}
199
200impl Default for LiquidTypography {
201    fn default() -> Self {
202        Self {
203            large_title: ramp_style(34.0, FontWeight::BOLD),
204            title1: ramp_style(28.0, FontWeight::BOLD),
205            title2: ramp_style(22.0, FontWeight::BOLD),
206            title3: ramp_style(20.0, FontWeight::SEMI_BOLD),
207            headline: ramp_style(17.0, FontWeight::SEMI_BOLD),
208            body: ramp_style(17.0, FontWeight::NORMAL),
209            callout: ramp_style(16.0, FontWeight::NORMAL),
210            subheadline: ramp_style(15.0, FontWeight::NORMAL),
211            footnote: ramp_style(13.0, FontWeight::NORMAL),
212            caption1: ramp_style(12.0, FontWeight::NORMAL),
213            caption2: ramp_style(11.0, FontWeight::NORMAL),
214        }
215    }
216}
217
218/// Theme configuration passed to [`LiquidTheme`].
219#[derive(Clone, Debug, PartialEq)]
220pub struct LiquidThemeSpec {
221    pub scheme: SchemeMode,
222    /// Accent color (iOS system blue by default).
223    pub accent: Color,
224    pub typography: LiquidTypography,
225    /// Tint amount used by the floating navigation surfaces.
226    pub glass_tint_amount: GlassTintAmount,
227}
228
229impl Default for LiquidThemeSpec {
230    fn default() -> Self {
231        Self {
232            scheme: SchemeMode::Auto,
233            accent: Color::from_rgb_u8(0, 122, 255),
234            typography: LiquidTypography::default(),
235            glass_tint_amount: GlassTintAmount::default(),
236        }
237    }
238}
239
240fn theme_local<T: Clone + PartialEq + 'static>(
241    cell: &RefCell<Option<CompositionLocal<T>>>,
242    default: fn() -> T,
243) -> CompositionLocal<T> {
244    cell.borrow_mut()
245        .get_or_insert_with(|| compositionLocalOf(default))
246        .clone()
247}
248
249fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
250    thread_local! {
251        static LOCAL: RefCell<Option<CompositionLocal<LiquidColors>>> = const { RefCell::new(None) };
252    }
253    LOCAL.with(|cell| {
254        theme_local(cell, || {
255            LiquidColors::light(LiquidThemeSpec::default().accent)
256        })
257    })
258}
259
260fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
261    thread_local! {
262        static LOCAL: RefCell<Option<CompositionLocal<LiquidTypography>>> = const { RefCell::new(None) };
263    }
264    LOCAL.with(|cell| theme_local(cell, LiquidTypography::default))
265}
266
267fn local_liquid_glass_tint_amount() -> CompositionLocal<GlassTintAmount> {
268    thread_local! {
269        static LOCAL: RefCell<Option<CompositionLocal<GlassTintAmount>>> = const { RefCell::new(None) };
270    }
271    LOCAL.with(|cell| theme_local(cell, GlassTintAmount::default))
272}
273
274/// The active navigation surface tint amount, defaulting to 25 percent.
275#[composable]
276pub fn liquid_glass_tint_amount() -> GlassTintAmount {
277    local_liquid_glass_tint_amount().current()
278}
279
280/// The active semantic palette (light defaults outside a [`LiquidTheme`]).
281#[composable]
282pub fn liquid_colors() -> LiquidColors {
283    local_liquid_colors().current()
284}
285
286/// The active type ramp.
287#[composable]
288pub fn liquid_typography() -> LiquidTypography {
289    local_liquid_typography().current()
290}
291
292/// Provides the Liquid design system (colors, typography) to `content`.
293///
294/// `SchemeMode::Auto` follows the OS light/dark appearance live.
295#[composable]
296pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
297    let options = cranpose_services::local_accessibility_options().current();
298    let dark = match spec.scheme {
299        SchemeMode::Auto => isSystemInDarkTheme(),
300        SchemeMode::Light => false,
301        SchemeMode::Dark => true,
302    } != options.invert_colors;
303    let colors = if dark {
304        LiquidColors::dark(spec.accent)
305    } else {
306        LiquidColors::light(spec.accent)
307    };
308    let colors = if options.increase_contrast {
309        colors.with_more_contrast()
310    } else {
311        colors
312    };
313    let typography = if options.bold_text {
314        spec.typography.clone().bolder()
315    } else {
316        spec.typography.clone()
317    };
318    CompositionLocalProvider(
319        vec![
320            local_liquid_colors().provides(colors),
321            local_liquid_typography().provides(typography),
322            local_liquid_glass_tint_amount().provides(spec.glass_tint_amount),
323        ],
324        move || {
325            content();
326        },
327    );
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn palettes_differ_and_share_accent() {
336        let accent = Color::from_rgb_u8(0, 122, 255);
337        let light = LiquidColors::light(accent);
338        let dark = LiquidColors::dark(accent);
339        assert!(!light.is_dark);
340        assert!(dark.is_dark);
341        assert_eq!(light.accent, dark.accent);
342        assert_ne!(light.background, dark.background);
343        assert_ne!(light.glass_tint, dark.glass_tint);
344        assert_eq!(light.toggle_on, accent);
345        assert_eq!(dark.toggle_on, accent);
346        assert_ne!(light.toggle_off, dark.toggle_off);
347    }
348
349    #[test]
350    fn type_ramp_is_descending() {
351        let t = LiquidTypography::default();
352        let sizes = [
353            &t.large_title,
354            &t.title1,
355            &t.title2,
356            &t.title3,
357            &t.body,
358            &t.footnote,
359            &t.caption2,
360        ];
361        let values: Vec<f32> = sizes
362            .iter()
363            .map(|style| style.span_style.font_size.value())
364            .collect();
365        assert!(values.windows(2).all(|pair| pair[0] >= pair[1]));
366    }
367}
368
369#[cfg(test)]
370mod option_tests {
371    use super::*;
372
373    #[test]
374    fn more_contrast_lifts_the_quiet_colors_toward_the_label() {
375        let plain = LiquidColors::light(Color::from_rgb_u8(0, 122, 255));
376        let strong = plain.with_more_contrast();
377        assert!(strong.secondary_label.a() > plain.secondary_label.a());
378        assert!(strong.separator.a() > plain.separator.a());
379        assert_eq!(strong.label, plain.label);
380        assert_eq!(strong.accent, plain.accent);
381    }
382
383    #[test]
384    fn bold_text_adds_a_weight_step_and_stops_at_the_top() {
385        let ramp = LiquidTypography::default().bolder();
386        assert_eq!(
387            ramp.body.span_style.font_weight,
388            Some(FontWeight::SEMI_BOLD)
389        );
390        assert_eq!(
391            ramp.headline.span_style.font_weight,
392            Some(FontWeight::EXTRA_BOLD)
393        );
394        assert_eq!(
395            ramp.large_title.span_style.font_weight,
396            Some(FontWeight(900))
397        );
398        assert_eq!(ramp.body.span_style.font_size, TextUnit::Sp(17.0));
399    }
400}