cranpose-liquid 0.9.36

Glass controls, themes and motion for Cranpose apps
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Liquid theme: semantic colors, the iOS-style type ramp, and glass defaults,
//! provided to the subtree through composition locals — the analogue of
//! `MaterialTheme`.

use std::cell::OnceCell;

use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
use cranpose_macros::composable;
use cranpose_services::isSystemInDarkTheme;
use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle, TextUnit};
use cranpose_ui_graphics::Color;

use crate::appearance::GlassTintAmount;

/// Whether the theme follows the OS appearance or is pinned.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SchemeMode {
    /// Follow [`cranpose_services::isSystemInDarkTheme`] live.
    #[default]
    Auto,
    Light,
    Dark,
}

/// Semantic color palette mirroring the iOS system palette.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LiquidColors {
    /// True when this is the dark palette (drives glass exposure/tints).
    pub is_dark: bool,
    /// Primary text.
    pub label: Color,
    /// Secondary text (subtitles, captions).
    pub secondary_label: Color,
    /// Tertiary text (placeholders, disabled).
    pub tertiary_label: Color,
    /// Hairline separators.
    pub separator: Color,
    /// Filled control track (chips, search fields).
    pub fill: Color,
    /// Lighter fill for nested controls.
    pub secondary_fill: Color,
    /// Resting tint of a glass surface pane (cards, planks): the surface
    /// role at pane translucency, so the backdrop transmits through it.
    pub surface_glass: Color,
    /// Window background (grouped style).
    pub background: Color,
    /// Elevated surface (cards, list sections).
    pub surface: Color,
    /// Pressed surface wash.
    pub surface_pressed: Color,
    /// Accent (interactive) color.
    pub accent: Color,
    /// Content on accent fills.
    pub on_accent: Color,
    /// Destructive action color.
    pub destructive: Color,
    /// Success color.
    pub success: Color,
    /// Active switch track.
    pub toggle_on: Color,
    /// Inactive switch track.
    pub toggle_off: Color,
    /// Warning color.
    pub warning: Color,
    /// Tint mixed over glass materials.
    pub glass_tint: Color,
    /// Hairline stroke drawn around glass shapes.
    pub glass_stroke: Color,
}

impl LiquidColors {
    pub fn light(accent: Color) -> Self {
        Self {
            is_dark: false,
            label: Color::BLACK,
            secondary_label: Color::from_rgba_u8(60, 60, 67, 153),
            tertiary_label: Color::from_rgba_u8(60, 60, 67, 76),
            separator: Color::from_rgba_u8(60, 60, 60, 30),
            fill: Color::from_rgba_u8(120, 120, 128, 40),
            secondary_fill: Color::from_rgba_u8(120, 120, 128, 28),
            surface_glass: Color::from_rgba_u8(255, 255, 255, 191),
            background: Color::from_rgb_u8(242, 242, 247),
            surface: Color::WHITE,
            surface_pressed: Color::from_rgb_u8(226, 226, 231),
            accent,
            on_accent: Color::WHITE,
            destructive: Color::from_rgb_u8(255, 59, 48),
            success: Color::from_rgb_u8(52, 199, 89),
            toggle_on: accent,
            toggle_off: Color::from_rgba_u8(60, 60, 67, 76),
            warning: Color::from_rgb_u8(255, 149, 0),
            glass_tint: Color::from_rgba_u8(255, 255, 255, 18),
            glass_stroke: Color::from_rgba_u8(255, 255, 255, 120),
        }
    }

    pub fn dark(accent: Color) -> Self {
        Self {
            is_dark: true,
            label: Color::WHITE,
            secondary_label: Color::from_rgba_u8(235, 235, 245, 153),
            tertiary_label: Color::from_rgba_u8(235, 235, 245, 76),
            separator: Color::from_rgba_u8(84, 84, 88, 128),
            fill: Color::from_rgba_u8(120, 120, 128, 70),
            secondary_fill: Color::from_rgba_u8(120, 120, 128, 50),
            surface_glass: Color::from_rgba_u8(28, 28, 30, 184),
            background: Color::from_rgb_u8(10, 10, 12),
            surface: Color::from_rgb_u8(28, 28, 30),
            surface_pressed: Color::from_rgb_u8(44, 44, 46),
            accent,
            on_accent: Color::WHITE,
            destructive: Color::from_rgb_u8(255, 69, 58),
            success: Color::from_rgb_u8(48, 209, 88),
            toggle_on: accent,
            toggle_off: Color::from_rgba_u8(235, 235, 245, 76),
            warning: Color::from_rgb_u8(255, 159, 10),
            glass_tint: Color::from_rgba_u8(20, 20, 24, 40),
            glass_stroke: Color::from_rgba_u8(255, 255, 255, 46),
        }
    }
}

