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
5use std::cell::OnceCell;
6
7use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
8use cranpose_macros::composable;
9use cranpose_services::isSystemInDarkTheme;
10use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle, TextUnit};
11use cranpose_ui_graphics::Color;
12
13use crate::appearance::GlassTintAmount;
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::BLACK,
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, 60, 30),
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_rgba_u8(60, 60, 67, 76),
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::WHITE,
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, 128),
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_rgba_u8(235, 235, 245, 76),
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).
125impl LiquidColors {
126    /// The palette for a person who asked the system for more contrast: the
127    /// secondary and tertiary labels, the separator, the fills and the glass
128    /// edge move toward the label color, and the off toggle darkens.
129    pub fn with_more_contrast(mut self) -> Self {
130        self.secondary_label = self.secondary_label.with_alpha(0.9);
131        self.tertiary_label = self.tertiary_label.with_alpha(0.7);
132        self.separator = self.separator.with_alpha(0.6);
133        self.fill = self.fill.with_alpha(0.32);
134        self.secondary_fill = self.secondary_fill.with_alpha(0.24);
135        self.glass_stroke = self.glass_stroke.with_alpha(0.9);
136        self.toggle_off = if self.is_dark {
137            Color::from_rgb_u8(142, 142, 147)
138        } else {
139            Color::from_rgb_u8(99, 99, 108)
140        };
141        self
142    }
143}
144
145#[derive(Clone, Debug, PartialEq)]
146pub struct LiquidTypography {
147    pub large_title: TextStyle,
148    pub title1: TextStyle,
149    pub title2: TextStyle,
150    pub title3: TextStyle,
151    pub headline: TextStyle,
152    pub body: TextStyle,
153    pub callout: TextStyle,
154    pub subheadline: TextStyle,
155    pub footnote: TextStyle,
156    pub caption1: TextStyle,
157    pub caption2: TextStyle,
158}
159
160fn ramp_style(size_sp: f32, weight: FontWeight) -> TextStyle {
161    TextStyle {
162        span_style: SpanStyle {
163            font_size: TextUnit::Sp(size_sp),
164            font_weight: Some(weight),
165            ..Default::default()
166        },
167        ..Default::default()
168    }
169}
170
171impl LiquidTypography {
172    /// The ramp for a person who asked the system for bold text: every style
173    /// gains two hundred of weight, so body text reads semi bold and titles
174    /// extra bold.
175    pub fn bolder(self) -> Self {
176        Self {
177            large_title: bolder(self.large_title),
178            title1: bolder(self.title1),
179            title2: bolder(self.title2),
180            title3: bolder(self.title3),
181            headline: bolder(self.headline),
182            body: bolder(self.body),
183            callout: bolder(self.callout),
184            subheadline: bolder(self.subheadline),
185            footnote: bolder(self.footnote),
186            caption1: bolder(self.caption1),
187            caption2: bolder(self.caption2),
188        }
189    }
190}
191
192fn bolder(mut style: TextStyle) -> TextStyle {
193    let weight = style.span_style.font_weight.unwrap_or(FontWeight::NORMAL);
194    style.span_style.font_weight = Some(FontWeight((weight.0 + 200).min(900)));
195    style
196}
197
198impl Default for LiquidTypography {
199    fn default() -> Self {
200        Self {
201            large_title: ramp_style(34.0, FontWeight::BOLD),
202            title1: ramp_style(28.0, FontWeight::BOLD),
203            title2: ramp_style(22.0, FontWeight::BOLD),
204            title3: ramp_style(20.0, FontWeight::SEMI_BOLD),
205            headline: ramp_style(17.0, FontWeight::SEMI_BOLD),
206            body: ramp_style(17.0, FontWeight::NORMAL),
207            callout: ramp_style(16.0, FontWeight::NORMAL),
208            subheadline: ramp_style(15.0, FontWeight::NORMAL),
209            footnote: ramp_style(13.0, FontWeight::NORMAL),
210            caption1: ramp_style(12.0, FontWeight::NORMAL),
211            caption2: ramp_style(11.0, FontWeight::NORMAL),
212        }
213    }
214}
215
216/// How a [`crate::LiquidSegmentedControl`] looks. [`Self::system`] is the
217/// system control; an app gives its controls another look with
218/// [`ProvideLiquidSegmentedStyle`].
219///
220/// ```rust,ignore
221/// let colors = liquid_colors();
222/// let style = LiquidSegmentedStyle {
223///     indicator: colors.accent,
224///     selected_label: colors.on_accent,
225///     ..LiquidSegmentedStyle::system(&colors, &liquid_typography())
226/// };
227/// ProvideLiquidSegmentedStyle(style, || settings());
228/// ```
229#[derive(Clone, Debug, PartialEq)]
230pub struct LiquidSegmentedStyle {
231    /// The track under the segments.
232    pub track: Color,
233    /// The lens over the chosen segment at rest. A held lens clears to glass.
234    pub indicator: Color,
235    /// The labels of the segments.
236    pub label: Color,
237    /// The label of the chosen segment while the lens rests over it. While
238    /// the lens is held, the label moves to [`Self::label`] as the indicator
239    /// clears.
240    pub selected_label: Color,
241    /// The labels' text. The chosen segment's label is one weight heavier.
242    pub text: TextStyle,
243    /// The height of the control.
244    pub height: f32,
245}
246
247impl LiquidSegmentedStyle {
248    /// The system control: a white lens on a light track, or a gray lens on a
249    /// dark one, with every label in the label color.
250    pub fn system(colors: &LiquidColors, typography: &LiquidTypography) -> Self {
251        let mut text = typography.subheadline.clone();
252        text.span_style.font_size = TextUnit::Sp(13.0);
253        text.span_style.font_weight = Some(FontWeight::NORMAL);
254        let (track, indicator) = if colors.is_dark {
255            (
256                Color::from_rgb_u8(28, 28, 31),
257                Color::from_rgb_u8(90, 90, 95),
258            )
259        } else {
260            (Color::from_rgb_u8(238, 238, 239), Color::WHITE)
261        };
262        Self {
263            track,
264            indicator,
265            label: colors.label,
266            selected_label: colors.label,
267            text,
268            height: 32.0,
269        }
270    }
271}
272
273/// Theme configuration passed to [`LiquidTheme`].
274#[derive(Clone, Debug, PartialEq)]
275pub struct LiquidThemeSpec {
276    pub scheme: SchemeMode,
277    /// Accent color (iOS system blue by default).
278    pub accent: Color,
279    pub typography: LiquidTypography,
280    /// Tint amount used by the floating navigation surfaces.
281    pub glass_tint_amount: GlassTintAmount,
282}
283
284impl Default for LiquidThemeSpec {
285    fn default() -> Self {
286        Self {
287            scheme: SchemeMode::Auto,
288            accent: Color::from_rgb_u8(0, 122, 255),
289            typography: LiquidTypography::default(),
290            glass_tint_amount: GlassTintAmount::default(),
291        }
292    }
293}
294
295struct ThemeLocals {
296    colors: OnceCell<CompositionLocal<LiquidColors>>,
297    typography: OnceCell<CompositionLocal<LiquidTypography>>,
298    glass_tint: OnceCell<CompositionLocal<GlassTintAmount>>,
299    segmented: OnceCell<CompositionLocal<Option<LiquidSegmentedStyle>>>,
300}
301
302thread_local! {
303    static LOCALS: ThemeLocals = const { ThemeLocals {
304        colors: OnceCell::new(),
305        typography: OnceCell::new(),
306        glass_tint: OnceCell::new(),
307        segmented: OnceCell::new(),
308    } };
309}
310
311fn theme_local<T: Clone + PartialEq + 'static>(
312    cell: impl FnOnce(&ThemeLocals) -> &OnceCell<CompositionLocal<T>>,
313    default: fn() -> T,
314) -> CompositionLocal<T> {
315    LOCALS.with(|locals| {
316        cell(locals)
317            .get_or_init(|| compositionLocalOf(default))
318            .clone()
319    })
320}
321
322fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
323    theme_local(
324        |locals| &locals.colors,
325        || LiquidColors::light(LiquidThemeSpec::default().accent),
326    )
327}
328
329fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
330    theme_local(|locals| &locals.typography, LiquidTypography::default)
331}
332
333fn local_liquid_glass_tint_amount() -> CompositionLocal<GlassTintAmount> {
334    theme_local(|locals| &locals.glass_tint, GlassTintAmount::default)
335}
336
337fn local_liquid_segmented_style() -> CompositionLocal<Option<LiquidSegmentedStyle>> {
338    theme_local(|locals| &locals.segmented, || None)
339}
340
341/// The look of segmented controls here: the one provided by
342/// [`ProvideLiquidSegmentedStyle`], or the system one.
343#[composable]
344pub fn liquid_segmented_style() -> LiquidSegmentedStyle {
345    local_liquid_segmented_style()
346        .current()
347        .unwrap_or_else(|| LiquidSegmentedStyle::system(&liquid_colors(), &liquid_typography()))
348}
349
350/// Gives the segmented controls in `content` the look of `style`.
351#[composable]
352pub fn ProvideLiquidSegmentedStyle(style: LiquidSegmentedStyle, content: impl FnOnce()) {
353    CompositionLocalProvider(
354        [local_liquid_segmented_style().provides(Some(style))],
355        move || {
356            content();
357        },
358    );
359}
360
361/// The active navigation surface tint amount, defaulting to 25 percent.
362#[composable]
363pub fn liquid_glass_tint_amount() -> GlassTintAmount {
364    local_liquid_glass_tint_amount().current()
365}
366
367/// The active semantic palette (light defaults outside a [`LiquidTheme`]).
368#[composable]
369pub fn liquid_colors() -> LiquidColors {
370    local_liquid_colors().current()
371}
372
373/// The active type ramp.
374#[composable]
375pub fn liquid_typography() -> LiquidTypography {
376    local_liquid_typography().current()
377}
378
379/// Provides the Liquid design system (colors, typography) to `content`.
380///
381/// `SchemeMode::Auto` follows the OS light/dark appearance live. The scheme
382/// also sets [`cranpose_ui::local_on_light_surface`], so the text selection
383/// menu over a light scheme draws dark labels.
384#[composable]
385pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
386    let options = cranpose_services::local_accessibility_options().current();
387    let dark = match spec.scheme {
388        SchemeMode::Auto => isSystemInDarkTheme(),
389        SchemeMode::Light => false,
390        SchemeMode::Dark => true,
391    } != options.invert_colors;
392    let colors = if dark {
393        LiquidColors::dark(spec.accent)
394    } else {
395        LiquidColors::light(spec.accent)
396    };
397    let colors = if options.increase_contrast {
398        colors.with_more_contrast()
399    } else {
400        colors
401    };
402    let typography = if options.bold_text {
403        spec.typography.clone().bolder()
404    } else {
405        spec.typography.clone()
406    };
407    CompositionLocalProvider(
408        [
409            local_liquid_colors().provides(colors),
410            local_liquid_typography().provides(typography),
411            local_liquid_glass_tint_amount().provides(spec.glass_tint_amount),
412            cranpose_ui::local_on_light_surface().provides(!dark),
413        ],
414        move || {
415            content();
416        },
417    );
418}
419
420#[cfg(test)]
421#[path = "tests/theme_tests.rs"]
422mod tests;
423
424#[cfg(test)]
425#[path = "tests/theme_option_tests.rs"]
426mod option_tests;