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