/// The iOS text-style ramp as ready-to-use [`TextStyle`]s (colors come from
/// [`LiquidColors::label`] by default at the call site).
impl LiquidColors {
    /// The palette for a person who asked the system for more contrast: the
    /// secondary and tertiary labels, the separator, the fills and the glass
    /// edge move toward the label color, and the off toggle darkens.
    pub fn with_more_contrast(mut self) -> Self {
        self.secondary_label = self.secondary_label.with_alpha(0.9);
        self.tertiary_label = self.tertiary_label.with_alpha(0.7);
        self.separator = self.separator.with_alpha(0.6);
        self.fill = self.fill.with_alpha(0.32);
        self.secondary_fill = self.secondary_fill.with_alpha(0.24);
        self.glass_stroke = self.glass_stroke.with_alpha(0.9);
        self.toggle_off = if self.is_dark {
            Color::from_rgb_u8(142, 142, 147)
        } else {
            Color::from_rgb_u8(99, 99, 108)
        };
        self
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct LiquidTypography {
    pub large_title: TextStyle,
    pub title1: TextStyle,
    pub title2: TextStyle,
    pub title3: TextStyle,
    pub headline: TextStyle,
    pub body: TextStyle,
    pub callout: TextStyle,
    pub subheadline: TextStyle,
    pub footnote: TextStyle,
    pub caption1: TextStyle,
    pub caption2: TextStyle,
}

fn ramp_style(size_sp: f32, weight: FontWeight) -> TextStyle {
    TextStyle {
        span_style: SpanStyle {
            font_size: TextUnit::Sp(size_sp),
            font_weight: Some(weight),
            ..Default::default()
        },
        ..Default::default()
    }
}

impl LiquidTypography {
    /// The ramp for a person who asked the system for bold text: every style
    /// gains two hundred of weight, so body text reads semi bold and titles
    /// extra bold.
    pub fn bolder(self) -> Self {
        Self {
            large_title: bolder(self.large_title),
            title1: bolder(self.title1),
            title2: bolder(self.title2),
            title3: bolder(self.title3),
            headline: bolder(self.headline),
            body: bolder(self.body),
            callout: bolder(self.callout),
            subheadline: bolder(self.subheadline),
            footnote: bolder(self.footnote),
            caption1: bolder(self.caption1),
            caption2: bolder(self.caption2),
        }
    }
}

fn bolder(mut style: TextStyle) -> TextStyle {
    let weight = style.span_style.font_weight.unwrap_or(FontWeight::NORMAL);
    style.span_style.font_weight = Some(FontWeight((weight.0 + 200).min(900)));
    style
}

impl Default for LiquidTypography {
    fn default() -> Self {
        Self {
            large_title: ramp_style(34.0, FontWeight::BOLD),
            title1: ramp_style(28.0, FontWeight::BOLD),
            title2: ramp_style(22.0, FontWeight::BOLD),
            title3: ramp_style(20.0, FontWeight::SEMI_BOLD),
            headline: ramp_style(17.0, FontWeight::SEMI_BOLD),
            body: ramp_style(17.0, FontWeight::NORMAL),
            callout: ramp_style(16.0, FontWeight::NORMAL),
            subheadline: ramp_style(15.0, FontWeight::NORMAL),
            footnote: ramp_style(13.0, FontWeight::NORMAL),
            caption1: ramp_style(12.0, FontWeight::NORMAL),
            caption2: ramp_style(11.0, FontWeight::NORMAL),
        }
    }
}

/// How a [`crate::LiquidSegmentedControl`] looks. [`Self::system`] is the
/// system control; an app gives its controls another look with
/// [`ProvideLiquidSegmentedStyle`].
///
/// ```rust,ignore
/// let colors = liquid_colors();
/// let style = LiquidSegmentedStyle {
///     indicator: colors.accent,
///     selected_label: colors.on_accent,
///     ..LiquidSegmentedStyle::system(&colors, &liquid_typography())
/// };
/// ProvideLiquidSegmentedStyle(style, || settings());
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct LiquidSegmentedStyle {
    /// The track under the segments.
    pub track: Color,
    /// The lens over the chosen segment at rest. A held lens clears to glass.
    pub indicator: Color,
    /// The labels of the segments.
    pub label: Color,
    /// The label of the chosen segment while the lens rests over it. While
    /// the lens is held, the label moves to [`Self::label`] as the indicator
    /// clears.
    pub selected_label: Color,
    /// The labels' text. The chosen segment's label is one weight heavier.
    pub text: TextStyle,
    /// The height of the control.
    pub height: f32,
}

impl LiquidSegmentedStyle {
    /// The system control: a white lens on a light track, or a gray lens on a
    /// dark one, with every label in the label color.
    pub fn system(colors: &LiquidColors, typography: &LiquidTypography) -> Self {
        let mut text = typography.subheadline.clone();
        text.span_style.font_size = TextUnit::Sp(13.0);
        text.span_style.font_weight = Some(FontWeight::NORMAL);
        let (track, indicator) = if colors.is_dark {
            (
                Color::from_rgb_u8(28, 28, 31),
                Color::from_rgb_u8(90, 90, 95),
            )
        } else {
            (Color::from_rgb_u8(238, 238, 239), Color::WHITE)
        };
        Self {
            track,
            indicator,
            label: colors.label,
            selected_label: colors.label,
            text,
            height: 32.0,
        }
    }
}

/// Theme configuration passed to [`LiquidTheme`].
#[derive(Clone, Debug, PartialEq)]
pub struct LiquidThemeSpec {
    pub scheme: SchemeMode,
    /// Accent color (iOS system blue by default).
    pub accent: Color,
    pub typography: LiquidTypography,
    /// Tint amount used by the floating navigation surfaces.
    pub glass_tint_amount: GlassTintAmount,
}

impl Default for LiquidThemeSpec {
    fn default() -> Self {
        Self {
            scheme: SchemeMode::Auto,
            accent: Color::from_rgb_u8(0, 122, 255),
            typography: LiquidTypography::default(),
            glass_tint_amount: GlassTintAmount::default(),
        }
    }
}

fn theme_local<T: Clone + PartialEq + 'static>(
    cell: impl FnOnce(&crate::thread_state::LiquidThreadState) -> &OnceCell<CompositionLocal<T>>,
    default: fn() -> T,
) -> CompositionLocal<T> {
    crate::thread_state::with(|locals| {
        cell(locals)
            .get_or_init(|| compositionLocalOf(default))
            .clone()
    })
}

fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
    theme_local(
        |locals| &locals.colors,
        || LiquidColors::light(LiquidThemeSpec::default().accent),
    )
}

fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
    theme_local(|locals| &locals.typography, LiquidTypography::default)
}

fn local_liquid_glass_tint_amount() -> CompositionLocal<GlassTintAmount> {
    theme_local(|locals| &locals.glass_tint, GlassTintAmount::default)
}

fn local_liquid_segmented_style() -> CompositionLocal<Option<LiquidSegmentedStyle>> {
    theme_local(|locals| &locals.segmented, || None)
}

/// The look of segmented controls here: the one provided by
/// [`ProvideLiquidSegmentedStyle`], or the system one.
#[composable]
pub fn liquid_segmented_style() -> LiquidSegmentedStyle {
    local_liquid_segmented_style()
        .current()
        .unwrap_or_else(|| LiquidSegmentedStyle::system(&liquid_colors(), &liquid_typography()))
}

/// Gives the segmented controls in `content` the look of `style`.
#[composable]
pub fn ProvideLiquidSegmentedStyle(style: LiquidSegmentedStyle, content: impl FnOnce()) {
    CompositionLocalProvider(
        [local_liquid_segmented_style().provides(Some(style))],
        move || {
            content();
        },
    );
}

/// The active navigation surface tint amount, defaulting to 25 percent.
#[composable]
pub fn liquid_glass_tint_amount() -> GlassTintAmount {
    local_liquid_glass_tint_amount().current()
}

/// The active semantic palette (light defaults outside a [`LiquidTheme`]).
#[composable]
pub fn liquid_colors() -> LiquidColors {
    local_liquid_colors().current()
}

/// The active type ramp.
#[composable]
pub fn liquid_typography() -> LiquidTypography {
    local_liquid_typography().current()
}

/// Provides the Liquid design system (colors, typography) to `content`.
///
/// `SchemeMode::Auto` follows the OS light/dark appearance live. The scheme
/// also sets [`cranpose_ui::local_on_light_surface`], so the text selection
/// menu over a light scheme draws dark labels.
#[composable]
pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
    let options = cranpose_services::local_accessibility_options().current();
    let dark = match spec.scheme {
        SchemeMode::Auto => isSystemInDarkTheme(),
        SchemeMode::Light => false,
        SchemeMode::Dark => true,
    } != options.invert_colors;
    let colors = if dark {
        LiquidColors::dark(spec.accent)
    } else {
        LiquidColors::light(spec.accent)
    };
    let colors = if options.increase_contrast {
        colors.with_more_contrast()
    } else {
        colors
    };
    let typography = if options.bold_text {
        spec.typography.clone().bolder()
    } else {
        spec.typography.clone()
    };
    CompositionLocalProvider(
        [
            local_liquid_colors().provides(colors),
            local_liquid_typography().provides(typography),
            local_liquid_glass_tint_amount().provides(spec.glass_tint_amount),
            cranpose_ui::local_on_light_surface().provides(!dark),
        ],
        move || {
            content();
        },
    );
}

#[cfg(test)]
#[path = "tests/theme_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "tests/theme_option_tests.rs"]
mod option_tests;