1use super::palette::Color;
6use std::fmt;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum FontFamily {
11 #[default]
13 Roboto,
14 RobotoMono,
16 SansSerif,
18 Monospace,
20 SegoeUI,
22 CascadiaCode,
24}
25
26impl FontFamily {
27 #[allow(clippy::wrong_self_convention)]
29 pub fn to_css(&self) -> &'static str {
30 match self {
31 Self::Roboto => "Roboto, sans-serif",
32 Self::RobotoMono => "'Roboto Mono', monospace",
33 Self::SansSerif => "system-ui, -apple-system, sans-serif",
34 Self::Monospace => "ui-monospace, 'Cascadia Code', monospace",
35 Self::SegoeUI => "'Segoe UI', 'Helvetica Neue', sans-serif",
36 Self::CascadiaCode => "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
37 }
38 }
39}
40
41impl fmt::Display for FontFamily {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 write!(f, "{}", self.to_css())
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum FontWeight {
50 Thin,
52 Light,
54 #[default]
56 Regular,
57 Medium,
59 SemiBold,
61 Bold,
63 Black,
65}
66
67impl FontWeight {
68 pub fn value(&self) -> u16 {
70 match self {
71 Self::Thin => 100,
72 Self::Light => 300,
73 Self::Regular => 400,
74 Self::Medium => 500,
75 Self::SemiBold => 600,
76 Self::Bold => 700,
77 Self::Black => 900,
78 }
79 }
80}
81
82impl fmt::Display for FontWeight {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(f, "{}", self.value())
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum TextAlign {
91 #[default]
92 Start,
93 Middle,
94 End,
95}
96
97impl TextAlign {
98 pub fn as_svg_anchor(self) -> &'static str {
100 match self {
101 Self::Start => "start",
102 Self::Middle => "middle",
103 Self::End => "end",
104 }
105 }
106}
107
108impl fmt::Display for TextAlign {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 write!(f, "{}", self.as_svg_anchor())
111 }
112}
113
114#[derive(Debug, Clone)]
116pub struct TextStyle {
117 pub family: FontFamily,
119 pub size: f32,
121 pub weight: FontWeight,
123 pub line_height: f32,
125 pub letter_spacing: f32,
127 pub color: Color,
129 pub align: TextAlign,
131}
132
133impl TextStyle {
134 pub fn new(size: f32, weight: FontWeight) -> Self {
136 Self {
137 family: FontFamily::default(),
138 size,
139 weight,
140 line_height: 1.5,
141 letter_spacing: 0.0,
142 color: Color::rgb(0, 0, 0),
143 align: TextAlign::default(),
144 }
145 }
146
147 pub fn with_family(mut self, family: FontFamily) -> Self {
149 self.family = family;
150 self
151 }
152
153 pub fn with_line_height(mut self, height: f32) -> Self {
155 self.line_height = height;
156 self
157 }
158
159 pub fn with_letter_spacing(mut self, spacing: f32) -> Self {
161 self.letter_spacing = spacing;
162 self
163 }
164
165 pub fn with_color(mut self, color: Color) -> Self {
167 self.color = color;
168 self
169 }
170
171 pub fn with_align(mut self, align: TextAlign) -> Self {
173 self.align = align;
174 self
175 }
176
177 pub fn to_svg_attrs(&self) -> String {
179 let mut attrs = format!(
180 "font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\" fill=\"{}\"",
181 self.family,
182 self.size,
183 self.weight,
184 self.color.to_css_hex()
185 );
186
187 if self.letter_spacing != 0.0 {
188 attrs.push_str(&format!(" letter-spacing=\"{}em\"", self.letter_spacing));
189 }
190
191 if self.align != TextAlign::Start {
192 attrs.push_str(&format!(" text-anchor=\"{}\"", self.align));
193 }
194
195 attrs
196 }
197}
198
199impl Default for TextStyle {
200 fn default() -> Self {
201 Self::new(14.0, FontWeight::Regular)
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct MaterialTypography {
208 pub display_large: TextStyle,
210 pub display_medium: TextStyle,
212 pub display_small: TextStyle,
214
215 pub headline_large: TextStyle,
217 pub headline_medium: TextStyle,
219 pub headline_small: TextStyle,
221
222 pub title_large: TextStyle,
224 pub title_medium: TextStyle,
226 pub title_small: TextStyle,
228
229 pub body_large: TextStyle,
231 pub body_medium: TextStyle,
233 pub body_small: TextStyle,
235
236 pub label_large: TextStyle,
238 pub label_medium: TextStyle,
240 pub label_small: TextStyle,
242
243 pub code: TextStyle,
245}
246
247impl MaterialTypography {
248 pub fn with_color(color: Color) -> Self {
250 Self {
251 display_large: TextStyle::new(57.0, FontWeight::Regular)
253 .with_line_height(1.12)
254 .with_letter_spacing(-0.014)
255 .with_color(color),
256 display_medium: TextStyle::new(45.0, FontWeight::Regular)
257 .with_line_height(1.16)
258 .with_color(color),
259 display_small: TextStyle::new(36.0, FontWeight::Regular)
260 .with_line_height(1.22)
261 .with_color(color),
262
263 headline_large: TextStyle::new(32.0, FontWeight::Regular)
265 .with_line_height(1.25)
266 .with_color(color),
267 headline_medium: TextStyle::new(28.0, FontWeight::Regular)
268 .with_line_height(1.29)
269 .with_color(color),
270 headline_small: TextStyle::new(24.0, FontWeight::Regular)
271 .with_line_height(1.33)
272 .with_color(color),
273
274 title_large: TextStyle::new(22.0, FontWeight::Regular)
276 .with_line_height(1.27)
277 .with_color(color),
278 title_medium: TextStyle::new(16.0, FontWeight::Medium)
279 .with_line_height(1.5)
280 .with_letter_spacing(0.009)
281 .with_color(color),
282 title_small: TextStyle::new(14.0, FontWeight::Medium)
283 .with_line_height(1.43)
284 .with_letter_spacing(0.007)
285 .with_color(color),
286
287 body_large: TextStyle::new(16.0, FontWeight::Regular)
289 .with_line_height(1.5)
290 .with_letter_spacing(0.031)
291 .with_color(color),
292 body_medium: TextStyle::new(14.0, FontWeight::Regular)
293 .with_line_height(1.43)
294 .with_letter_spacing(0.018)
295 .with_color(color),
296 body_small: TextStyle::new(12.0, FontWeight::Regular)
297 .with_line_height(1.33)
298 .with_letter_spacing(0.033)
299 .with_color(color),
300
301 label_large: TextStyle::new(14.0, FontWeight::Medium)
303 .with_line_height(1.43)
304 .with_letter_spacing(0.007)
305 .with_color(color),
306 label_medium: TextStyle::new(12.0, FontWeight::Medium)
307 .with_line_height(1.33)
308 .with_letter_spacing(0.042)
309 .with_color(color),
310 label_small: TextStyle::new(11.0, FontWeight::Medium)
311 .with_line_height(1.45)
312 .with_letter_spacing(0.045)
313 .with_color(color),
314
315 code: TextStyle::new(14.0, FontWeight::Regular)
317 .with_family(FontFamily::RobotoMono)
318 .with_line_height(1.5)
319 .with_color(color),
320 }
321 }
322}
323
324impl Default for MaterialTypography {
325 fn default() -> Self {
326 Self::with_color(Color::rgb(28, 27, 31))
327 }
328}
329
330#[derive(Debug, Clone)]
335pub struct VideoTypography {
336 pub slide_title: TextStyle,
338 pub section_header: TextStyle,
340 pub body: TextStyle,
342 pub label: TextStyle,
344 pub code: TextStyle,
346 pub icon_text: TextStyle,
348}
349
350impl VideoTypography {
351 pub const MIN_FONT_SIZE: f32 = 18.0;
353
354 pub fn dark() -> Self {
356 let heading = Color::rgb(241, 245, 249); let body_color = Color::rgb(148, 163, 184); let accent = Color::rgb(96, 165, 250); Self {
361 slide_title: TextStyle::new(56.0, FontWeight::Bold)
362 .with_family(FontFamily::SegoeUI)
363 .with_color(heading)
364 .with_line_height(1.15),
365 section_header: TextStyle::new(36.0, FontWeight::SemiBold)
366 .with_family(FontFamily::SegoeUI)
367 .with_color(heading)
368 .with_line_height(1.2),
369 body: TextStyle::new(24.0, FontWeight::Regular)
370 .with_family(FontFamily::SegoeUI)
371 .with_color(body_color)
372 .with_line_height(1.4),
373 label: TextStyle::new(18.0, FontWeight::Regular)
374 .with_family(FontFamily::SegoeUI)
375 .with_color(body_color)
376 .with_line_height(1.4),
377 code: TextStyle::new(22.0, FontWeight::Regular)
378 .with_family(FontFamily::CascadiaCode)
379 .with_color(accent)
380 .with_line_height(1.5),
381 icon_text: TextStyle::new(18.0, FontWeight::Bold)
382 .with_family(FontFamily::SegoeUI)
383 .with_color(heading)
384 .with_line_height(1.4),
385 }
386 }
387
388 pub fn light() -> Self {
390 let heading = Color::rgb(15, 23, 42); let body_color = Color::rgb(71, 85, 105); let accent = Color::rgb(37, 99, 235); Self {
395 slide_title: TextStyle::new(56.0, FontWeight::Bold)
396 .with_family(FontFamily::SegoeUI)
397 .with_color(heading)
398 .with_line_height(1.15),
399 section_header: TextStyle::new(36.0, FontWeight::SemiBold)
400 .with_family(FontFamily::SegoeUI)
401 .with_color(heading)
402 .with_line_height(1.2),
403 body: TextStyle::new(24.0, FontWeight::Regular)
404 .with_family(FontFamily::SegoeUI)
405 .with_color(body_color)
406 .with_line_height(1.4),
407 label: TextStyle::new(18.0, FontWeight::Regular)
408 .with_family(FontFamily::SegoeUI)
409 .with_color(body_color)
410 .with_line_height(1.4),
411 code: TextStyle::new(22.0, FontWeight::Regular)
412 .with_family(FontFamily::CascadiaCode)
413 .with_color(accent)
414 .with_line_height(1.5),
415 icon_text: TextStyle::new(18.0, FontWeight::Bold)
416 .with_family(FontFamily::SegoeUI)
417 .with_color(heading)
418 .with_line_height(1.4),
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn test_font_family_css() {
429 assert_eq!(FontFamily::Roboto.to_css(), "Roboto, sans-serif");
430 assert_eq!(FontFamily::RobotoMono.to_css(), "'Roboto Mono', monospace");
431 assert_eq!(FontFamily::SegoeUI.to_css(), "'Segoe UI', 'Helvetica Neue', sans-serif");
432 assert_eq!(
433 FontFamily::CascadiaCode.to_css(),
434 "'Cascadia Code', 'Fira Code', 'Consolas', monospace"
435 );
436 }
437
438 #[test]
439 fn test_font_weight_value() {
440 assert_eq!(FontWeight::Regular.value(), 400);
441 assert_eq!(FontWeight::Bold.value(), 700);
442 }
443
444 #[test]
445 fn test_text_align_svg() {
446 assert_eq!(TextAlign::Start.as_svg_anchor(), "start");
447 assert_eq!(TextAlign::Middle.as_svg_anchor(), "middle");
448 assert_eq!(TextAlign::End.as_svg_anchor(), "end");
449 }
450
451 #[test]
452 fn test_text_style_creation() {
453 let style = TextStyle::new(16.0, FontWeight::Bold);
454 assert_eq!(style.size, 16.0);
455 assert_eq!(style.weight, FontWeight::Bold);
456 }
457
458 #[test]
459 fn test_text_style_builder() {
460 let style = TextStyle::new(14.0, FontWeight::Regular)
461 .with_family(FontFamily::RobotoMono)
462 .with_color(Color::rgb(255, 0, 0))
463 .with_align(TextAlign::Middle);
464
465 assert_eq!(style.family, FontFamily::RobotoMono);
466 assert_eq!(style.color, Color::rgb(255, 0, 0));
467 assert_eq!(style.align, TextAlign::Middle);
468 }
469
470 #[test]
471 fn test_text_style_to_svg_attrs() {
472 let style = TextStyle::new(16.0, FontWeight::Bold).with_color(Color::rgb(0, 0, 0));
473
474 let attrs = style.to_svg_attrs();
475 assert!(attrs.contains("font-size=\"16\""));
476 assert!(attrs.contains("font-weight=\"700\""));
477 assert!(attrs.contains("fill=\"#000000\""));
478 }
479
480 #[test]
481 fn test_material_typography_scale() {
482 let typo = MaterialTypography::default();
483
484 assert_eq!(typo.display_large.size, 57.0);
485 assert_eq!(typo.headline_large.size, 32.0);
486 assert_eq!(typo.body_medium.size, 14.0);
487 assert_eq!(typo.label_small.size, 11.0);
488 assert_eq!(typo.code.family, FontFamily::RobotoMono);
489 }
490
491 #[test]
492 fn test_material_typography_with_color() {
493 let color = Color::rgb(255, 255, 255);
494 let typo = MaterialTypography::with_color(color);
495
496 assert_eq!(typo.body_medium.color, color);
497 assert_eq!(typo.headline_large.color, color);
498 }
499
500 #[test]
501 fn test_font_family_display() {
502 assert_eq!(format!("{}", FontFamily::Roboto), "Roboto, sans-serif");
503 assert_eq!(format!("{}", FontFamily::SansSerif), "system-ui, -apple-system, sans-serif");
504 assert_eq!(
505 format!("{}", FontFamily::Monospace),
506 "ui-monospace, 'Cascadia Code', monospace"
507 );
508 }
509
510 #[test]
511 fn test_font_family_default() {
512 assert_eq!(FontFamily::default(), FontFamily::Roboto);
513 }
514
515 #[test]
516 fn test_font_weight_display() {
517 assert_eq!(format!("{}", FontWeight::Thin), "100");
518 assert_eq!(format!("{}", FontWeight::Light), "300");
519 assert_eq!(format!("{}", FontWeight::Regular), "400");
520 assert_eq!(format!("{}", FontWeight::Medium), "500");
521 assert_eq!(format!("{}", FontWeight::SemiBold), "600");
522 assert_eq!(format!("{}", FontWeight::Bold), "700");
523 assert_eq!(format!("{}", FontWeight::Black), "900");
524 }
525
526 #[test]
527 fn test_font_weight_default() {
528 assert_eq!(FontWeight::default(), FontWeight::Regular);
529 }
530
531 #[test]
532 fn test_text_align_display() {
533 assert_eq!(format!("{}", TextAlign::Start), "start");
534 assert_eq!(format!("{}", TextAlign::Middle), "middle");
535 assert_eq!(format!("{}", TextAlign::End), "end");
536 }
537
538 #[test]
539 fn test_text_align_default() {
540 assert_eq!(TextAlign::default(), TextAlign::Start);
541 }
542
543 #[test]
544 fn test_text_style_with_line_height() {
545 let style = TextStyle::new(14.0, FontWeight::Regular).with_line_height(2.0);
546 assert_eq!(style.line_height, 2.0);
547 }
548
549 #[test]
550 fn test_text_style_with_letter_spacing() {
551 let style = TextStyle::new(14.0, FontWeight::Regular).with_letter_spacing(0.05);
552 assert_eq!(style.letter_spacing, 0.05);
553 }
554
555 #[test]
556 fn test_text_style_default() {
557 let style = TextStyle::default();
558 assert_eq!(style.size, 14.0);
559 assert_eq!(style.weight, FontWeight::Regular);
560 assert_eq!(style.family, FontFamily::Roboto);
561 assert_eq!(style.line_height, 1.5);
562 assert_eq!(style.letter_spacing, 0.0);
563 assert_eq!(style.align, TextAlign::Start);
564 }
565
566 #[test]
567 fn test_text_style_svg_attrs_with_letter_spacing() {
568 let style = TextStyle::new(14.0, FontWeight::Regular).with_letter_spacing(0.05);
569 let attrs = style.to_svg_attrs();
570 assert!(attrs.contains("letter-spacing=\"0.05em\""));
571 }
572
573 #[test]
574 fn test_text_style_svg_attrs_with_alignment() {
575 let style = TextStyle::new(14.0, FontWeight::Regular).with_align(TextAlign::End);
576 let attrs = style.to_svg_attrs();
577 assert!(attrs.contains("text-anchor=\"end\""));
578 }
579
580 #[test]
581 fn test_text_style_svg_attrs_no_optional() {
582 let style = TextStyle::new(14.0, FontWeight::Regular);
583 let attrs = style.to_svg_attrs();
584 assert!(!attrs.contains("letter-spacing"));
585 assert!(!attrs.contains("text-anchor"));
586 }
587
588 #[test]
589 fn test_font_weight_all_values() {
590 assert_eq!(FontWeight::Thin.value(), 100);
591 assert_eq!(FontWeight::Light.value(), 300);
592 assert_eq!(FontWeight::Medium.value(), 500);
593 assert_eq!(FontWeight::SemiBold.value(), 600);
594 assert_eq!(FontWeight::Black.value(), 900);
595 }
596
597 #[test]
598 fn test_font_family_segoe_ui_display() {
599 let display = format!("{}", FontFamily::SegoeUI);
600 assert!(display.contains("Segoe UI"));
601 }
602
603 #[test]
604 fn test_font_family_cascadia_code_display() {
605 let display = format!("{}", FontFamily::CascadiaCode);
606 assert!(display.contains("Cascadia Code"));
607 }
608
609 #[test]
610 fn test_video_typography_dark() {
611 let vt = VideoTypography::dark();
612 assert_eq!(vt.slide_title.size, 56.0);
613 assert_eq!(vt.slide_title.weight, FontWeight::Bold);
614 assert_eq!(vt.slide_title.family, FontFamily::SegoeUI);
615
616 assert_eq!(vt.section_header.size, 36.0);
617 assert_eq!(vt.section_header.weight, FontWeight::SemiBold);
618
619 assert_eq!(vt.body.size, 24.0);
620 assert_eq!(vt.body.weight, FontWeight::Regular);
621
622 assert_eq!(vt.label.size, 18.0);
623 assert!(vt.label.size >= VideoTypography::MIN_FONT_SIZE);
624
625 assert_eq!(vt.code.size, 22.0);
626 assert_eq!(vt.code.family, FontFamily::CascadiaCode);
627
628 assert_eq!(vt.icon_text.size, 18.0);
629 assert_eq!(vt.icon_text.weight, FontWeight::Bold);
630 }
631
632 #[test]
633 fn test_video_typography_light() {
634 let vt = VideoTypography::light();
635 assert_eq!(vt.slide_title.size, 56.0);
636 assert_eq!(vt.body.size, 24.0);
637 assert_eq!(vt.code.family, FontFamily::CascadiaCode);
638 }
639
640 #[test]
641 fn test_video_typography_all_sizes_meet_minimum() {
642 for vt in &[VideoTypography::dark(), VideoTypography::light()] {
643 assert!(vt.slide_title.size >= VideoTypography::MIN_FONT_SIZE);
644 assert!(vt.section_header.size >= VideoTypography::MIN_FONT_SIZE);
645 assert!(vt.body.size >= VideoTypography::MIN_FONT_SIZE);
646 assert!(vt.label.size >= VideoTypography::MIN_FONT_SIZE);
647 assert!(vt.code.size >= VideoTypography::MIN_FONT_SIZE);
648 assert!(vt.icon_text.size >= VideoTypography::MIN_FONT_SIZE);
649 }
650 }
651
652 #[test]
653 fn test_video_typography_min_font_size_constant() {
654 assert_eq!(VideoTypography::MIN_FONT_SIZE, 18.0);
655 }
656}