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
295fn theme_local<T: Clone + PartialEq + 'static>(
296 cell: impl FnOnce(&crate::thread_state::LiquidThreadState) -> &OnceCell<CompositionLocal<T>>,
297 default: fn() -> T,
298) -> CompositionLocal<T> {
299 crate::thread_state::with(|locals| {
300 cell(locals)
301 .get_or_init(|| compositionLocalOf(default))
302 .clone()
303 })
304}
305
306fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
307 theme_local(
308 |locals| &locals.colors,
309 || LiquidColors::light(LiquidThemeSpec::default().accent),
310 )
311}
312
313fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
314 theme_local(|locals| &locals.typography, LiquidTypography::default)
315}
316
317fn local_liquid_glass_tint_amount() -> CompositionLocal<GlassTintAmount> {
318 theme_local(|locals| &locals.glass_tint, GlassTintAmount::default)
319}
320
321fn local_liquid_segmented_style() -> CompositionLocal<Option<LiquidSegmentedStyle>> {
322 theme_local(|locals| &locals.segmented, || None)
323}
324
325#[composable]
328pub fn liquid_segmented_style() -> LiquidSegmentedStyle {
329 local_liquid_segmented_style()
330 .current()
331 .unwrap_or_else(|| LiquidSegmentedStyle::system(&liquid_colors(), &liquid_typography()))
332}
333
334#[composable]
336pub fn ProvideLiquidSegmentedStyle(style: LiquidSegmentedStyle, content: impl FnOnce()) {
337 CompositionLocalProvider(
338 [local_liquid_segmented_style().provides(Some(style))],
339 move || {
340 content();
341 },
342 );
343}
344
345#[composable]
347pub fn liquid_glass_tint_amount() -> GlassTintAmount {
348 local_liquid_glass_tint_amount().current()
349}
350
351#[composable]
353pub fn liquid_colors() -> LiquidColors {
354 local_liquid_colors().current()
355}
356
357#[composable]
359pub fn liquid_typography() -> LiquidTypography {
360 local_liquid_typography().current()
361}
362
363#[composable]
369pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
370 let options = cranpose_services::local_accessibility_options().current();
371 let dark = match spec.scheme {
372 SchemeMode::Auto => isSystemInDarkTheme(),
373 SchemeMode::Light => false,
374 SchemeMode::Dark => true,
375 } != options.invert_colors;
376 let colors = if dark {
377 LiquidColors::dark(spec.accent)
378 } else {
379 LiquidColors::light(spec.accent)
380 };
381 let colors = if options.increase_contrast {
382 colors.with_more_contrast()
383 } else {
384 colors
385 };
386 let typography = if options.bold_text {
387 spec.typography.clone().bolder()
388 } else {
389 spec.typography.clone()
390 };
391 CompositionLocalProvider(
392 [
393 local_liquid_colors().provides(colors),
394 local_liquid_typography().provides(typography),
395 local_liquid_glass_tint_amount().provides(spec.glass_tint_amount),
396 cranpose_ui::local_on_light_surface().provides(!dark),
397 ],
398 move || {
399 content();
400 },
401 );
402}
403
404#[cfg(test)]
405#[path = "tests/theme_tests.rs"]
406mod tests;
407
408#[cfg(test)]
409#[path = "tests/theme_option_tests.rs"]
410mod option_tests;