1#![allow(non_snake_case)]
6
7use std::cell::RefCell;
8
9use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
10use cranpose_macros::composable;
11use cranpose_services::isSystemInDarkTheme;
12use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle, TextUnit};
13use cranpose_ui_graphics::Color;
14
15use crate::appearance::GlassTintAmount;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
19pub enum SchemeMode {
20 #[default]
22 Auto,
23 Light,
24 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 {
75 Self {
76 is_dark: false,
77 label: Color::from_rgb_u8(17, 17, 20),
78 secondary_label: Color::from_rgba_u8(60, 60, 67, 153),
79 tertiary_label: Color::from_rgba_u8(60, 60, 67, 76),
80 separator: Color::from_rgba_u8(60, 60, 67, 56),
81 fill: Color::from_rgba_u8(120, 120, 128, 40),
82 secondary_fill: Color::from_rgba_u8(120, 120, 128, 28),
83 surface_glass: Color::from_rgba_u8(255, 255, 255, 191),
84 background: Color::from_rgb_u8(242, 242, 247),
85 surface: Color::WHITE,
86 surface_pressed: Color::from_rgb_u8(226, 226, 231),
87 accent,
88 on_accent: Color::WHITE,
89 destructive: Color::from_rgb_u8(255, 59, 48),
90 success: Color::from_rgb_u8(52, 199, 89),
91 toggle_on: accent,
92 toggle_off: Color::from_rgb_u8(170, 170, 181),
93 warning: Color::from_rgb_u8(255, 149, 0),
94 glass_tint: Color::from_rgba_u8(255, 255, 255, 18),
95 glass_stroke: Color::from_rgba_u8(255, 255, 255, 120),
96 }
97 }
98
99 pub fn dark(accent: Color) -> Self {
100 Self {
101 is_dark: true,
102 label: Color::from_rgb_u8(242, 242, 247),
103 secondary_label: Color::from_rgba_u8(235, 235, 245, 153),
104 tertiary_label: Color::from_rgba_u8(235, 235, 245, 76),
105 separator: Color::from_rgba_u8(84, 84, 88, 130),
106 fill: Color::from_rgba_u8(120, 120, 128, 70),
107 secondary_fill: Color::from_rgba_u8(120, 120, 128, 50),
108 surface_glass: Color::from_rgba_u8(28, 28, 30, 184),
109 background: Color::from_rgb_u8(10, 10, 12),
110 surface: Color::from_rgb_u8(28, 28, 30),
111 surface_pressed: Color::from_rgb_u8(44, 44, 46),
112 accent,
113 on_accent: Color::WHITE,
114 destructive: Color::from_rgb_u8(255, 69, 58),
115 success: Color::from_rgb_u8(48, 209, 88),
116 toggle_on: accent,
117 toggle_off: Color::from_rgb_u8(99, 99, 102),
118 warning: Color::from_rgb_u8(255, 159, 10),
119 glass_tint: Color::from_rgba_u8(20, 20, 24, 40),
120 glass_stroke: Color::from_rgba_u8(255, 255, 255, 46),
121 }
122 }
123}
124
125impl LiquidColors {
128 pub fn with_more_contrast(mut self) -> Self {
132 self.secondary_label = self.secondary_label.with_alpha(0.9);
133 self.tertiary_label = self.tertiary_label.with_alpha(0.7);
134 self.separator = self.separator.with_alpha(0.6);
135 self.fill = self.fill.with_alpha(0.32);
136 self.secondary_fill = self.secondary_fill.with_alpha(0.24);
137 self.glass_stroke = self.glass_stroke.with_alpha(0.9);
138 self.toggle_off = if self.is_dark {
139 Color::from_rgb_u8(142, 142, 147)
140 } else {
141 Color::from_rgb_u8(99, 99, 108)
142 };
143 self
144 }
145}
146
147#[derive(Clone, Debug, PartialEq)]
148pub struct LiquidTypography {
149 pub large_title: TextStyle,
150 pub title1: TextStyle,
151 pub title2: TextStyle,
152 pub title3: TextStyle,
153 pub headline: TextStyle,
154 pub body: TextStyle,
155 pub callout: TextStyle,
156 pub subheadline: TextStyle,
157 pub footnote: TextStyle,
158 pub caption1: TextStyle,
159 pub caption2: TextStyle,
160}
161
162fn ramp_style(size_sp: f32, weight: FontWeight) -> TextStyle {
163 TextStyle {
164 span_style: SpanStyle {
165 font_size: TextUnit::Sp(size_sp),
166 font_weight: Some(weight),
167 ..Default::default()
168 },
169 ..Default::default()
170 }
171}
172
173impl LiquidTypography {
174 pub fn bolder(self) -> Self {
178 Self {
179 large_title: bolder(self.large_title),
180 title1: bolder(self.title1),
181 title2: bolder(self.title2),
182 title3: bolder(self.title3),
183 headline: bolder(self.headline),
184 body: bolder(self.body),
185 callout: bolder(self.callout),
186 subheadline: bolder(self.subheadline),
187 footnote: bolder(self.footnote),
188 caption1: bolder(self.caption1),
189 caption2: bolder(self.caption2),
190 }
191 }
192}
193
194fn bolder(mut style: TextStyle) -> TextStyle {
195 let weight = style.span_style.font_weight.unwrap_or(FontWeight::NORMAL);
196 style.span_style.font_weight = Some(FontWeight((weight.0 + 200).min(900)));
197 style
198}
199
200impl Default for LiquidTypography {
201 fn default() -> Self {
202 Self {
203 large_title: ramp_style(34.0, FontWeight::BOLD),
204 title1: ramp_style(28.0, FontWeight::BOLD),
205 title2: ramp_style(22.0, FontWeight::BOLD),
206 title3: ramp_style(20.0, FontWeight::SEMI_BOLD),
207 headline: ramp_style(17.0, FontWeight::SEMI_BOLD),
208 body: ramp_style(17.0, FontWeight::NORMAL),
209 callout: ramp_style(16.0, FontWeight::NORMAL),
210 subheadline: ramp_style(15.0, FontWeight::NORMAL),
211 footnote: ramp_style(13.0, FontWeight::NORMAL),
212 caption1: ramp_style(12.0, FontWeight::NORMAL),
213 caption2: ramp_style(11.0, FontWeight::NORMAL),
214 }
215 }
216}
217
218#[derive(Clone, Debug, PartialEq)]
220pub struct LiquidThemeSpec {
221 pub scheme: SchemeMode,
222 pub accent: Color,
224 pub typography: LiquidTypography,
225 pub glass_tint_amount: GlassTintAmount,
227}
228
229impl Default for LiquidThemeSpec {
230 fn default() -> Self {
231 Self {
232 scheme: SchemeMode::Auto,
233 accent: Color::from_rgb_u8(0, 122, 255),
234 typography: LiquidTypography::default(),
235 glass_tint_amount: GlassTintAmount::default(),
236 }
237 }
238}
239
240fn theme_local<T: Clone + PartialEq + 'static>(
241 cell: &RefCell<Option<CompositionLocal<T>>>,
242 default: fn() -> T,
243) -> CompositionLocal<T> {
244 cell.borrow_mut()
245 .get_or_insert_with(|| compositionLocalOf(default))
246 .clone()
247}
248
249fn local_liquid_colors() -> CompositionLocal<LiquidColors> {
250 thread_local! {
251 static LOCAL: RefCell<Option<CompositionLocal<LiquidColors>>> = const { RefCell::new(None) };
252 }
253 LOCAL.with(|cell| {
254 theme_local(cell, || {
255 LiquidColors::light(LiquidThemeSpec::default().accent)
256 })
257 })
258}
259
260fn local_liquid_typography() -> CompositionLocal<LiquidTypography> {
261 thread_local! {
262 static LOCAL: RefCell<Option<CompositionLocal<LiquidTypography>>> = const { RefCell::new(None) };
263 }
264 LOCAL.with(|cell| theme_local(cell, LiquidTypography::default))
265}
266
267fn local_liquid_glass_tint_amount() -> CompositionLocal<GlassTintAmount> {
268 thread_local! {
269 static LOCAL: RefCell<Option<CompositionLocal<GlassTintAmount>>> = const { RefCell::new(None) };
270 }
271 LOCAL.with(|cell| theme_local(cell, GlassTintAmount::default))
272}
273
274#[composable]
276pub fn liquid_glass_tint_amount() -> GlassTintAmount {
277 local_liquid_glass_tint_amount().current()
278}
279
280#[composable]
282pub fn liquid_colors() -> LiquidColors {
283 local_liquid_colors().current()
284}
285
286#[composable]
288pub fn liquid_typography() -> LiquidTypography {
289 local_liquid_typography().current()
290}
291
292#[composable]
296pub fn LiquidTheme(spec: LiquidThemeSpec, content: impl FnOnce()) {
297 let options = cranpose_services::local_accessibility_options().current();
298 let dark = match spec.scheme {
299 SchemeMode::Auto => isSystemInDarkTheme(),
300 SchemeMode::Light => false,
301 SchemeMode::Dark => true,
302 } != options.invert_colors;
303 let colors = if dark {
304 LiquidColors::dark(spec.accent)
305 } else {
306 LiquidColors::light(spec.accent)
307 };
308 let colors = if options.increase_contrast {
309 colors.with_more_contrast()
310 } else {
311 colors
312 };
313 let typography = if options.bold_text {
314 spec.typography.clone().bolder()
315 } else {
316 spec.typography.clone()
317 };
318 CompositionLocalProvider(
319 vec![
320 local_liquid_colors().provides(colors),
321 local_liquid_typography().provides(typography),
322 local_liquid_glass_tint_amount().provides(spec.glass_tint_amount),
323 ],
324 move || {
325 content();
326 },
327 );
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn palettes_differ_and_share_accent() {
336 let accent = Color::from_rgb_u8(0, 122, 255);
337 let light = LiquidColors::light(accent);
338 let dark = LiquidColors::dark(accent);
339 assert!(!light.is_dark);
340 assert!(dark.is_dark);
341 assert_eq!(light.accent, dark.accent);
342 assert_ne!(light.background, dark.background);
343 assert_ne!(light.glass_tint, dark.glass_tint);
344 assert_eq!(light.toggle_on, accent);
345 assert_eq!(dark.toggle_on, accent);
346 assert_ne!(light.toggle_off, dark.toggle_off);
347 }
348
349 #[test]
350 fn type_ramp_is_descending() {
351 let t = LiquidTypography::default();
352 let sizes = [
353 &t.large_title,
354 &t.title1,
355 &t.title2,
356 &t.title3,
357 &t.body,
358 &t.footnote,
359 &t.caption2,
360 ];
361 let values: Vec<f32> = sizes
362 .iter()
363 .map(|style| style.span_style.font_size.value())
364 .collect();
365 assert!(values.windows(2).all(|pair| pair[0] >= pair[1]));
366 }
367}
368
369#[cfg(test)]
370mod option_tests {
371 use super::*;
372
373 #[test]
374 fn more_contrast_lifts_the_quiet_colors_toward_the_label() {
375 let plain = LiquidColors::light(Color::from_rgb_u8(0, 122, 255));
376 let strong = plain.with_more_contrast();
377 assert!(strong.secondary_label.a() > plain.secondary_label.a());
378 assert!(strong.separator.a() > plain.separator.a());
379 assert_eq!(strong.label, plain.label);
380 assert_eq!(strong.accent, plain.accent);
381 }
382
383 #[test]
384 fn bold_text_adds_a_weight_step_and_stops_at_the_top() {
385 let ramp = LiquidTypography::default().bolder();
386 assert_eq!(
387 ramp.body.span_style.font_weight,
388 Some(FontWeight::SEMI_BOLD)
389 );
390 assert_eq!(
391 ramp.headline.span_style.font_weight,
392 Some(FontWeight::EXTRA_BOLD)
393 );
394 assert_eq!(
395 ramp.large_title.span_style.font_weight,
396 Some(FontWeight(900))
397 );
398 assert_eq!(ramp.body.span_style.font_size, TextUnit::Sp(17.0));
399 }
400}