1use std::cell::RefCell;
10
11use oxframe_core::{Color, CubicBezier, Seconds, Vec2, presets};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Ink {
16 Background,
18 Surface,
20 Text,
22 Muted,
24 Accent,
26 OnAccent,
28 Success,
30 Warning,
32 Danger,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Step {
39 Xs,
41 Sm,
43 Md,
45 Lg,
47 Xl,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Pace {
54 Fast,
56 Normal,
58 Slow,
60}
61
62impl Seconds for Pace {
63 fn seconds(self) -> f64 {
64 let m = current().motion;
65 match self {
66 Pace::Fast => m.fast,
67 Pace::Normal => m.normal,
68 Pace::Slow => m.slow,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy)]
75pub struct TypeStyle {
76 pub size: f64,
78 pub weight: f64,
80 pub line_height: f64,
82}
83
84#[derive(Debug, Clone, Copy)]
86pub struct TypeScale {
87 pub display: TypeStyle,
89 pub h1: TypeStyle,
91 pub h2: TypeStyle,
93 pub body: TypeStyle,
95 pub caption: TypeStyle,
97 pub code: TypeStyle,
99}
100
101#[derive(Debug, Clone, Copy)]
103pub struct Scale {
104 pub xs: f64,
106 pub sm: f64,
108 pub md: f64,
110 pub lg: f64,
112 pub xl: f64,
114}
115
116impl Scale {
117 fn get(self, step: Step) -> f64 {
118 match step {
119 Step::Xs => self.xs,
120 Step::Sm => self.sm,
121 Step::Md => self.md,
122 Step::Lg => self.lg,
123 Step::Xl => self.xl,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy)]
130pub struct ShadowSpec {
131 pub color: Color,
133 pub blur: f64,
135 pub offset: Vec2,
137}
138
139#[derive(Debug, Clone, Copy)]
141pub struct Motion {
142 pub fast: f64,
144 pub normal: f64,
146 pub slow: f64,
148 pub enter: CubicBezier,
150 pub exit: CubicBezier,
152 pub emphasis: CubicBezier,
154}
155
156struct Raw {
157 background: Color,
158 surface: Color,
159 text: Color,
160 muted: Color,
161 accent: Color,
162 success: Color,
163 warning: Color,
164 danger: Color,
165 type_scale: TypeScale,
166 motion: Motion,
167}
168
169#[derive(Debug, Clone)]
171pub struct Theme {
172 pub background: Color,
174 pub surface: Color,
176 pub text: Color,
178 pub muted: Color,
180 pub accent: Color,
182 pub on_accent: Color,
184 pub success: Color,
186 pub warning: Color,
188 pub danger: Color,
190 pub type_scale: TypeScale,
192 pub space: Scale,
194 pub radius: Scale,
196 pub shadow_sm: ShadowSpec,
198 pub shadow_md: ShadowSpec,
200 pub shadow_lg: ShadowSpec,
202 pub motion: Motion,
204}
205
206impl Theme {
207 pub fn dark() -> Self {
209 Self::palette(Raw {
210 background: hex("#141414"),
211 surface: hex("#1c1e24"),
212 text: Color::WHITE,
213 muted: hex("#b7bcc6"),
214 accent: hex("#68abdf"),
215 success: hex("#3dcc7a"),
216 warning: hex("#f5c242"),
217 danger: hex("#e13238"),
218 type_scale: type_scale(1.0),
219 motion: motion(1.0),
220 })
221 }
222
223 pub fn light() -> Self {
225 Self::palette(Raw {
226 background: hex("#f6f4f1"),
227 surface: Color::WHITE,
228 text: hex("#1a1c1f"),
229 muted: hex("#555b66"),
230 accent: hex("#1d6aab"),
231 success: hex("#0e8a45"),
232 warning: hex("#8a5a00"),
233 danger: hex("#c4232c"),
234 type_scale: type_scale(1.0),
235 motion: motion(1.0),
236 })
237 }
238
239 pub fn midnight() -> Self {
241 Self::palette(Raw {
242 background: hex("#0b1020"),
243 surface: hex("#151b2e"),
244 text: hex("#e8eefc"),
245 muted: hex("#9aa6c2"),
246 accent: hex("#7ee0ff"),
247 success: hex("#3dcc7a"),
248 warning: hex("#f5c242"),
249 danger: hex("#ff6b6b"),
250 type_scale: type_scale(1.0),
251 motion: motion(1.05),
252 })
253 }
254
255 pub fn paper() -> Self {
257 Self::palette(Raw {
258 background: hex("#f3ead7"),
259 surface: hex("#fffaf1"),
260 text: hex("#2a241b"),
261 muted: hex("#6b5e4e"),
262 accent: hex("#b4532a"),
263 success: hex("#2f7d4a"),
264 warning: hex("#8a5a00"),
265 danger: hex("#a32020"),
266 type_scale: type_scale(0.96),
267 motion: motion(1.15),
268 })
269 }
270
271 pub fn vivid() -> Self {
273 Self::palette(Raw {
274 background: hex("#0e0e10"),
275 surface: hex("#18181c"),
276 text: Color::WHITE,
277 muted: hex("#c4c4cc"),
278 accent: hex("#ff4d6d"),
279 success: hex("#3dcc7a"),
280 warning: hex("#ffd000"),
281 danger: hex("#ff4d6d"),
282 type_scale: type_scale(1.08),
283 motion: Motion {
284 fast: 0.15,
285 normal: 0.35,
286 slow: 0.6,
287 ..motion(1.0)
288 },
289 })
290 }
291
292 pub fn preset(name: &str) -> Option<Self> {
294 Some(match name {
295 "dark" => Self::dark(),
296 "light" => Self::light(),
297 "midnight" => Self::midnight(),
298 "paper" => Self::paper(),
299 "vivid" => Self::vivid(),
300 _ => return None,
301 })
302 }
303
304 pub fn accent(mut self, color: Color) -> Self {
307 self.surface = self.background.mix(color, 0.16);
308 if self.is_light() {
309 self.surface = self.surface.mix(Color::WHITE, 0.35);
310 }
311 let fit = |c: Color| {
312 c.readable_on(self.background, 4.5)
313 .readable_on(self.surface, 4.5)
314 };
315 self.accent = fit(color);
316 self.on_accent = on_color(self.accent);
317 self.text = fit(self.text);
318 self.muted = fit(self.muted);
319 self.success = fit(self.success);
320 self.warning = fit(self.warning);
321 self.danger = fit(self.danger);
322 self
323 }
324
325 pub fn is_light(&self) -> bool {
327 self.background.luminance() > 0.4
328 }
329
330 pub fn ink(&self, ink: Ink) -> Color {
332 match ink {
333 Ink::Background => self.background,
334 Ink::Surface => self.surface,
335 Ink::Text => self.text,
336 Ink::Muted => self.muted,
337 Ink::Accent => self.accent,
338 Ink::OnAccent => self.on_accent,
339 Ink::Success => self.success,
340 Ink::Warning => self.warning,
341 Ink::Danger => self.danger,
342 }
343 }
344
345 pub fn space(&self, step: Step) -> f64 {
347 self.space.get(step)
348 }
349
350 pub fn radius(&self, step: Step) -> f64 {
352 self.radius.get(step)
353 }
354
355 pub fn shadow(&self, step: Step) -> ShadowSpec {
357 match step {
358 Step::Xs | Step::Sm => self.shadow_sm,
359 Step::Md => self.shadow_md,
360 Step::Lg | Step::Xl => self.shadow_lg,
361 }
362 }
363
364 pub fn role(&self, role: TypeRole) -> TypeStyle {
366 match role {
367 TypeRole::Display => self.type_scale.display,
368 TypeRole::H1 => self.type_scale.h1,
369 TypeRole::H2 => self.type_scale.h2,
370 TypeRole::Body => self.type_scale.body,
371 TypeRole::Caption => self.type_scale.caption,
372 TypeRole::Code => self.type_scale.code,
373 }
374 }
375
376 fn palette(raw: Raw) -> Self {
377 let Raw {
378 background,
379 surface,
380 text,
381 muted,
382 accent,
383 success,
384 warning,
385 danger,
386 type_scale,
387 motion,
388 } = raw;
389 let fit = |c: Color| c.readable_on(background, 4.5).readable_on(surface, 4.5);
390 let accent = fit(accent);
391 Self {
392 background,
393 surface,
394 text: fit(text),
395 muted: fit(muted),
396 accent,
397 on_accent: on_color(accent),
398 success: fit(success),
399 warning: fit(warning),
400 danger: fit(danger),
401 type_scale,
402 space: Scale {
403 xs: 8.0,
404 sm: 16.0,
405 md: 24.0,
406 lg: 40.0,
407 xl: 64.0,
408 },
409 radius: Scale {
410 xs: 6.0,
411 sm: 12.0,
412 md: 20.0,
413 lg: 32.0,
414 xl: 48.0,
415 },
416 shadow_sm: shadow(0.22, 12.0, 4.0),
417 shadow_md: shadow(0.32, 28.0, 12.0),
418 shadow_lg: shadow(0.42, 48.0, 24.0),
419 motion,
420 }
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum TypeRole {
427 Display,
429 H1,
431 H2,
433 Body,
435 Caption,
437 Code,
439}
440
441fn on_color(bg: Color) -> Color {
442 if Color::WHITE.contrast_ratio(bg) >= 4.5 {
443 Color::WHITE
444 } else {
445 Color::BLACK
446 }
447}
448
449fn hex(s: &str) -> Color {
450 Color::from_hex(s).expect("theme color")
451}
452
453fn shadow(alpha: f32, blur: f64, y: f64) -> ShadowSpec {
454 ShadowSpec {
455 color: Color::BLACK.with_alpha(alpha),
456 blur,
457 offset: Vec2::new(0.0, y),
458 }
459}
460
461fn style(size: f64, weight: f64, line_height: f64) -> TypeStyle {
462 TypeStyle {
463 size,
464 weight,
465 line_height,
466 }
467}
468
469fn type_scale(k: f64) -> TypeScale {
470 let s = |n: f64| (n * k).round();
471 TypeScale {
472 display: style(s(96.0), 700.0, 1.05),
473 h1: style(s(64.0), 700.0, 1.1),
474 h2: style(s(44.0), 600.0, 1.15),
475 body: style(s(32.0), 400.0, 1.35),
476 caption: style(s(22.0), 400.0, 1.3),
477 code: style(s(28.0), 450.0, 1.45),
478 }
479}
480
481fn motion(k: f64) -> Motion {
482 Motion {
483 fast: 0.2 * k,
484 normal: 0.45 * k,
485 slow: 0.8 * k,
486 enter: presets::OUT_CUBIC,
487 exit: presets::IN_CUBIC,
488 emphasis: presets::OUT_BACK,
489 }
490}
491
492thread_local! {
493 static CURRENT: RefCell<Theme> = RefCell::new(Theme::dark());
494}
495
496pub fn current() -> Theme {
498 CURRENT.with(|t| t.borrow().clone())
499}
500
501pub fn enter<R>(theme: &Theme, f: impl FnOnce() -> R) -> R {
503 let prev = CURRENT.with(|t| t.replace(theme.clone()));
504 struct Restore(Option<Theme>);
505 impl Drop for Restore {
506 fn drop(&mut self) {
507 if let Some(prev) = self.0.take() {
508 CURRENT.with(|t| *t.borrow_mut() = prev);
509 }
510 }
511 }
512 let _restore = Restore(Some(prev));
513 f()
514}
515
516pub trait IntoPaint {
518 fn into_paint(self) -> Color;
520}
521
522impl IntoPaint for Color {
523 fn into_paint(self) -> Color {
524 self
525 }
526}
527
528impl IntoPaint for Ink {
529 fn into_paint(self) -> Color {
530 current().ink(self)
531 }
532}
533
534impl From<Ink> for Color {
535 fn from(ink: Ink) -> Color {
536 ink.into_paint()
537 }
538}
539
540pub trait Len {
542 fn px(self) -> f64;
544}
545
546impl Len for f64 {
547 fn px(self) -> f64 {
548 self
549 }
550}
551
552impl Len for Step {
553 fn px(self) -> f64 {
554 current().space(self)
555 }
556}
557
558pub trait Corner {
560 fn radius(self) -> f64;
562}
563
564impl Corner for f64 {
565 fn radius(self) -> f64 {
566 self
567 }
568}
569
570impl Corner for Step {
571 fn radius(self) -> f64 {
572 current().radius(self)
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn inks(theme: &Theme) -> [Color; 6] {
581 [
582 theme.text,
583 theme.muted,
584 theme.accent,
585 theme.success,
586 theme.warning,
587 theme.danger,
588 ]
589 }
590
591 #[test]
592 fn every_preset_is_readable() {
593 for theme in [
594 Theme::dark(),
595 Theme::light(),
596 Theme::midnight(),
597 Theme::paper(),
598 Theme::vivid(),
599 Theme::dark().accent(hex("#224466")),
600 Theme::light().accent(hex("#ff88aa")),
601 ] {
602 for c in inks(&theme) {
603 assert!(
604 c.contrast_ratio(theme.background) >= 4.5,
605 "bg {:?} {:?}",
606 c,
607 theme.background
608 );
609 assert!(
610 c.contrast_ratio(theme.surface) >= 4.5,
611 "surface {:?} {:?}",
612 c,
613 theme.surface
614 );
615 }
616 assert!(theme.on_accent.contrast_ratio(theme.accent) >= 4.5);
617 }
618 }
619
620 #[test]
621 fn accent_keeps_contrast_and_tints_the_surface() {
622 let dark = Theme::dark();
623 let branded = Theme::dark().accent(hex("#224466"));
624 assert!(branded.accent.contrast_ratio(branded.background) >= 4.5);
625 assert_ne!(branded.surface.to_rgba8(), dark.surface.to_rgba8());
626 assert_eq!(branded.background.to_rgba8(), dark.background.to_rgba8());
627 }
628
629 #[test]
630 fn steps_resolve_on_different_scales() {
631 let theme = Theme::dark();
632 enter(&theme, || {
633 assert_eq!(Step::Md.px(), theme.space.md);
634 assert_eq!(Step::Md.radius(), theme.radius.md);
635 assert_ne!(Step::Md.px(), Step::Md.radius());
636 assert!((Pace::Normal.seconds() - theme.motion.normal).abs() < 1e-9);
637 });
638 }
639}