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,
23 Dark,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct LiquidColors {
30 pub is_dark: bool,
32 pub label: Color,
34 pub secondary_label: Color,
36 pub tertiary_label: Color,
38 pub separator: Color,
40 pub fill: Color,
42 pub secondary_fill: Color,
44 pub surface_glass: Color,
47 pub background: Color,
49 pub surface: Color,
51 pub surface_pressed: Color,
53 pub accent: Color,
55 pub on_accent: Color,
57 pub destructive: Color,
59 pub success: Color,
61 pub toggle_on: Color,
63 pub toggle_off: Color,
65 pub warning: Color,
67 pub glass_tint: Color,
69 pub glass_stroke: Color,
71}
72
73impl LiquidColors {
74 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 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
127impl LiquidColors {
130 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#[derive(Clone, Debug, PartialEq)]
152pub struct LiquidTypography {
153 pub large_title: TextStyle,
155 pub title1: TextStyle,
157 pub title2: TextStyle,
159 pub title3: TextStyle,
161 pub headline: TextStyle,
163 pub body: TextStyle,
165 pub callout: TextStyle,
167 pub subheadline: TextStyle,
169 pub footnote: TextStyle,
171 pub caption1: TextStyle,
173 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 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#[derive(Clone, Debug, PartialEq)]
247pub struct LiquidSegmentedStyle {
248 pub track: Color,
250 pub indicator: Color,
252 pub label: Color,
254 pub selected_label: Color,
258 pub text: TextStyle,
260 pub height: f32,
262}
263
264impl LiquidSegmentedStyle {
265 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#[derive(Clone, Debug, PartialEq)]
292pub struct LiquidThemeSpec {
293 pub scheme: SchemeMode,
295 pub accent: Color,
297 pub typography: LiquidTypography,
299 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#[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#[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#[composable]
366pub fn liquid_glass_tint_amount() -> GlassTintAmount {
367 local_liquid_glass_tint_amount().current()
368}
369
370#[composable]
372pub fn liquid_colors() -> LiquidColors {
373 local_liquid_colors().current()
374}
375
376#[composable]
378pub fn liquid_typography() -> LiquidTypography {
379 local_liquid_typography().current()
380}
381
382#[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;