1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
17pub enum SchemeMode {
18 #[default]
20 Auto,
21 Light,
22 Dark,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct LiquidColors {
28 pub is_dark: bool,
30 pub label: Color,
32 pub secondary_label: Color,
34 pub tertiary_label: Color,
36 pub separator: Color,
38 pub fill: Color,
40 pub secondary_fill: Color,
42 pub surface_glass: Color,
45 pub background: Color,
47 pub surface: Color,
49 pub surface_pressed: Color,
51 pub accent: Color,
53 pub on_accent: Color,
55 pub destructive: Color,
57 pub success: Color,
59 pub toggle_on: Color,
61 pub toggle_off: Color,
63 pub warning: Color,
65 pub glass_tint: Color,
67 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
123impl LiquidColors {
126 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 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#[derive(Clone, Debug, PartialEq)]
230pub struct LiquidSegmentedStyle {
231 pub track: Color,
233 pub indicator: Color,
235 pub label: Color,
237 pub selected_label: Color,
241 pub text: TextStyle,
243 pub height: f32,
245}
246
247impl LiquidSegmentedStyle {
248 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#[derive(Clone, Debug, PartialEq)]
275pub struct LiquidThemeSpec {
276 pub scheme: SchemeMode,
277 pub accent: Color,
279 pub typography: LiquidTypography,
280 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#[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#[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#[composable]
363pub fn liquid_glass_tint_amount() -> GlassTintAmount {
364 local_liquid_glass_tint_amount().current()
365}
366
367#[composable]
369pub fn liquid_colors() -> LiquidColors {
370 local_liquid_colors().current()
371}
372
373#[composable]
375pub fn liquid_typography() -> LiquidTypography {
376 local_liquid_typography().current()
377}
378
379#[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;