1use std::borrow::Cow;
8use std::sync::OnceLock;
9
10use iced::font::Weight;
11use iced::theme::Palette;
12use iced::theme::palette::{Extended, Pair, Secondary, deviate};
13use iced::{Color, Font, Theme, color};
14
15static BASE_FONT: OnceLock<Font> = OnceLock::new();
16
17pub fn set_font(font: Font) -> bool {
24 BASE_FONT.set(font).is_ok()
25}
26
27pub fn font() -> Font {
29 BASE_FONT.get().copied().unwrap_or(Font::DEFAULT)
30}
31
32pub fn semibold() -> Font {
34 Font {
35 weight: Weight::Semibold,
36 ..font()
37 }
38}
39
40pub mod radius {
42 pub const SM: f32 = 4.0;
43 pub const MD: f32 = 6.0;
44 pub const LG: f32 = 8.0;
45 pub const FULL: f32 = 9999.0;
46}
47
48pub mod space {
50 pub const XS: f32 = 4.0;
51 pub const SM: f32 = 8.0;
52 pub const MD: f32 = 12.0;
53 pub const LG: f32 = 16.0;
54 pub const XL: f32 = 24.0;
55}
56
57pub mod text_size {
59 pub const XS: f32 = 12.0;
60 pub const SM: f32 = 14.0;
61 pub const MD: f32 = 16.0;
62 pub const LG: f32 = 18.0;
63}
64
65const POPOVER_LIFT: f32 = 0.06;
68
69#[derive(Debug, Clone, Copy, PartialEq)]
71pub struct Tokens {
72 pub background: Color,
73 pub foreground: Color,
74 pub popover: Color,
78 pub muted: Color,
79 pub muted_foreground: Color,
80 pub accent: Color,
81 pub accent_foreground: Color,
82 pub border: Color,
83 pub ring: Color,
84 pub primary: Color,
85 pub primary_foreground: Color,
86 pub secondary: Color,
87 pub secondary_foreground: Color,
88 pub destructive: Color,
89 pub destructive_foreground: Color,
90 pub success: Color,
91 pub warning: Color,
92 pub is_dark: bool,
93}
94
95impl Tokens {
96 pub fn of(theme: &Theme) -> Self {
98 let ext = theme.extended_palette();
99 let background = ext.background.base.color;
100 let foreground = ext.background.base.text;
101
102 let popover = if ext.is_dark {
103 mix(background, foreground, POPOVER_LIFT)
104 } else {
105 background
106 };
107
108 Self {
109 background,
110 foreground,
111 popover,
112 muted: ext.background.weak.color,
113 muted_foreground: mix(foreground, background, 0.4),
114 accent: ext.secondary.weak.color,
115 accent_foreground: on(ext.secondary.weak.color),
116 border: ext.background.strong.color,
117 ring: Color {
118 a: 0.5,
119 ..ext.primary.base.color
120 },
121 primary: ext.primary.base.color,
122 primary_foreground: on(ext.primary.base.color),
123 secondary: ext.secondary.base.color,
124 secondary_foreground: on(ext.secondary.base.color),
125 destructive: ext.danger.base.color,
126 destructive_foreground: on(ext.danger.base.color),
127 success: ext.success.base.color,
128 warning: ext.warning.base.color,
129 is_dark: ext.is_dark,
130 }
131 }
132
133 pub fn disabled_field(&self) -> Color {
135 mix(self.background, self.muted, 0.5)
136 }
137}
138
139pub fn light() -> Theme {
141 Config::light().build()
142}
143
144pub fn dark() -> Theme {
146 Config::dark().build()
147}
148
149#[derive(Debug, Clone, PartialEq)]
166pub struct Config {
167 name: Cow<'static, str>,
168 background: Color,
169 foreground: Color,
170 primary: Color,
171 secondary: Option<Color>,
172 accent: Option<Color>,
173 destructive: Color,
174 success: Color,
175 warning: Color,
176 border: Option<Color>,
177}
178
179impl Config {
180 pub fn light() -> Self {
182 Self {
183 name: Cow::Borrowed("Cube Light"),
184 background: color!(0xffffff),
185 foreground: color!(0x09090b),
186 primary: color!(0x18181b),
187 secondary: None,
188 accent: None,
189 destructive: color!(0xdc2626),
190 success: color!(0x16a34a),
191 warning: color!(0xd97706),
192 border: None,
193 }
194 }
195
196 pub fn dark() -> Self {
198 Self {
199 name: Cow::Borrowed("Cube Dark"),
200 background: color!(0x09090b),
201 foreground: color!(0xfafafa),
202 primary: color!(0xfafafa),
203 secondary: None,
204 accent: None,
205 destructive: color!(0xdc2626),
206 success: color!(0x22c55e),
207 warning: color!(0xf59e0b),
208 border: None,
209 }
210 }
211
212 pub fn name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
214 self.name = name.into();
215 self
216 }
217
218 pub fn primary(mut self, colour: Color) -> Self {
220 self.primary = colour;
221 self
222 }
223
224 pub fn secondary(mut self, colour: Color) -> Self {
226 self.secondary = Some(colour);
227 self
228 }
229
230 pub fn accent(mut self, colour: Color) -> Self {
233 self.accent = Some(colour);
234 self
235 }
236
237 pub fn destructive(mut self, colour: Color) -> Self {
239 self.destructive = colour;
240 self
241 }
242
243 pub fn success(mut self, colour: Color) -> Self {
244 self.success = colour;
245 self
246 }
247
248 pub fn warning(mut self, colour: Color) -> Self {
249 self.warning = colour;
250 self
251 }
252
253 pub fn background(mut self, colour: Color) -> Self {
256 self.background = colour;
257 self
258 }
259
260 pub fn foreground(mut self, colour: Color) -> Self {
262 self.foreground = colour;
263 self
264 }
265
266 pub fn border(mut self, colour: Color) -> Self {
269 self.border = Some(colour);
270 self
271 }
272
273 pub fn palette(&self) -> Palette {
275 Palette {
276 background: self.background,
277 text: self.foreground,
278 primary: self.primary,
279 success: self.success,
280 warning: self.warning,
281 danger: self.destructive,
282 }
283 }
284
285 pub fn extended(&self) -> Extended {
289 let mut extended = Extended::generate(self.palette());
290 let shade = extended.background.weak.color;
291 let secondary = self.secondary.unwrap_or(shade);
292 let accent = self.accent.unwrap_or(shade);
293 let strong = deviate(secondary, 0.1);
294
295 extended.secondary = Secondary {
296 base: Pair::new(secondary, on(secondary)),
297 weak: Pair::new(accent, on(accent)),
298 strong: Pair::new(strong, on(strong)),
299 };
300 if let Some(border) = self.border {
301 extended.background.strong = Pair::new(border, self.foreground);
302 }
303 extended
304 }
305
306 pub fn build(&self) -> Theme {
308 let extended = self.extended();
309 Theme::custom_with_fn(self.name.clone(), self.palette(), move |_| extended)
310 }
311}
312
313pub fn mix(a: Color, b: Color, t: f32) -> Color {
315 let t = t.clamp(0.0, 1.0);
316 Color {
317 r: a.r + (b.r - a.r) * t,
318 g: a.g + (b.g - a.g) * t,
319 b: a.b + (b.b - a.b) * t,
320 a: a.a + (b.a - a.a) * t,
321 }
322}
323
324pub fn on(background: Color) -> Color {
326 let luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
327 if luminance > 0.55 {
328 color!(0x09090b)
329 } else {
330 color!(0xfafafa)
331 }
332}
333
334pub fn fade(color: Color, alpha: f32) -> Color {
336 Color {
337 a: color.a * alpha.clamp(0.0, 1.0),
338 ..color
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn semibold_keeps_the_base_family() {
348 let font = semibold();
349 assert_eq!(font.weight, Weight::Semibold);
350 assert_eq!(font.family, super::font().family);
351 }
352
353 #[test]
354 fn mix_endpoints_return_inputs() {
355 let a = Color::from_rgb(0.0, 0.2, 0.4);
356 let b = Color::from_rgb(1.0, 0.8, 0.6);
357 assert_eq!(mix(a, b, 0.0), a);
358 assert_eq!(mix(a, b, 1.0), b);
359 }
360
361 #[test]
362 fn mix_clamps_out_of_range_factor() {
363 let a = Color::BLACK;
364 let b = Color::WHITE;
365 assert_eq!(mix(a, b, -1.0), a);
366 assert_eq!(mix(a, b, 2.0), b);
367 }
368
369 #[test]
370 fn fade_scales_alpha_only() {
371 let faded = fade(Color::from_rgba(0.1, 0.2, 0.3, 0.8), 0.5);
372 assert_eq!((faded.r, faded.g, faded.b), (0.1, 0.2, 0.3));
373 assert!((faded.a - 0.4).abs() < f32::EPSILON);
374 }
375
376 #[test]
377 fn on_picks_dark_text_for_light_backgrounds() {
378 assert_eq!(on(Color::WHITE), color!(0x09090b));
379 assert_eq!(on(Color::BLACK), color!(0xfafafa));
380 assert_eq!(on(color!(0xdc2626)), color!(0xfafafa));
381 }
382
383 #[test]
384 fn popover_is_the_background_in_light_and_lifted_in_dark() {
385 let light = Tokens::of(&light());
386 assert_eq!(light.popover, light.background);
387
388 let dark = Tokens::of(&dark());
389 let card = mix(dark.background, dark.foreground, 0.04);
390 let brightness = |colour: Color| colour.r + colour.g + colour.b;
391 assert!(brightness(dark.popover) > brightness(card));
392 assert!(brightness(dark.popover) < brightness(dark.foreground));
393 }
394
395 #[test]
396 fn light_and_dark_report_their_mode() {
397 assert!(!Tokens::of(&light()).is_dark);
398 assert!(Tokens::of(&dark()).is_dark);
399 }
400
401 #[test]
402 fn primary_foreground_contrasts_with_primary() {
403 for theme in [light(), dark()] {
404 let tokens = Tokens::of(&theme);
405 assert_ne!(tokens.primary, tokens.primary_foreground);
406 assert_ne!(tokens.background, tokens.foreground);
407 }
408 }
409
410 #[test]
411 fn defaults_match_the_original_palettes() {
412 let light = Tokens::of(&light());
413 assert_eq!(light.background, color!(0xffffff));
414 assert_eq!(light.foreground, color!(0x09090b));
415 assert_eq!(light.primary, color!(0x18181b));
416 assert_eq!(light.destructive, color!(0xdc2626));
417 assert_eq!(light.secondary, light.muted);
418 assert_eq!(light.accent, light.muted);
419 assert_eq!(light.secondary_foreground, light.foreground);
420 assert_eq!(light.accent_foreground, light.foreground);
421
422 let dark = Tokens::of(&dark());
423 assert_eq!(dark.background, color!(0x09090b));
424 assert_eq!(dark.primary, color!(0xfafafa));
425 assert_eq!(dark.success, color!(0x22c55e));
426 assert_eq!(dark.secondary, dark.muted);
427 assert_eq!(dark.secondary_foreground, dark.foreground);
428 assert_eq!(dark.accent_foreground, dark.foreground);
429 }
430
431 #[test]
432 fn defaults_keep_the_generated_border() {
433 for config in [Config::light(), Config::dark()] {
434 let generated = Extended::generate(config.palette());
435 let tokens = Tokens::of(&config.build());
436 assert_eq!(tokens.border, generated.background.strong.color);
437 assert_eq!(tokens.muted, generated.background.weak.color);
438 }
439 }
440
441 #[test]
442 fn config_colours_reach_tokens() {
443 let theme = Config::light()
444 .name("Brand")
445 .primary(color!(0x4f46e5))
446 .secondary(color!(0xe0e7ff))
447 .accent(color!(0xfef3c7))
448 .destructive(color!(0xbe123c))
449 .success(color!(0x059669))
450 .warning(color!(0xca8a04))
451 .background(color!(0xfafaf9))
452 .foreground(color!(0x1c1917))
453 .border(color!(0xd6d3d1))
454 .build();
455 let tokens = Tokens::of(&theme);
456
457 assert_eq!(theme.to_string(), "Brand");
458 assert_eq!(tokens.primary, color!(0x4f46e5));
459 assert_eq!(tokens.primary_foreground, on(color!(0x4f46e5)));
460 assert_eq!(tokens.secondary, color!(0xe0e7ff));
461 assert_eq!(tokens.secondary_foreground, on(color!(0xe0e7ff)));
462 assert_eq!(tokens.accent, color!(0xfef3c7));
463 assert_eq!(tokens.destructive, color!(0xbe123c));
464 assert_eq!(tokens.success, color!(0x059669));
465 assert_eq!(tokens.warning, color!(0xca8a04));
466 assert_eq!(tokens.background, color!(0xfafaf9));
467 assert_eq!(tokens.foreground, color!(0x1c1917));
468 assert_eq!(tokens.border, color!(0xd6d3d1));
469 }
470
471 #[test]
472 fn unset_surfaces_follow_a_new_background() {
473 let background = color!(0xf5f5f4);
474 let tokens = Tokens::of(&Config::light().background(background).build());
475 let shade = Extended::generate(Palette {
476 background,
477 ..Config::light().palette()
478 })
479 .background
480 .weak
481 .color;
482 assert_eq!(tokens.secondary, shade);
483 assert_eq!(tokens.accent, shade);
484 }
485
486 #[test]
487 fn dark_mode_follows_the_background() {
488 assert!(!Tokens::of(&Config::light().build()).is_dark);
489 assert!(Tokens::of(&Config::dark().build()).is_dark);
490 let night = Config::light()
491 .background(color!(0x0f172a))
492 .foreground(color!(0xf8fafc))
493 .build();
494 assert!(Tokens::of(&night).is_dark);
495 let paper = Config::dark()
496 .background(color!(0xfffbeb))
497 .foreground(color!(0x292524))
498 .build();
499 assert!(!Tokens::of(&paper).is_dark);
500 }
501
502 #[test]
503 fn tokens_resolve_for_builtin_iced_themes() {
504 for theme in Theme::ALL {
505 let tokens = Tokens::of(theme);
506 assert_ne!(tokens.background, tokens.foreground, "{theme}");
507 }
508 }
509}