1use std::collections::HashSet;
4use std::sync::{Arc, Mutex};
5
6use crate::Rgba;
7use native_theme_derive::ThemeFields;
8use serde::{Deserialize, Serialize};
9
10static FONT_FAMILY_CACHE: std::sync::LazyLock<Mutex<HashSet<Arc<str>>>> =
15 std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
16
17pub fn intern_font_family(family: &str) -> Arc<str> {
43 if let Ok(mut cache) = FONT_FAMILY_CACHE.lock() {
44 if let Some(existing) = cache.get(family) {
45 return Arc::clone(existing);
46 }
47 let arc: Arc<str> = Arc::from(family);
48 cache.insert(Arc::clone(&arc));
49 arc
50 } else {
51 Arc::from(family)
53 }
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum FontStyle {
60 #[default]
62 Normal,
63 Italic,
65 Oblique,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
79pub enum FontSize {
80 Pt(f32),
85 Px(f32),
89}
90
91impl FontSize {
92 pub fn to_logical_px(self, dpi: f32) -> f32 {
116 match self {
117 Self::Pt(v) => v * dpi / 72.0,
118 Self::Px(v) => v,
119 }
120 }
121
122 pub fn raw(self) -> f32 {
126 match self {
127 Self::Pt(v) | Self::Px(v) => v,
128 }
129 }
130
131 pub fn is_pt(self) -> bool {
133 matches!(self, Self::Pt(_))
134 }
135}
136
137impl Default for FontSize {
138 fn default() -> Self {
139 Self::Px(0.0)
140 }
141}
142
143#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
154#[serde(try_from = "FontSpecRaw", into = "FontSpecRaw")]
155#[theme_layer(fields = "family, size_pt, size_px, weight, style, color")]
160pub struct FontSpec {
161 pub family: Option<Arc<str>>,
163 pub size: Option<FontSize>,
169 pub weight: Option<u16>,
171 pub style: Option<FontStyle>,
173 pub color: Option<Rgba>,
175}
176
177#[serde_with::skip_serializing_none]
179#[derive(Default, Serialize, Deserialize)]
180#[serde(default)]
181struct FontSpecRaw {
182 family: Option<Arc<str>>,
183 size_pt: Option<f32>,
184 size_px: Option<f32>,
185 weight: Option<u16>,
186 style: Option<FontStyle>,
187 color: Option<Rgba>,
188}
189
190impl TryFrom<FontSpecRaw> for FontSpec {
191 type Error = String;
192 fn try_from(raw: FontSpecRaw) -> Result<Self, Self::Error> {
193 let size = match (raw.size_pt, raw.size_px) {
194 (Some(v), None) => Some(FontSize::Pt(v)),
195 (None, Some(v)) => Some(FontSize::Px(v)),
196 (None, None) => None,
197 (Some(_), Some(_)) => return Err("font: set `size_pt` or `size_px`, not both".into()),
198 };
199 Ok(FontSpec {
200 family: raw.family,
201 size,
202 weight: raw.weight,
203 style: raw.style,
204 color: raw.color,
205 })
206 }
207}
208
209impl From<FontSpec> for FontSpecRaw {
210 fn from(fs: FontSpec) -> Self {
211 let (size_pt, size_px) = match fs.size {
212 Some(FontSize::Pt(v)) => (Some(v), None),
213 Some(FontSize::Px(v)) => (None, Some(v)),
214 None => (None, None),
215 };
216 FontSpecRaw {
217 family: fs.family,
218 size_pt,
219 size_px,
220 weight: fs.weight,
221 style: fs.style,
222 color: fs.color,
223 }
224 }
225}
226
227impl_merge!(FontSpec {
228 option { family, size, weight, style, color }
229});
230
231#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
242pub struct ResolvedFontSpec {
243 pub family: Arc<str>,
245 pub size: f32,
249 #[serde(default)]
266 pub defined_size: Option<FontSize>,
267 pub weight: u16,
269 pub style: FontStyle,
271 pub color: Rgba,
273}
274
275#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
280#[serde(try_from = "TextScaleEntryRaw", into = "TextScaleEntryRaw")]
281#[theme_layer(fields = "size_pt, size_px, weight, line_height_pt, line_height_px")]
285pub struct TextScaleEntry {
286 pub size: Option<FontSize>,
291 pub weight: Option<u16>,
293 pub line_height: Option<FontSize>,
296}
297
298#[serde_with::skip_serializing_none]
300#[derive(Default, Serialize, Deserialize)]
301#[serde(default)]
302struct TextScaleEntryRaw {
303 size_pt: Option<f32>,
304 size_px: Option<f32>,
305 weight: Option<u16>,
306 line_height_pt: Option<f32>,
307 line_height_px: Option<f32>,
308}
309
310impl TryFrom<TextScaleEntryRaw> for TextScaleEntry {
311 type Error = String;
312 fn try_from(raw: TextScaleEntryRaw) -> Result<Self, Self::Error> {
313 let size = match (raw.size_pt, raw.size_px) {
314 (Some(v), None) => Some(FontSize::Pt(v)),
315 (None, Some(v)) => Some(FontSize::Px(v)),
316 (None, None) => None,
317 (Some(_), Some(_)) => {
318 return Err("text_scale: set `size_pt` or `size_px`, not both".into());
319 }
320 };
321 let line_height = match (raw.line_height_pt, raw.line_height_px) {
322 (Some(v), None) => Some(FontSize::Pt(v)),
323 (None, Some(v)) => Some(FontSize::Px(v)),
324 (None, None) => None,
325 (Some(_), Some(_)) => {
326 return Err(
327 "text_scale: set `line_height_pt` or `line_height_px`, not both".into(),
328 );
329 }
330 };
331 if let (Some(s), Some(lh)) = (&size, &line_height)
332 && s.is_pt() != lh.is_pt()
333 {
334 return Err(
335 "text_scale: size and line_height must use the same unit suffix (_pt or _px)"
336 .into(),
337 );
338 }
339 Ok(TextScaleEntry {
340 size,
341 weight: raw.weight,
342 line_height,
343 })
344 }
345}
346
347impl From<TextScaleEntry> for TextScaleEntryRaw {
348 fn from(e: TextScaleEntry) -> Self {
349 let (size_pt, size_px) = match e.size {
350 Some(FontSize::Pt(v)) => (Some(v), None),
351 Some(FontSize::Px(v)) => (None, Some(v)),
352 None => (None, None),
353 };
354 let (line_height_pt, line_height_px) = match e.line_height {
355 Some(FontSize::Pt(v)) => (Some(v), None),
356 Some(FontSize::Px(v)) => (None, Some(v)),
357 None => (None, None),
358 };
359 TextScaleEntryRaw {
360 size_pt,
361 size_px,
362 weight: e.weight,
363 line_height_pt,
364 line_height_px,
365 }
366 }
367}
368
369impl_merge!(TextScaleEntry {
370 option { size, weight, line_height }
371});
372
373#[serde_with::skip_serializing_none]
378#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
379#[serde(default)]
380pub struct TextScale {
381 pub caption: Option<TextScaleEntry>,
383 pub section_heading: Option<TextScaleEntry>,
385 pub dialog_title: Option<TextScaleEntry>,
387 pub display: Option<TextScaleEntry>,
389}
390
391impl_merge!(TextScale {
392 optional_nested { caption, section_heading, dialog_title, display }
393});
394
395#[cfg(test)]
396#[allow(clippy::unwrap_used, clippy::expect_used)]
397mod tests {
398 use super::FontSize;
399 use super::*;
400
401 #[test]
404 fn font_spec_default_is_empty() {
405 assert!(FontSpec::default().is_empty());
406 }
407
408 #[test]
409 fn font_spec_not_empty_when_family_set() {
410 let fs = FontSpec {
411 family: Some("Inter".into()),
412 ..Default::default()
413 };
414 assert!(!fs.is_empty());
415 }
416
417 #[test]
418 fn font_spec_not_empty_when_size_set() {
419 let fs = FontSpec {
420 size: Some(FontSize::Px(14.0)),
421 ..Default::default()
422 };
423 assert!(!fs.is_empty());
424 }
425
426 #[test]
427 fn font_spec_not_empty_when_weight_set() {
428 let fs = FontSpec {
429 weight: Some(700),
430 ..Default::default()
431 };
432 assert!(!fs.is_empty());
433 }
434
435 #[test]
436 fn font_spec_toml_round_trip() {
437 let fs = FontSpec {
438 family: Some("Inter".into()),
439 size: Some(FontSize::Px(14.0)),
440 weight: Some(400),
441 ..Default::default()
442 };
443 let toml_str = toml::to_string(&fs).unwrap();
444 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
445 assert_eq!(deserialized, fs);
446 }
447
448 #[test]
449 fn font_spec_toml_round_trip_partial() {
450 let fs = FontSpec {
451 family: Some("Inter".into()),
452 size: None,
453 weight: None,
454 ..Default::default()
455 };
456 let toml_str = toml::to_string(&fs).unwrap();
457 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
458 assert_eq!(deserialized, fs);
459 assert!(deserialized.size.is_none());
460 assert!(deserialized.weight.is_none());
461 }
462
463 #[test]
464 fn font_spec_merge_overlay_family_replaces_base() {
465 let mut base = FontSpec {
466 family: Some("Noto Sans".into()),
467 size: Some(FontSize::Px(12.0)),
468 weight: None,
469 ..Default::default()
470 };
471 let overlay = FontSpec {
472 family: Some("Inter".into()),
473 size: None,
474 weight: None,
475 ..Default::default()
476 };
477 base.merge(&overlay);
478 assert_eq!(base.family.as_deref(), Some("Inter"));
479 assert_eq!(base.size, Some(FontSize::Px(12.0)));
481 }
482
483 #[test]
484 fn font_spec_merge_none_preserves_base() {
485 let mut base = FontSpec {
486 family: Some("Noto Sans".into()),
487 size: Some(FontSize::Px(12.0)),
488 weight: Some(400),
489 ..Default::default()
490 };
491 let overlay = FontSpec::default();
492 base.merge(&overlay);
493 assert_eq!(base.family.as_deref(), Some("Noto Sans"));
494 assert_eq!(base.size, Some(FontSize::Px(12.0)));
495 assert_eq!(base.weight, Some(400));
496 }
497
498 #[test]
501 fn text_scale_entry_default_is_empty() {
502 assert!(TextScaleEntry::default().is_empty());
503 }
504
505 #[test]
506 fn text_scale_entry_toml_round_trip() {
507 let entry = TextScaleEntry {
508 size: Some(FontSize::Px(12.0)),
509 weight: Some(400),
510 line_height: Some(FontSize::Px(1.4)),
511 };
512 let toml_str = toml::to_string(&entry).unwrap();
513 let deserialized: TextScaleEntry = toml::from_str(&toml_str).unwrap();
514 assert_eq!(deserialized, entry);
515 }
516
517 #[test]
518 fn text_scale_entry_merge_overlay_wins() {
519 let mut base = TextScaleEntry {
520 size: Some(FontSize::Px(12.0)),
521 weight: Some(400),
522 line_height: None,
523 };
524 let overlay = TextScaleEntry {
525 size: None,
526 weight: Some(700),
527 line_height: Some(FontSize::Px(1.5)),
528 };
529 base.merge(&overlay);
530 assert_eq!(base.size, Some(FontSize::Px(12.0))); assert_eq!(base.weight, Some(700)); assert_eq!(base.line_height, Some(FontSize::Px(1.5))); }
534
535 #[test]
538 fn text_scale_default_is_empty() {
539 assert!(TextScale::default().is_empty());
540 }
541
542 #[test]
543 fn text_scale_not_empty_when_entry_set() {
544 let ts = TextScale {
545 caption: Some(TextScaleEntry {
546 size: Some(FontSize::Px(11.0)),
547 ..Default::default()
548 }),
549 ..Default::default()
550 };
551 assert!(!ts.is_empty());
552 }
553
554 #[test]
555 fn text_scale_toml_round_trip() {
556 let ts = TextScale {
557 caption: Some(TextScaleEntry {
558 size: Some(FontSize::Px(11.0)),
559 weight: Some(400),
560 line_height: Some(FontSize::Px(1.3)),
561 }),
562 section_heading: Some(TextScaleEntry {
563 size: Some(FontSize::Px(14.0)),
564 weight: Some(600),
565 line_height: Some(FontSize::Px(1.4)),
566 }),
567 dialog_title: Some(TextScaleEntry {
568 size: Some(FontSize::Px(16.0)),
569 weight: Some(700),
570 line_height: Some(FontSize::Px(1.2)),
571 }),
572 display: Some(TextScaleEntry {
573 size: Some(FontSize::Px(24.0)),
574 weight: Some(300),
575 line_height: Some(FontSize::Px(1.1)),
576 }),
577 };
578 let toml_str = toml::to_string(&ts).unwrap();
579 let deserialized: TextScale = toml::from_str(&toml_str).unwrap();
580 assert_eq!(deserialized, ts);
581 }
582
583 #[test]
584 fn text_scale_merge_some_plus_some_merges_inner() {
585 let mut base = TextScale {
586 caption: Some(TextScaleEntry {
587 size: Some(FontSize::Px(11.0)),
588 weight: Some(400),
589 line_height: None,
590 }),
591 ..Default::default()
592 };
593 let overlay = TextScale {
594 caption: Some(TextScaleEntry {
595 size: None,
596 weight: Some(600),
597 line_height: Some(FontSize::Px(1.3)),
598 }),
599 ..Default::default()
600 };
601 base.merge(&overlay);
602 let cap = base.caption.as_ref().unwrap();
603 assert_eq!(cap.size, Some(FontSize::Px(11.0))); assert_eq!(cap.weight, Some(600)); assert_eq!(cap.line_height, Some(FontSize::Px(1.3))); }
607
608 #[test]
609 fn text_scale_merge_none_plus_some_clones_overlay() {
610 let mut base = TextScale::default();
611 let overlay = TextScale {
612 section_heading: Some(TextScaleEntry {
613 size: Some(FontSize::Px(14.0)),
614 ..Default::default()
615 }),
616 ..Default::default()
617 };
618 base.merge(&overlay);
619 assert!(base.section_heading.is_some());
620 assert_eq!(base.section_heading.unwrap().size, Some(FontSize::Px(14.0)));
621 }
622
623 #[test]
624 fn text_scale_merge_none_preserves_base_entry() {
625 let mut base = TextScale {
626 display: Some(TextScaleEntry {
627 size: Some(FontSize::Px(24.0)),
628 ..Default::default()
629 }),
630 ..Default::default()
631 };
632 let overlay = TextScale::default();
633 base.merge(&overlay);
634 assert!(base.display.is_some());
635 assert_eq!(base.display.unwrap().size, Some(FontSize::Px(24.0)));
636 }
637
638 #[test]
641 fn resolved_font_clone_shares_family_arc() {
642 let font = ResolvedFontSpec {
643 family: Arc::from("Inter"),
644 size: 14.0,
645 defined_size: Some(FontSize::Px(14.0)),
646 weight: 400,
647 style: FontStyle::Normal,
648 color: crate::Rgba::rgb(0, 0, 0),
649 };
650 let cloned = font.clone();
651 assert!(std::ptr::eq(
653 font.family.as_ref() as *const str,
654 cloned.family.as_ref() as *const str,
655 ));
656 }
657
658 #[test]
661 fn font_style_default_is_normal() {
662 assert_eq!(FontStyle::default(), FontStyle::Normal);
663 }
664
665 #[test]
666 fn font_style_serde_round_trip() {
667 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
669 struct Wrapper {
670 style: FontStyle,
671 }
672
673 for (variant, expected_str) in [
674 (FontStyle::Normal, "normal"),
675 (FontStyle::Italic, "italic"),
676 (FontStyle::Oblique, "oblique"),
677 ] {
678 let original = Wrapper { style: variant };
679 let serialized = toml::to_string(&original).unwrap();
680 assert!(serialized.contains(expected_str), "got: {serialized}");
681 let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
682 assert_eq!(deserialized, original);
683 }
684 }
685
686 #[test]
687 fn font_spec_with_style_and_color_round_trip() {
688 let fs = FontSpec {
689 family: Some("Inter".into()),
690 size: Some(FontSize::Px(14.0)),
691 weight: Some(400),
692 style: Some(FontStyle::Italic),
693 color: Some(crate::Rgba::rgb(255, 0, 0)),
694 };
695 let toml_str = toml::to_string(&fs).unwrap();
696 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
697 assert_eq!(deserialized, fs);
698 }
699
700 #[test]
701 fn font_spec_style_none_preserved() {
702 let fs = FontSpec {
703 family: Some("Inter".into()),
704 style: None,
705 ..Default::default()
706 };
707 let toml_str = toml::to_string(&fs).unwrap();
708 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
709 assert!(deserialized.style.is_none());
710 }
711
712 #[test]
713 fn font_spec_merge_includes_style_and_color() {
714 let mut base = FontSpec {
715 family: Some("Noto Sans".into()),
716 style: Some(FontStyle::Normal),
717 color: Some(crate::Rgba::rgb(0, 0, 0)),
718 ..Default::default()
719 };
720 let overlay = FontSpec {
721 style: Some(FontStyle::Italic),
722 ..Default::default()
723 };
724 base.merge(&overlay);
725 assert_eq!(base.style, Some(FontStyle::Italic)); assert_eq!(base.color, Some(crate::Rgba::rgb(0, 0, 0))); assert_eq!(base.family.as_deref(), Some("Noto Sans")); }
729
730 #[test]
733 fn pt_to_logical_px_at_96_dpi() {
734 assert_eq!(FontSize::Pt(10.0).to_logical_px(96.0), 10.0 * 96.0 / 72.0);
735 }
736
737 #[test]
738 fn px_ignores_dpi() {
739 assert_eq!(FontSize::Px(14.0).to_logical_px(96.0), 14.0);
740 assert_eq!(FontSize::Px(14.0).to_logical_px(144.0), 14.0);
741 }
742
743 #[test]
744 fn pt_to_logical_px_at_72_dpi_is_identity() {
745 assert_eq!(FontSize::Pt(10.0).to_logical_px(72.0), 10.0);
746 }
747
748 #[test]
749 fn raw_extracts_value() {
750 assert_eq!(FontSize::Pt(10.0).raw(), 10.0);
751 assert_eq!(FontSize::Px(14.0).raw(), 14.0);
752 }
753
754 #[test]
755 fn font_size_default_is_px_zero() {
756 assert_eq!(FontSize::default(), FontSize::Px(0.0));
757 }
758
759 #[test]
762 fn fontspec_toml_round_trip_size_pt() {
763 let fs = FontSpec {
764 family: Some("Inter".into()),
765 size: Some(FontSize::Pt(10.0)),
766 weight: Some(400),
767 ..Default::default()
768 };
769 let toml_str = toml::to_string(&fs).expect("serialize");
770 assert!(
771 toml_str.contains("size_pt"),
772 "should contain size_pt: {toml_str}"
773 );
774 assert!(
775 !toml_str.contains("size_px"),
776 "should not contain size_px: {toml_str}"
777 );
778 let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
779 assert_eq!(deserialized, fs);
780 }
781
782 #[test]
783 fn fontspec_toml_round_trip_size_px() {
784 let fs = FontSpec {
785 size: Some(FontSize::Px(14.0)),
786 ..Default::default()
787 };
788 let toml_str = toml::to_string(&fs).expect("serialize");
789 assert!(
790 toml_str.contains("size_px"),
791 "should contain size_px: {toml_str}"
792 );
793 assert!(
794 !toml_str.contains("size_pt"),
795 "should not contain size_pt: {toml_str}"
796 );
797 let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
798 assert_eq!(deserialized, fs);
799 }
800
801 #[test]
802 fn fontspec_toml_rejects_both_pt_and_px() {
803 let toml_str = "size_pt = 10.0\nsize_px = 14.0\n";
804 assert!(toml::from_str::<FontSpec>(toml_str).is_err());
805 }
806
807 #[test]
808 fn fontspec_toml_rejects_bare_size() {
809 let toml_str = "size = 10.0\n";
810 let result: FontSpec = toml::from_str(toml_str).expect("deserialize");
814 assert!(
815 result.size.is_none(),
816 "bare 'size' should not set FontSpec.size"
817 );
818 }
819
820 #[test]
821 fn fontspec_toml_no_size_is_valid() {
822 let fs: FontSpec = toml::from_str(r#"family = "Inter""#).expect("deserialize");
823 assert!(fs.size.is_none());
824 }
825
826 #[test]
827 fn text_scale_entry_toml_round_trip_size_pt() {
828 let entry = TextScaleEntry {
829 size: Some(FontSize::Pt(9.0)),
830 weight: Some(400),
831 line_height: Some(FontSize::Pt(12.6)),
832 };
833 let toml_str = toml::to_string(&entry).expect("serialize");
834 assert!(toml_str.contains("size_pt"));
835 let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
836 assert_eq!(deserialized, entry);
837 }
838
839 #[test]
840 fn text_scale_entry_toml_round_trip_size_px() {
841 let entry = TextScaleEntry {
842 size: Some(FontSize::Px(14.0)),
843 weight: Some(400),
844 line_height: Some(FontSize::Px(18.0)),
845 };
846 let toml_str = toml::to_string(&entry).expect("serialize");
847 assert!(toml_str.contains("size_px"));
848 let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
849 assert_eq!(deserialized, entry);
850 }
851}